diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index 6f8bdb0..39b3d08 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -1,9 +1,9 @@ -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 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, @@ -11,9 +11,9 @@ import Purchases, { PurchasesPackage, PurchasesStoreProduct, } from 'react-native-purchases'; -import { useApp } from '../../context/AppContext'; -import { useColors } from '../../constants/Colors'; -import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +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'; @@ -66,183 +66,183 @@ const summarizeOfferingPackages = (offering: PurchasesOffering | null) => { })), }; }; - -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 EUR / Monat', - proBadgeText: 'EMPFOHLEN', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / 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 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 EUR / Monat', + proBadgeText: 'EMPFOHLEN', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39.99 EUR / 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 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; - + + // 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) { @@ -250,34 +250,34 @@ export default function BillingScreen() { } 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; - + + 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, @@ -287,15 +287,15 @@ export default function BillingScreen() { 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') { + + 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); @@ -310,666 +310,675 @@ export default function BillingScreen() { 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(); + 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 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; - } - - console.error('Payment failed', e); - Alert.alert('Unerwarteter Fehler', msg); - } finally { - setIsUpdating(false); - } - }; - - const handleRestore = async () => { - setIsUpdating(true); - try { - if (!isExpoGo) { + } + 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 () => { + } + 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 - - - - + // 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 - - · + 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} - - )} - - ))} - - + 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 - - · + 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' && } - - - 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' && } - - - + + + {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 - - · + 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, - }, -}); + 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 f08b2e1..70c09ca 100644 --- a/server/index.js +++ b/server/index.js @@ -69,7 +69,7 @@ const SEMANTIC_SEARCH_COST = 2; const HEALTH_CHECK_COST = 2; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; -const DEFAULT_BOOTSTRAP_PLANTS = [ +const DEFAULT_BOOTSTRAP_PLANTS = [ { id: '1', name: 'Monstera Deliciosa', @@ -97,10 +97,18 @@ const DEFAULT_BOOTSTRAP_PLANTS = [ temp: '15-30C', light: 'Low to full light', }, - }, -]; - -let db; + }, +]; + +const FULL_BOOTSTRAP_CATALOG_CANDIDATES = [ + path.join(__dirname, 'data', 'plants_dump_utf8.json'), + path.join(__dirname, '..', 'plants_dump_utf8.json'), +]; +const FULL_BOOTSTRAP_MANIFEST_CANDIDATES = [ + path.join(__dirname, 'public', 'plants', 'manifest.json'), +]; + +let db; const parseBoolean = (value, fallbackValue) => { if (typeof value !== 'string') return fallbackValue; @@ -270,23 +278,161 @@ const ensureRequestAuth = (request) => { const isGuest = (userId) => userId === 'guest'; -const ensureNonEmptyString = (value, fieldName) => { - if (typeof value === 'string' && value.trim()) return value.trim(); - const error = new Error(`${fieldName} is required.`); - error.code = 'BAD_REQUEST'; - throw error; -}; - -const seedBootstrapCatalogIfNeeded = async () => { - const existing = await getPlants(db, { limit: 1 }); - if (existing.length > 0) return; - - await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, { - source: 'bootstrap', - preserveExistingIds: false, - enforceUniqueImages: false, - }); -}; +const ensureNonEmptyString = (value, fieldName) => { + if (typeof value === 'string' && value.trim()) return value.trim(); + const error = new Error(`${fieldName} is required.`); + error.code = 'BAD_REQUEST'; + throw error; +}; + +const readJsonFromCandidates = (filePaths) => { + for (const filePath of filePaths) { + if (!fs.existsSync(filePath)) continue; + + try { + const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); + return { + parsed: JSON.parse(raw), + sourcePath: filePath, + }; + } catch (error) { + console.warn('Failed to parse bootstrap JSON file.', { + filePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return null; +}; + +const buildEntriesFromManifest = (manifest) => { + const items = Array.isArray(manifest?.items) ? manifest.items : []; + return items + .filter((item) => item && typeof item.name === 'string' && typeof item.botanicalName === 'string') + .map((item) => ({ + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `${item.botanicalName}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + name: item.name.trim(), + botanicalName: item.botanicalName.trim(), + imageUri: typeof item.localImageUri === 'string' && item.localImageUri.trim() + ? item.localImageUri.trim() + : (typeof item.sourceUri === 'string' ? item.sourceUri.trim() : ''), + imageStatus: item.status === 'missing' ? 'missing' : 'ok', + description: '', + categories: [], + confidence: 1, + careInfo: { + waterIntervalDays: 7, + light: 'Unknown', + temp: 'Unknown', + }, + })) + .filter((entry) => entry.imageUri); +}; + +const mergeBootstrapEntries = (primaryEntries, secondaryEntries) => { + const mergedByBotanical = new Map(); + + primaryEntries.forEach((entry) => { + const botanicalKey = typeof entry?.botanicalName === 'string' + ? entry.botanicalName.trim().toLowerCase() + : ''; + if (!botanicalKey || mergedByBotanical.has(botanicalKey)) return; + mergedByBotanical.set(botanicalKey, { ...entry }); + }); + + secondaryEntries.forEach((entry) => { + const botanicalKey = typeof entry?.botanicalName === 'string' + ? entry.botanicalName.trim().toLowerCase() + : ''; + if (!botanicalKey) return; + + const existing = mergedByBotanical.get(botanicalKey); + if (!existing) { + mergedByBotanical.set(botanicalKey, { ...entry }); + return; + } + + const shouldPreferLocalImage = typeof entry.imageUri === 'string' && entry.imageUri.startsWith('/plants/'); + mergedByBotanical.set(botanicalKey, { + ...existing, + imageUri: shouldPreferLocalImage ? entry.imageUri : existing.imageUri, + imageStatus: shouldPreferLocalImage ? entry.imageStatus || existing.imageStatus : existing.imageStatus, + id: existing.id || entry.id, + name: existing.name || entry.name, + botanicalName: existing.botanicalName || entry.botanicalName, + }); + }); + + return Array.from(mergedByBotanical.values()); +}; + +const loadFullBootstrapCatalog = () => { + const catalogDump = readJsonFromCandidates(FULL_BOOTSTRAP_CATALOG_CANDIDATES); + const manifestDump = readJsonFromCandidates(FULL_BOOTSTRAP_MANIFEST_CANDIDATES); + + const catalogEntries = Array.isArray(catalogDump?.parsed) ? catalogDump.parsed : []; + const manifestEntries = manifestDump ? buildEntriesFromManifest(manifestDump.parsed) : []; + const mergedEntries = mergeBootstrapEntries(catalogEntries, manifestEntries); + + if (mergedEntries.length === 0) return null; + + return { + entries: mergedEntries, + sourcePath: [catalogDump?.sourcePath, manifestDump?.sourcePath].filter(Boolean).join(', '), + }; +}; + +const isMinimalBootstrapCatalog = (entries) => { + if (!Array.isArray(entries) || entries.length !== DEFAULT_BOOTSTRAP_PLANTS.length) { + return false; + } + + const botanicalNames = new Set( + entries + .map((entry) => (typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : '')) + .filter(Boolean), + ); + + return DEFAULT_BOOTSTRAP_PLANTS.every((entry) => botanicalNames.has(entry.botanicalName.trim().toLowerCase())); +}; + +const seedBootstrapCatalogIfNeeded = async () => { + const fullCatalog = loadFullBootstrapCatalog(); + const diagnostics = await getPlantDiagnostics(db); + + if (diagnostics.totalCount > 0) { + if (fullCatalog && diagnostics.totalCount === DEFAULT_BOOTSTRAP_PLANTS.length) { + const existingEntries = await getPlants(db, { limit: DEFAULT_BOOTSTRAP_PLANTS.length + 1 }); + if (isMinimalBootstrapCatalog(existingEntries) && fullCatalog.entries.length > existingEntries.length) { + await rebuildPlantsCatalog(db, fullCatalog.entries, { + source: 'bootstrap_upgrade_from_minimal_catalog', + preserveExistingIds: false, + enforceUniqueImages: false, + }); + console.log(`Upgraded minimal bootstrap catalog to full catalog (${fullCatalog.entries.length} entries).`); + } + } + return; + } + + if (fullCatalog) { + await rebuildPlantsCatalog(db, fullCatalog.entries, { + source: 'bootstrap_full_catalog', + preserveExistingIds: false, + enforceUniqueImages: false, + }); + console.log(`Bootstrapped full plant catalog from ${fullCatalog.sourcePath} (${fullCatalog.entries.length} entries).`); + return; + } + + await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, { + source: 'bootstrap_minimal_catalog', + preserveExistingIds: false, + enforceUniqueImages: false, + }); + console.warn('Full bootstrap catalog was not found. Seeded minimal fallback catalog with 2 entries.'); +}; app.use(cors()); app.use('/plants', express.static(plantsPublicDir)); diff --git a/server/lib/billing.js b/server/lib/billing.js index 16be92e..7dcce5f 100644 --- a/server/lib/billing.js +++ b/server/lib/billing.js @@ -403,6 +403,19 @@ const syncRevenueCatCustomerInfo = async (db, userId, customerInfo, options = {} ); } + // Fallback: also check active entitlements for topup products. + // This handles cases where a topup product is misconfigured in RevenueCat + // to grant an entitlement instead of being treated as a consumable. + const rawActiveEntitlements = Object.values(customerInfo?.entitlements?.active || {}); + for (const entitlement of rawActiveEntitlements) { + const productId = entitlement?.productIdentifier; + if (isSupportedTopupProduct(productId)) { + const purchaseDate = entitlement?.latestPurchaseDate || entitlement?.originalPurchaseDate; + const txId = purchaseDate ? `entitlement:${productId}:${purchaseDate}` : null; + await grantRevenueCatTopupIfNeeded(tx, account, txId, productId); + } + } + account.updatedAt = nowIso(); await upsertAccount(tx, account); return {