From c3fed5226a26b80d83fffe37854d4bccd37d0188 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Wed, 8 Apr 2026 00:18:09 +0200 Subject: [PATCH] feat: + initialize project with docker-compose infrastructure and server application logic --- app/profile/billing.tsx | 1968 +++++++++++++++++++-------------------- server/index.js | 41 +- 2 files changed, 1020 insertions(+), 989 deletions(-) diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index 43faed7..63d0f22 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -1,984 +1,984 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Ionicons } from '@expo/vector-icons'; -import { useRouter } from 'expo-router'; -import Constants from 'expo-constants'; -import Purchases, { - PACKAGE_TYPE, - PRODUCT_CATEGORY, - PurchasesOffering, - PurchasesPackage, - PurchasesStoreProduct, -} from 'react-native-purchases'; -import { useApp } from '../../context/AppContext'; -import { useColors } from '../../constants/Colors'; -import { ThemeBackdrop } from '../../components/ThemeBackdrop'; -import { Language } from '../../types'; -import { PurchaseProductId } from '../../services/backend/contracts'; - -type SubscriptionProductId = 'monthly_pro' | 'yearly_pro'; -type TopupProductId = Extract; -type SubscriptionPackages = Partial>; -type TopupProducts = Partial>; - -const isMatchingPackage = ( - pkg: PurchasesPackage, - productId: SubscriptionProductId, - expectedPackageType: PACKAGE_TYPE, -) => { - return ( - pkg.product.identifier === productId - || pkg.identifier === productId - || pkg.packageType === expectedPackageType - ); -}; - -const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => { - if (!offering) { - return {}; - } - - const availablePackages = [ - offering.monthly, - offering.annual, - ...offering.availablePackages, - ].filter((value): value is PurchasesPackage => Boolean(value)); - - return { - monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)), - yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)), - }; -}; - -const summarizeOfferingPackages = (offering: PurchasesOffering | null) => { - if (!offering) { - return { identifier: null, packages: [] as Array> }; - } - - return { - identifier: offering.identifier, - packages: offering.availablePackages.map((pkg) => ({ - identifier: pkg.identifier, - packageType: pkg.packageType, - productIdentifier: pkg.product.identifier, - priceString: pkg.product.priceString, - })), - }; -}; - -const getBillingCopy = (language: Language) => { - if (language === 'de') { - return { - title: 'Abo und Credits', - planLabel: 'Aktueller Plan', - planFree: 'Free', - planPro: 'Pro', - creditsAvailableLabel: 'Verfügbare Credits', - manageSubscription: 'Abo verwalten', - subscriptionTitle: 'Abos', - subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.', - freePlanName: 'Free', - freePlanPrice: '0 EUR / Monat', - proPlanName: 'Pro', - proPlanPrice: '4,99 € / Monat', - proBadgeText: 'EMPFOHLEN', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39,99 € / Jahr', - proYearlyBadgeText: 'SPAREN', - proBenefits: [ - '250 Credits jeden Monat', - 'Pro-Scans mit GPT-5.4', - 'Unbegrenzte Historie & Galerie', - 'KI-Pflanzendoktor inklusive', - 'Priorisierter Support' - ], - topupTitle: 'Credits Aufladen', - topupSmall: '25 Credits – 1,99 €', - topupMedium: '120 Credits – 6,99 €', - topupLarge: '300 Credits – 12,99 €', - topupBestValue: 'BESTES ANGEBOT', - cancelTitle: 'Schade, dass du gehst', - cancelQuestion: 'Dürfen wir fragen, warum du kündigst?', - reasonTooExpensive: 'Es ist mir zu teuer', - reasonNotUsing: 'Ich nutze die App zu selten', - reasonOther: 'Ein anderer Grund', - offerTitle: 'Ein Geschenk für dich!', - offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).', - offerAccept: 'Rabatt sichern', - offerDecline: 'Nein, Kündigung fortsetzen', - confirmCancelBtn: 'Jetzt kündigen', - restorePurchases: 'Käufe wiederherstellen', - autoRenewMonthly: 'Verlängert sich monatlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', - autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', - manageInSettings: 'In iOS-Einstellungen verwalten', - }; - } else if (language === 'es') { - return { - title: 'Suscripción y Créditos', - planLabel: 'Plan Actual', - planFree: 'Gratis', - planPro: 'Pro', - creditsAvailableLabel: 'Créditos Disponibles', - manageSubscription: 'Administrar Suscripción', - subscriptionTitle: 'Suscripciones', - subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.', - freePlanName: 'Gratis', - freePlanPrice: '0 EUR / Mes', - proPlanName: 'Pro', - proPlanPrice: '4.99 EUR / Mes', - proBadgeText: 'RECOMENDADO', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / Año', - proYearlyBadgeText: 'AHORRAR', - proBenefits: [ - '250 créditos cada mes', - 'Escaneos Pro con GPT-5.4', - 'Historial y galería ilimitados', - 'Doctor de plantas de IA incluido', - 'Soporte prioritario' - ], - topupTitle: 'Recargar Créditos', - topupSmall: '25 Créditos – 1,99 €', - topupMedium: '120 Créditos – 6,99 €', - topupLarge: '300 Créditos – 12,99 €', - topupBestValue: 'MEJOR OFERTA', - cancelTitle: 'Lamentamos verte ir', - cancelQuestion: '¿Podemos saber por qué cancelas?', - reasonTooExpensive: 'Es muy caro', - reasonNotUsing: 'No lo uso suficiente', - reasonOther: 'Otra razón', - offerTitle: '¡Un regalo para ti!', - offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).', - offerAccept: 'Aceptar descuento', - offerDecline: 'No, continuar cancelando', - confirmCancelBtn: 'Cancelar ahora', - restorePurchases: 'Restaurar Compras', - autoRenewMonthly: 'Se renueva mensualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', - autoRenewYearly: 'Se renueva anualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', - manageInSettings: 'Administrar en Ajustes de iOS', - }; - } - return { - title: 'Billing & Credits', - planLabel: 'Current Plan', - planFree: 'Free', - planPro: 'Pro', - creditsAvailableLabel: 'Available Credits', - manageSubscription: 'Manage Subscription', - subscriptionTitle: 'Subscriptions', - subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.', - freePlanName: 'Free', - freePlanPrice: '0 EUR / Month', - proPlanName: 'Pro', - proPlanPrice: '4.99 EUR / Month', - proBadgeText: 'RECOMMENDED', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / Year', - proYearlyBadgeText: 'SAVE', - proBenefits: [ - '250 credits every month', - 'Pro scans with GPT-5.4', - 'Unlimited history & gallery', - 'AI Plant Doctor included', - 'Priority support' - ], - topupTitle: 'Topup Credits', - topupSmall: '25 Credits – €1.99', - topupMedium: '120 Credits – €6.99', - topupLarge: '300 Credits – €12.99', - topupBestValue: 'BEST VALUE', - cancelTitle: 'Sorry to see you go', - cancelQuestion: 'May we ask why you are cancelling?', - reasonTooExpensive: 'It is too expensive', - reasonNotUsing: 'I don\'t use it enough', - reasonOther: 'Other reason', - offerTitle: 'A gift for you!', - offerText: 'Stay with us and get your next month for just €2.49 (50% off).', - offerAccept: 'Claim discount', - offerDecline: 'No, continue cancelling', - confirmCancelBtn: 'Cancel now', - restorePurchases: 'Restore Purchases', - autoRenewMonthly: 'Auto-renews monthly. Cancel anytime in iOS Settings.', - autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.', - manageInSettings: 'Manage in iOS Settings', - }; -}; - - - -export default function BillingScreen() { - const router = useRouter(); - const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const copy = getBillingCopy(language); - const isExpoGo = Constants.appOwnership === 'expo'; - - const [subModalVisible, setSubModalVisible] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); - const [storeReady, setStoreReady] = useState(isExpoGo); - const [subscriptionPackages, setSubscriptionPackages] = useState({}); - const [topupProducts, setTopupProducts] = useState({}); - - // Cancel Flow State - const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); - - const planId = billingSummary?.entitlement?.plan || 'free'; - const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--'); - - useEffect(() => { - let cancelled = false; - - const loadStoreProducts = async () => { - if (isExpoGo) { - setStoreReady(true); - return; - } - - try { - const [offerings, topups] = await Promise.all([ - Purchases.getOfferings(), - Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), - ]); - - if (cancelled) return; - - const currentOffering = offerings.current; - const resolvedPackages = resolveSubscriptionPackages(currentOffering); - if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) { - console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering)); - } - - setSubscriptionPackages(resolvedPackages); - - setTopupProducts({ - topup_small: topups.find((product) => product.identifier === 'topup_small'), - topup_medium: topups.find((product) => product.identifier === 'topup_medium'), - topup_large: topups.find((product) => product.identifier === 'topup_large'), - }); - } catch (error) { - console.warn('Failed to load RevenueCat products', error); - } finally { - if (!cancelled) { - setStoreReady(true); - } - } - }; - - loadStoreProducts(); - - return () => { - cancelled = true; - }; - }, [isExpoGo]); - - const monthlyPackage = subscriptionPackages.monthly_pro; - const yearlyPackage = subscriptionPackages.yearly_pro; - - const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice; - const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; - - const topupLabels = useMemo(() => ({ - topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall, - topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium, - topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge, - }), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]); - - const openAppleSubscriptions = async () => { - await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions'); - }; - - const handlePurchase = async (productId: PurchaseProductId) => { - setIsUpdating(true); - try { - if (isExpoGo) { - // ExpoGo has no native RevenueCat — use simulation for development only - await simulatePurchase(productId); - } else { - if (productId === 'monthly_pro' || productId === 'yearly_pro') { - if (planId === 'pro') { - await openAppleSubscriptions(); - setSubModalVisible(false); - return; - } - const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage; - const latestOffering = !selectedPackage - ? await Purchases.getOfferings().then((offerings) => offerings.current) - : null; - if (!selectedPackage) { - console.warn('[Billing] Purchase blocked because subscription package was not resolved', { - productId, - offering: summarizeOfferingPackages(latestOffering), - }); - throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); - } - await Purchases.purchasePackage(selectedPackage); - // Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3) - const customerInfo = await Purchases.getCustomerInfo(); - await syncRevenueCatState(customerInfo as any, 'subscription_purchase'); - } else { - const selectedProduct = topupProducts[productId]; - if (!selectedProduct) { - throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.'); - } - await Purchases.purchaseStoreProduct(selectedProduct); - const customerInfo = await Purchases.getCustomerInfo(); - await syncRevenueCatState(customerInfo as any, 'topup_purchase'); - } - } - setSubModalVisible(false); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled); - - if (userCancelled) { - return; - } - - // RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already - // owns this subscription on a different GreenLens account. Silently dismiss; - // the current account stays free. The user can restore via "Käufe wiederherstellen". - const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record).code : undefined; - if (rcErrorCode === 7) { - setSubModalVisible(false); - return; - } - - console.error('Payment failed', e); - Alert.alert('Unerwarteter Fehler', msg); - } finally { - setIsUpdating(false); - } - }; - - const handleRestore = async () => { - setIsUpdating(true); - try { - if (!isExpoGo) { - const customerInfo = await Purchases.restorePurchases(); - await syncRevenueCatState(customerInfo as any, 'restore'); - } - Alert.alert(copy.restorePurchases, '✓'); - } catch (e) { - Alert.alert('Error', e instanceof Error ? e.message : String(e)); - } finally { - setIsUpdating(false); - } - }; - - const handleDowngrade = async () => { - if (planId === 'free') return; - if (!isExpoGo) { - await openAppleSubscriptions(); - return; - } - // Expo Go / dev only: simulate cancel flow - setCancelStep('survey'); - }; - - const finalizeCancel = async () => { - setIsUpdating(true); - try { - await simulateWebhookEvent('entitlement_revoked'); - setCancelStep('none'); - setSubModalVisible(false); - } catch (e) { - console.error('Downgrade failed', e); - } finally { - setIsUpdating(false); - } - }; - - return ( - - - - - router.back()} style={styles.backButton}> - - - {copy.title} - - - - - {isLoadingBilling && session ? ( - - ) : ( - <> - {session && ( - - {copy.planLabel} - - - {planId === 'pro' ? copy.planPro : copy.planFree} - - setSubModalVisible(true)} - > - {copy.manageSubscription} - - - - {copy.creditsAvailableLabel} - {credits} - - )} - {!session && ( - - Subscription Plans - - Choose a plan to unlock AI plant scans and care features. - - - {/* Monthly */} - - - GreenLens Pro - - MONTHLY - - - {monthlyPrice} - {copy.autoRenewMonthly} - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - handlePurchase('monthly_pro')} - disabled={isUpdating || !storeReady} - > - Subscribe Monthly - - - - {/* Yearly */} - - - GreenLens Pro - - YEARLY - - - {yearlyPrice} - {copy.autoRenewYearly} - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - handlePurchase('yearly_pro')} - disabled={isUpdating || !storeReady} - > - Subscribe Yearly - - - - - Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · - Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - )} - - - {copy.topupTitle} - - {([ - { id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small }, - { id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue }, - { id: 'topup_large' as PurchaseProductId, label: topupLabels.topup_large }, - ] as { id: PurchaseProductId; label: string; badge?: string }[]).map((pack) => ( - handlePurchase(pack.id)} - disabled={isUpdating || !storeReady} - > - - - - {isUpdating ? '...' : pack.label} - - - {pack.badge && ( - - {pack.badge} - - )} - - ))} - - - Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · - Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - - )} - - - - setSubModalVisible(false)}> - - - - - {cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle} - - { - setSubModalVisible(false); - setCancelStep('none'); - }}> - - - - - {cancelStep === 'none' ? ( - <> - {copy.subscriptionHint} - - - - {copy.freePlanName} - {copy.freePlanPrice} - - {planId === 'free' && } - - - handlePurchase('monthly_pro')} - disabled={isUpdating || !storeReady} - > - - - {copy.proPlanName} - - {copy.proBadgeText} - - - {monthlyPrice} - {copy.autoRenewMonthly} - - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - - {planId === 'pro' && } - - - handlePurchase('yearly_pro')} - disabled={isUpdating || !storeReady} - > - - - {copy.proYearlyPlanName} - - {copy.proYearlyBadgeText} - - - {yearlyPrice} - {copy.autoRenewYearly} - - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - - {planId === 'pro' && } - - - - Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · - Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - ) : cancelStep === 'survey' ? ( - - {copy.cancelQuestion} - - {[ - { id: 'expensive', label: copy.reasonTooExpensive, icon: 'cash-outline' }, - { id: 'not_using', label: copy.reasonNotUsing, icon: 'calendar-outline' }, - { id: 'other', label: copy.reasonOther, icon: 'ellipsis-horizontal-outline' }, - ].map((reason) => ( - { - setCancelStep('offer'); - }} - > - - - - {reason.label} - - - ))} - - - ) : ( - - - - - - {copy.offerText} - - { - // Handle applying discount here (future implementation) - Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)'); - setCancelStep('none'); - setSubModalVisible(false); - }} - > - {copy.offerAccept} - - - - - {copy.offerDecline} - - - )} - {(isUpdating || (!storeReady && cancelStep === 'none')) && } - - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { flex: 1 }, - header: { flexDirection: 'row', alignItems: 'center', padding: 16 }, - backButton: { width: 40, height: 40, justifyContent: 'center' }, - title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' }, - scrollContent: { padding: 16, gap: 16 }, - card: { - padding: 16, - borderRadius: 16, - borderWidth: StyleSheet.hairlineWidth, - }, - sectionTitle: { - fontSize: 14, - fontWeight: '600', - textTransform: 'uppercase', - letterSpacing: 0.5, - marginBottom: 8, - }, - row: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - value: { - fontSize: 18, - fontWeight: '600', - }, - manageBtn: { - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - }, - manageBtnText: { - color: '#fff', - fontSize: 14, - fontWeight: '600', - }, - creditsValue: { - fontSize: 32, - fontWeight: '700', - }, - topupBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - borderRadius: 12, - borderWidth: 2, - gap: 8, - }, - topupText: { - fontSize: 16, - fontWeight: '600', - }, - modalOverlay: { - flex: 1, - backgroundColor: '#00000080', - justifyContent: 'flex-end', - }, - modalContent: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - padding: 24, - borderTopWidth: StyleSheet.hairlineWidth, - paddingBottom: 40, - }, - modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - modalTitle: { - fontSize: 20, - fontWeight: '700', - }, - modalHint: { - fontSize: 14, - marginBottom: 24, - }, - plansContainer: { - gap: 12, - }, - planOption: { - padding: 16, - borderRadius: 12, - borderWidth: 2, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - planName: { - fontSize: 18, - fontWeight: '600', - }, - planPrice: { - fontSize: 14, - }, - planHeaderRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginBottom: 2, - }, - proBadge: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 6, - }, - proBadgeText: { - color: '#fff', - fontSize: 10, - fontWeight: '800', - }, - proBenefits: { - marginTop: 12, - gap: 6, - }, - benefitRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - benefitText: { - fontSize: 12, - fontWeight: '500', - }, - cancelFlowContainer: { - marginTop: 8, - }, - cancelHint: { - fontSize: 15, - marginBottom: 16, - }, - reasonList: { - gap: 12, - }, - reasonOption: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderWidth: 1, - borderRadius: 12, - }, - reasonIcon: { - width: 36, - height: 36, - borderRadius: 18, - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }, - reasonText: { - flex: 1, - fontSize: 16, - fontWeight: '500', - }, - offerCard: { - borderRadius: 16, - padding: 24, - alignItems: 'center', - marginBottom: 16, - }, - offerIconWrap: { - width: 56, - height: 56, - borderRadius: 28, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 16, - }, - offerText: { - fontSize: 16, - textAlign: 'center', - lineHeight: 24, - marginBottom: 24, - fontWeight: '500', - }, - offerAcceptBtn: { - paddingHorizontal: 24, - paddingVertical: 14, - borderRadius: 24, - width: '100%', - alignItems: 'center', - }, - offerAcceptBtnText: { - color: '#fff', - fontSize: 16, - fontWeight: '700', - }, - offerDeclineBtn: { - paddingVertical: 12, - alignItems: 'center', - }, - offerDeclineBtnText: { - fontSize: 15, - fontWeight: '500', - }, - guestPlanCard: { - borderWidth: 2, - borderRadius: 12, - padding: 16, - }, - guestPlanHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginBottom: 4, - }, - guestPlanName: { - fontSize: 18, - fontWeight: '700', - }, - guestPlanPrice: { - fontSize: 22, - fontWeight: '700', - marginBottom: 2, - }, - guestPlanRenew: { - fontSize: 12, - }, - guestSubscribeBtn: { - marginTop: 14, - paddingVertical: 12, - borderRadius: 10, - alignItems: 'center', - }, - legalLinksRow: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - marginTop: 16, - }, - legalLink: { - fontSize: 12, - fontWeight: '500', - textDecorationLine: 'underline', - }, - legalSep: { - fontSize: 12, - }, - restoreBtn: { - alignItems: 'center', - paddingVertical: 8, - }, - autoRenewText: { - fontSize: 11, - marginTop: 2, - marginBottom: 4, - }, -}); +import React, { useEffect, useMemo, useState } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import Constants from 'expo-constants'; +import Purchases, { + PACKAGE_TYPE, + PRODUCT_CATEGORY, + PurchasesOffering, + PurchasesPackage, + PurchasesStoreProduct, +} from 'react-native-purchases'; +import { useApp } from '../../context/AppContext'; +import { useColors } from '../../constants/Colors'; +import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { Language } from '../../types'; +import { PurchaseProductId } from '../../services/backend/contracts'; + +type SubscriptionProductId = 'monthly_pro' | 'yearly_pro'; +type TopupProductId = Extract; +type SubscriptionPackages = Partial>; +type TopupProducts = Partial>; + +const isMatchingPackage = ( + pkg: PurchasesPackage, + productId: SubscriptionProductId, + expectedPackageType: PACKAGE_TYPE, +) => { + return ( + pkg.product.identifier === productId + || pkg.identifier === productId + || pkg.packageType === expectedPackageType + ); +}; + +const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => { + if (!offering) { + return {}; + } + + const availablePackages = [ + offering.monthly, + offering.annual, + ...offering.availablePackages, + ].filter((value): value is PurchasesPackage => Boolean(value)); + + return { + monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)), + yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)), + }; +}; + +const summarizeOfferingPackages = (offering: PurchasesOffering | null) => { + if (!offering) { + return { identifier: null, packages: [] as Array> }; + } + + return { + identifier: offering.identifier, + packages: offering.availablePackages.map((pkg) => ({ + identifier: pkg.identifier, + packageType: pkg.packageType, + productIdentifier: pkg.product.identifier, + priceString: pkg.product.priceString, + })), + }; +}; + +const getBillingCopy = (language: Language) => { + if (language === 'de') { + return { + title: 'Abo und Credits', + planLabel: 'Aktueller Plan', + planFree: 'Free', + planPro: 'Pro', + creditsAvailableLabel: 'Verfügbare Credits', + manageSubscription: 'Abo verwalten', + subscriptionTitle: 'Abos', + subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.', + freePlanName: 'Free', + freePlanPrice: '0 EUR / Monat', + proPlanName: 'Pro', + proPlanPrice: '4,99 € / Monat', + proBadgeText: 'EMPFOHLEN', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39,99 € / Jahr', + proYearlyBadgeText: 'SPAREN', + proBenefits: [ + '250 Credits jeden Monat', + 'Pro-Scans mit GPT-5.4', + 'Unbegrenzte Historie & Galerie', + 'KI-Pflanzendoktor inklusive', + 'Priorisierter Support' + ], + topupTitle: 'Credits Aufladen', + topupSmall: '25 Credits – 1,99 €', + topupMedium: '120 Credits – 6,99 €', + topupLarge: '300 Credits – 12,99 €', + topupBestValue: 'BESTES ANGEBOT', + cancelTitle: 'Schade, dass du gehst', + cancelQuestion: 'Dürfen wir fragen, warum du kündigst?', + reasonTooExpensive: 'Es ist mir zu teuer', + reasonNotUsing: 'Ich nutze die App zu selten', + reasonOther: 'Ein anderer Grund', + offerTitle: 'Ein Geschenk für dich!', + offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).', + offerAccept: 'Rabatt sichern', + offerDecline: 'Nein, Kündigung fortsetzen', + confirmCancelBtn: 'Jetzt kündigen', + restorePurchases: 'Käufe wiederherstellen', + autoRenewMonthly: 'Verlängert sich monatlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', + autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', + manageInSettings: 'In iOS-Einstellungen verwalten', + }; + } else if (language === 'es') { + return { + title: 'Suscripción y Créditos', + planLabel: 'Plan Actual', + planFree: 'Gratis', + planPro: 'Pro', + creditsAvailableLabel: 'Créditos Disponibles', + manageSubscription: 'Administrar Suscripción', + subscriptionTitle: 'Suscripciones', + subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.', + freePlanName: 'Gratis', + freePlanPrice: '0 EUR / Mes', + proPlanName: 'Pro', + proPlanPrice: '4.99 EUR / Mes', + proBadgeText: 'RECOMENDADO', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39.99 EUR / Año', + proYearlyBadgeText: 'AHORRAR', + proBenefits: [ + '250 créditos cada mes', + 'Escaneos Pro con GPT-5.4', + 'Historial y galería ilimitados', + 'Doctor de plantas de IA incluido', + 'Soporte prioritario' + ], + topupTitle: 'Recargar Créditos', + topupSmall: '25 Créditos – 1,99 €', + topupMedium: '120 Créditos – 6,99 €', + topupLarge: '300 Créditos – 12,99 €', + topupBestValue: 'MEJOR OFERTA', + cancelTitle: 'Lamentamos verte ir', + cancelQuestion: '¿Podemos saber por qué cancelas?', + reasonTooExpensive: 'Es muy caro', + reasonNotUsing: 'No lo uso suficiente', + reasonOther: 'Otra razón', + offerTitle: '¡Un regalo para ti!', + offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).', + offerAccept: 'Aceptar descuento', + offerDecline: 'No, continuar cancelando', + confirmCancelBtn: 'Cancelar ahora', + restorePurchases: 'Restaurar Compras', + autoRenewMonthly: 'Se renueva mensualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', + autoRenewYearly: 'Se renueva anualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', + manageInSettings: 'Administrar en Ajustes de iOS', + }; + } + return { + title: 'Billing & Credits', + planLabel: 'Current Plan', + planFree: 'Free', + planPro: 'Pro', + creditsAvailableLabel: 'Available Credits', + manageSubscription: 'Manage Subscription', + subscriptionTitle: 'Subscriptions', + subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.', + freePlanName: 'Free', + freePlanPrice: '0 EUR / Month', + proPlanName: 'Pro', + proPlanPrice: '4.99 EUR / Month', + proBadgeText: 'RECOMMENDED', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39.99 EUR / Year', + proYearlyBadgeText: 'SAVE', + proBenefits: [ + '250 credits every month', + 'Pro scans with GPT-5.4', + 'Unlimited history & gallery', + 'AI Plant Doctor included', + 'Priority support' + ], + topupTitle: 'Topup Credits', + topupSmall: '25 Credits – €1.99', + topupMedium: '120 Credits – €6.99', + topupLarge: '300 Credits – €12.99', + topupBestValue: 'BEST VALUE', + cancelTitle: 'Sorry to see you go', + cancelQuestion: 'May we ask why you are cancelling?', + reasonTooExpensive: 'It is too expensive', + reasonNotUsing: 'I don\'t use it enough', + reasonOther: 'Other reason', + offerTitle: 'A gift for you!', + offerText: 'Stay with us and get your next month for just €2.49 (50% off).', + offerAccept: 'Claim discount', + offerDecline: 'No, continue cancelling', + confirmCancelBtn: 'Cancel now', + restorePurchases: 'Restore Purchases', + autoRenewMonthly: 'Auto-renews monthly. Cancel anytime in iOS Settings.', + autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.', + manageInSettings: 'Manage in iOS Settings', + }; +}; + + + +export default function BillingScreen() { + const router = useRouter(); + const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const copy = getBillingCopy(language); + const isExpoGo = Constants.appOwnership === 'expo'; + + const [subModalVisible, setSubModalVisible] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [storeReady, setStoreReady] = useState(isExpoGo); + const [subscriptionPackages, setSubscriptionPackages] = useState({}); + const [topupProducts, setTopupProducts] = useState({}); + + // Cancel Flow State + const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); + + const planId = billingSummary?.entitlement?.plan || 'free'; + const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--'); + + useEffect(() => { + let cancelled = false; + + const loadStoreProducts = async () => { + if (isExpoGo) { + setStoreReady(true); + return; + } + + try { + const [offerings, topups] = await Promise.all([ + Purchases.getOfferings(), + Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), + ]); + + if (cancelled) return; + + const currentOffering = offerings.current; + const resolvedPackages = resolveSubscriptionPackages(currentOffering); + if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) { + console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering)); + } + + setSubscriptionPackages(resolvedPackages); + + setTopupProducts({ + topup_small: topups.find((product) => product.identifier === 'topup_small'), + topup_medium: topups.find((product) => product.identifier === 'topup_medium'), + topup_large: topups.find((product) => product.identifier === 'topup_large'), + }); + } catch (error) { + console.warn('Failed to load RevenueCat products', error); + } finally { + if (!cancelled) { + setStoreReady(true); + } + } + }; + + loadStoreProducts(); + + return () => { + cancelled = true; + }; + }, [isExpoGo]); + + const monthlyPackage = subscriptionPackages.monthly_pro; + const yearlyPackage = subscriptionPackages.yearly_pro; + + const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice; + const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; + + const topupLabels = useMemo(() => ({ + topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall, + topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium, + topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge, + }), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]); + + const openAppleSubscriptions = async () => { + await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions'); + }; + + const handlePurchase = async (productId: PurchaseProductId) => { + setIsUpdating(true); + try { + if (isExpoGo) { + // ExpoGo has no native RevenueCat — use simulation for development only + await simulatePurchase(productId); + } else { + if (productId === 'monthly_pro' || productId === 'yearly_pro') { + if (planId === 'pro') { + await openAppleSubscriptions(); + setSubModalVisible(false); + return; + } + const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage; + const latestOffering = !selectedPackage + ? await Purchases.getOfferings().then((offerings) => offerings.current) + : null; + if (!selectedPackage) { + console.warn('[Billing] Purchase blocked because subscription package was not resolved', { + productId, + offering: summarizeOfferingPackages(latestOffering), + }); + throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); + } + await Purchases.purchasePackage(selectedPackage); + // Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3) + const customerInfo = await Purchases.getCustomerInfo(); + await syncRevenueCatState(customerInfo as any, 'subscription_purchase'); + } else { + const selectedProduct = topupProducts[productId]; + if (!selectedProduct) { + throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.'); + } + await Purchases.purchaseStoreProduct(selectedProduct); + const customerInfo = await Purchases.getCustomerInfo(); + await syncRevenueCatState(customerInfo as any, 'topup_purchase'); + } + } + setSubModalVisible(false); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled); + + if (userCancelled) { + return; + } + + // RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already + // owns this subscription on a different GreenLens account. Silently dismiss; + // the current account stays free. The user can restore via "Käufe wiederherstellen". + const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record).code : undefined; + if (rcErrorCode === 7) { + setSubModalVisible(false); + return; + } + + console.error('Payment failed', e); + Alert.alert('Unerwarteter Fehler', msg); + } finally { + setIsUpdating(false); + } + }; + + const handleRestore = async () => { + setIsUpdating(true); + try { + if (!isExpoGo) { + const customerInfo = await Purchases.restorePurchases(); + await syncRevenueCatState(customerInfo as any, 'restore'); + } + Alert.alert(copy.restorePurchases, '✓'); + } catch (e) { + Alert.alert('Error', e instanceof Error ? e.message : String(e)); + } finally { + setIsUpdating(false); + } + }; + + const handleDowngrade = async () => { + if (planId === 'free') return; + if (!isExpoGo) { + await openAppleSubscriptions(); + return; + } + // Expo Go / dev only: simulate cancel flow + setCancelStep('survey'); + }; + + const finalizeCancel = async () => { + setIsUpdating(true); + try { + await simulateWebhookEvent('entitlement_revoked'); + setCancelStep('none'); + setSubModalVisible(false); + } catch (e) { + console.error('Downgrade failed', e); + } finally { + setIsUpdating(false); + } + }; + + return ( + + + + + router.back()} style={styles.backButton}> + + + {copy.title} + + + + + {isLoadingBilling && session ? ( + + ) : ( + <> + {session && ( + + {copy.planLabel} + + + {planId === 'pro' ? copy.planPro : copy.planFree} + + setSubModalVisible(true)} + > + {copy.manageSubscription} + + + + {copy.creditsAvailableLabel} + {credits} + + )} + {!session && ( + + Subscription Plans + + Choose a plan to unlock AI plant scans and care features. + + + {/* Monthly */} + + + GreenLens Pro + + MONTHLY + + + {monthlyPrice} + {copy.autoRenewMonthly} + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + handlePurchase('monthly_pro')} + disabled={isUpdating || !storeReady} + > + Subscribe Monthly + + + + {/* Yearly */} + + + GreenLens Pro + + YEARLY + + + {yearlyPrice} + {copy.autoRenewYearly} + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + handlePurchase('yearly_pro')} + disabled={isUpdating || !storeReady} + > + Subscribe Yearly + + + + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy Policy + + · + Linking.openURL('https://greenlenspro.com/terms')}> + Terms of Use + + + + {copy.restorePurchases} + + + )} + + + {copy.topupTitle} + + {([ + { id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small }, + { id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue }, + { id: 'topup_large' as PurchaseProductId, label: topupLabels.topup_large }, + ] as { id: PurchaseProductId; label: string; badge?: string }[]).map((pack) => ( + handlePurchase(pack.id)} + disabled={isUpdating || !storeReady} + > + + + + {isUpdating ? '...' : pack.label} + + + {pack.badge && ( + + {pack.badge} + + )} + + ))} + + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy Policy + + · + Linking.openURL('https://greenlenspro.com/terms')}> + Terms of Use + + + + {copy.restorePurchases} + + + + )} + + + + setSubModalVisible(false)}> + + + + + {cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle} + + { + setSubModalVisible(false); + setCancelStep('none'); + }}> + + + + + {cancelStep === 'none' ? ( + <> + {copy.subscriptionHint} + + + + {copy.freePlanName} + {copy.freePlanPrice} + + {planId === 'free' && } + + + handlePurchase('monthly_pro')} + disabled={isUpdating || !storeReady} + > + + + {copy.proPlanName} + + {copy.proBadgeText} + + + {monthlyPrice} + {copy.autoRenewMonthly} + + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + + {planId === 'pro' && } + + + handlePurchase('yearly_pro')} + disabled={isUpdating || !storeReady} + > + + + {copy.proYearlyPlanName} + + {copy.proYearlyBadgeText} + + + {yearlyPrice} + {copy.autoRenewYearly} + + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + + {planId === 'pro' && } + + + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy Policy + + · + Linking.openURL('https://greenlenspro.com/terms')}> + Terms of Use + + + + {copy.restorePurchases} + + + ) : cancelStep === 'survey' ? ( + + {copy.cancelQuestion} + + {[ + { id: 'expensive', label: copy.reasonTooExpensive, icon: 'cash-outline' }, + { id: 'not_using', label: copy.reasonNotUsing, icon: 'calendar-outline' }, + { id: 'other', label: copy.reasonOther, icon: 'ellipsis-horizontal-outline' }, + ].map((reason) => ( + { + setCancelStep('offer'); + }} + > + + + + {reason.label} + + + ))} + + + ) : ( + + + + + + {copy.offerText} + + { + // Handle applying discount here (future implementation) + Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)'); + setCancelStep('none'); + setSubModalVisible(false); + }} + > + {copy.offerAccept} + + + + + {copy.offerDecline} + + + )} + {(isUpdating || (!storeReady && cancelStep === 'none')) && } + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1 }, + header: { flexDirection: 'row', alignItems: 'center', padding: 16 }, + backButton: { width: 40, height: 40, justifyContent: 'center' }, + title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' }, + scrollContent: { padding: 16, gap: 16 }, + card: { + padding: 16, + borderRadius: 16, + borderWidth: StyleSheet.hairlineWidth, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 8, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + value: { + fontSize: 18, + fontWeight: '600', + }, + manageBtn: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + }, + manageBtnText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + creditsValue: { + fontSize: 32, + fontWeight: '700', + }, + topupBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + borderRadius: 12, + borderWidth: 2, + gap: 8, + }, + topupText: { + fontSize: 16, + fontWeight: '600', + }, + modalOverlay: { + flex: 1, + backgroundColor: '#00000080', + justifyContent: 'flex-end', + }, + modalContent: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 24, + borderTopWidth: StyleSheet.hairlineWidth, + paddingBottom: 40, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + modalTitle: { + fontSize: 20, + fontWeight: '700', + }, + modalHint: { + fontSize: 14, + marginBottom: 24, + }, + plansContainer: { + gap: 12, + }, + planOption: { + padding: 16, + borderRadius: 12, + borderWidth: 2, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + planName: { + fontSize: 18, + fontWeight: '600', + }, + planPrice: { + fontSize: 14, + }, + planHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 2, + }, + proBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 6, + }, + proBadgeText: { + color: '#fff', + fontSize: 10, + fontWeight: '800', + }, + proBenefits: { + marginTop: 12, + gap: 6, + }, + benefitRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + benefitText: { + fontSize: 12, + fontWeight: '500', + }, + cancelFlowContainer: { + marginTop: 8, + }, + cancelHint: { + fontSize: 15, + marginBottom: 16, + }, + reasonList: { + gap: 12, + }, + reasonOption: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderWidth: 1, + borderRadius: 12, + }, + reasonIcon: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + reasonText: { + flex: 1, + fontSize: 16, + fontWeight: '500', + }, + offerCard: { + borderRadius: 16, + padding: 24, + alignItems: 'center', + marginBottom: 16, + }, + offerIconWrap: { + width: 56, + height: 56, + borderRadius: 28, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + offerText: { + fontSize: 16, + textAlign: 'center', + lineHeight: 24, + marginBottom: 24, + fontWeight: '500', + }, + offerAcceptBtn: { + paddingHorizontal: 24, + paddingVertical: 14, + borderRadius: 24, + width: '100%', + alignItems: 'center', + }, + offerAcceptBtnText: { + color: '#fff', + fontSize: 16, + fontWeight: '700', + }, + offerDeclineBtn: { + paddingVertical: 12, + alignItems: 'center', + }, + offerDeclineBtnText: { + fontSize: 15, + fontWeight: '500', + }, + guestPlanCard: { + borderWidth: 2, + borderRadius: 12, + padding: 16, + }, + guestPlanHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 4, + }, + guestPlanName: { + fontSize: 18, + fontWeight: '700', + }, + guestPlanPrice: { + fontSize: 22, + fontWeight: '700', + marginBottom: 2, + }, + guestPlanRenew: { + fontSize: 12, + }, + guestSubscribeBtn: { + marginTop: 14, + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + }, + legalLinksRow: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginTop: 16, + }, + legalLink: { + fontSize: 12, + fontWeight: '500', + textDecorationLine: 'underline', + }, + legalSep: { + fontSize: 12, + }, + restoreBtn: { + alignItems: 'center', + paddingVertical: 8, + }, + autoRenewText: { + fontSize: 11, + marginTop: 2, + marginBottom: 4, + }, +}); diff --git a/server/index.js b/server/index.js index 044d385..06c1dfd 100644 --- a/server/index.js +++ b/server/index.js @@ -834,15 +834,46 @@ app.post('/v1/health-check', async (request, response) => { }); const analysis = analysisResponse?.analysis; if (!analysis) { + // All models in the chain failed (timeout, quota, network) — return a graceful + // "unavailable" result instead of PROVIDER_ERROR so the user never sees an error alert. + // Credits are NOT charged. Response is NOT cached so the user can retry. console.warn('Health check analysis was null — all models returned unusable output.', { attemptedModels: analysisResponse?.attemptedModels, modelUsed: analysisResponse?.modelUsed, }); - const error = new Error( - `Health check AI failed. Tried: ${(analysisResponse?.attemptedModels || []).join(', ')}. Verify API key, model access, and network.` - ); - error.code = 'PROVIDER_ERROR'; - throw error; + const unavailableIssue = language === 'de' + ? 'Die KI-Analyse ist gerade nicht verfügbar. Bitte versuche es in einem Moment erneut.' + : language === 'es' + ? 'El análisis de IA no está disponible ahora. Inténtalo de nuevo en un momento.' + : 'AI analysis is temporarily unavailable. Please try again in a moment.'; + const unavailableAction = language === 'de' + ? 'Erneut scannen wenn die Verbindung stabil ist.' + : language === 'es' + ? 'Volver a escanear cuando la conexión sea estable.' + : 'Try scanning again when your connection is stable.'; + const fallbackHealthCheck = { + generatedAt: nowIso(), + overallHealthScore: 50, + status: 'watch', + likelyIssues: [{ + title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable', + confidence: 0.1, + details: unavailableIssue, + }], + actionsNow: [unavailableAction], + plan7Days: [unavailableAction], + creditsCharged: 0, + imageUri, + }; + const fallbackPayload = { + healthCheck: fallbackHealthCheck, + creditsCharged: 0, + modelUsed: null, + modelFallbackCount: Math.max((analysisResponse?.attemptedModels?.length || 0) - 1, 0), + billing: await getBillingSummary(db, userId), + }; + response.status(200).json(fallbackPayload); + return; } let creditsCharged = 0;