From e3a28b0a1c58b511ba7b663b35bde6b5942fb0eb Mon Sep 17 00:00:00 2001 From: Timo Date: Mon, 10 Aug 2026 12:50:12 +0200 Subject: [PATCH] feat(billing): add weekly_pro subscription plan Adds a 2.99 EUR/week plan with a 3-day free trial alongside the existing monthly and yearly subscriptions. Backend: weekly_pro joins the supported subscription products, the available product list and the Discord sales label. No schema change -- weekly Pro grants the same 100 credits per calendar month as monthly Pro, so no column is needed to tell the two apart. Paywall: weekly and yearly are the two prominent cards, monthly is a selectable row below them. Weekly is preselected. Cards only render when their RevenueCat package exists, and the selection falls back to a visible plan so the CTA can never buy a product that is not loaded. Trial eligibility: checkTrialOrIntroductoryPriceEligibility now gates the trial copy. Apple grants one intro offer per subscription group, so with two trial products a second free-trial promise would otherwise be shown to users who get charged immediately. Anything but a clear ELIGIBLE is treated as no trial, as the RevenueCat SDK recommends. Analytics: trial_started previously fired on every subscription purchase, including monthly which never had a trial. It now fires only for products that actually carry one. paywall_viewed distinguishes trial_enabled from trial_eligible and reports selected_plan. Tests: 9 new cases covering the entitlement path, credits, renewal period and trial allowance. Co-Authored-By: Claude Opus 5 --- __tests__/services/mockBackendService.test.ts | 70 +++ app/profile/billing.tsx | 407 +++++++++++++----- context/AppContext.tsx | 4 +- .../2026-08-09-weekly-subscription-plan.md | 320 ++++++++++++++ server/lib/billing.js | 7 +- server/lib/discord.js | 1 + server/test/billing.test.js | 42 ++ services/backend/contracts.ts | 2 +- services/backend/mockBackendService.ts | 108 ++--- 9 files changed, 800 insertions(+), 161 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-09-weekly-subscription-plan.md diff --git a/__tests__/services/mockBackendService.test.ts b/__tests__/services/mockBackendService.test.ts index 7a99380..230988c 100644 --- a/__tests__/services/mockBackendService.test.ts +++ b/__tests__/services/mockBackendService.test.ts @@ -241,6 +241,76 @@ describe('mockBackendService billing simulation', () => { expect(response.billing.entitlement.renewsAt).toBe('2026-04-30T00:00:00.000Z'); }); + it('grants pro from a weekly_pro purchase and renews after 7 days', async () => { + const response = await mockBackendService.simulatePurchase({ + userId: 'test-user-weekly-purchase', + idempotencyKey: 'weekly-1', + productId: 'weekly_pro', + }); + + expect(response.appliedProduct).toBe('weekly_pro'); + expect(response.billing.entitlement.plan).toBe('pro'); + expect(response.billing.credits.monthlyAllowance).toBe(100); + + // Das Wochenabo verlaengert sich nach 7 Tagen, nicht nach 30 wie die anderen Plaene. + const renewsInDays = (new Date(response.billing.entitlement.renewsAt as string).getTime() - Date.now()) + / (24 * 60 * 60 * 1000); + expect(renewsInDays).toBeCloseTo(7, 3); + }); + + it('offers weekly_pro as a purchasable product', async () => { + const response = await mockBackendService.simulatePurchase({ + userId: 'test-user-weekly-products', + idempotencyKey: 'weekly-products-1', + productId: 'topup_small', + }); + + expect(response.billing.availableProducts).toContain('weekly_pro'); + }); + + it('syncs pro entitlement from a weekly_pro RevenueCat subscription', async () => { + // Regressionsschutz: fehlt weekly_pro im unterstuetzten Produkt-Set, verwirft der + // lokale Sync das aktive Entitlement und der Kaeufer bleibt auf 'free'. + const response = await mockBackendService.syncRevenueCatState({ + userId: 'test-user-rc-weekly', + customerInfo: { + entitlements: { + active: { + pro: { + productIdentifier: 'weekly_pro', + expirationDate: '2026-04-30T00:00:00.000Z', + }, + }, + }, + nonSubscriptions: {}, + }, + }); + + expect(response.billing.entitlement.plan).toBe('pro'); + expect(response.billing.entitlement.status).toBe('active'); + }); + + it('limits a weekly_pro trial to trial credits', async () => { + const response = await mockBackendService.syncRevenueCatState({ + userId: 'test-user-rc-weekly-trial', + customerInfo: { + entitlements: { + active: { + pro: { + productIdentifier: 'weekly_pro', + expirationDate: '2026-04-30T00:00:00.000Z', + periodType: 'TRIAL', + }, + }, + }, + nonSubscriptions: {}, + }, + }); + + expect(response.billing.entitlement.plan).toBe('pro'); + expect(response.billing.credits.monthlyAllowance).toBe(30); + }); + it('limits RevenueCat trial entitlement to trial credits', async () => { const response = await mockBackendService.syncRevenueCatState({ userId: 'test-user-rc-trial', diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index 3860e46..d77896f 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -6,6 +6,7 @@ import { useRouter, useLocalSearchParams } from 'expo-router'; import { useFocusEffect } from '@react-navigation/native'; import Constants from 'expo-constants'; import Purchases, { + INTRO_ELIGIBILITY_STATUS, LOG_LEVEL, PACKAGE_TYPE, PRODUCT_CATEGORY, @@ -20,11 +21,47 @@ import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { Language } from '../../types'; import { PurchaseProductId } from '../../services/backend/contracts'; -type SubscriptionProductId = 'monthly_pro' | 'yearly_pro'; +type SubscriptionProductId = 'weekly_pro' | 'monthly_pro' | 'yearly_pro'; type TopupProductId = Extract; type SubscriptionPackages = Partial>; type TopupProducts = Partial>; -type PaywallPlanId = 'monthly' | 'yearly'; +type PaywallPlanId = 'weekly' | 'monthly' | 'yearly'; + +const PRODUCT_BY_PAYWALL_PLAN: Record = { + weekly: 'weekly_pro', + monthly: 'monthly_pro', + yearly: 'yearly_pro', +}; + +// Trial-Laufzeit je Plan. Monthly hat bewusst keinen - der Trial ist das +// Argument fuer die beiden prominenten Optionen. +const TRIAL_DAYS_BY_PAYWALL_PLAN: Record = { + weekly: 3, + monthly: 0, + yearly: 7, +}; + +// Produkte, fuer die ueberhaupt eine Trial-Berechtigung geprueft werden muss. +const TRIAL_PRODUCTS: SubscriptionProductId[] = ['weekly_pro', 'yearly_pro']; + +type TrialEligibility = Partial>; + +// Apples Trial-Berechtigung gilt pro Subscription Group, nicht pro Produkt: wer den +// 3-Tage-Trial ueber Weekly verbraucht hat, bekommt auch ueber Yearly keinen zweiten. +// Alles ausser einem klaren ELIGIBLE wird deshalb als "kein Trial" behandelt - inklusive +// UNKNOWN, wozu auch das RevenueCat-SDK selbst raet, um keine falsche Zusage zu machen. +const readTrialEligibility = ( + result: Record, +): TrialEligibility => { + return TRIAL_PRODUCTS.reduce((acc, productId) => { + acc[productId] = result?.[productId]?.status === INTRO_ELIGIBILITY_STATUS.INTRO_ELIGIBILITY_STATUS_ELIGIBLE; + return acc; + }, {}); +}; + +const isSubscriptionProductId = (productId: PurchaseProductId): productId is SubscriptionProductId => ( + productId === 'weekly_pro' || productId === 'monthly_pro' || productId === 'yearly_pro' +); const TOPUP_CREDITS_BY_PRODUCT: Record = { @@ -55,12 +92,14 @@ const resolveSubscriptionPackages = (offering: PurchasesOffering | null): Subscr } const availablePackages = [ + offering.weekly, offering.monthly, offering.annual, ...offering.availablePackages, ].filter((value): value is PurchasesPackage => Boolean(value)); return { + weekly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'weekly_pro', PACKAGE_TYPE.WEEKLY)), monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)), yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)), }; @@ -123,6 +162,7 @@ const getBillingCopy = (language: Language) => { // Nackter Betrag: planCardPriceTrial/-Monthly haengen die Periode selbst an. proPlanPriceBare: '4,99 €', proYearlyPlanPriceBare: '39,99 €', + proWeeklyPlanPriceBare: '2,99 €', proBadgeText: 'EMPFOHLEN', proYearlyPlanName: 'Pro', proYearlyPlanPrice: '39,99 € / Jahr', @@ -184,9 +224,14 @@ const getBillingCopy = (language: Language) => { badgeFlexible: 'FLEXIBEL', planYearlyName: 'Jährlich - 7 Tage gratis', planMonthlyName: 'Monatlich', + planWeeklyName: 'Wöchentlich - 3 Tage gratis', + // Ohne Trial-Berechtigung: nur der Planname, keine Gratis-Zusage. + planYearlyNamePlain: 'Jährlich', + planWeeklyNamePlain: 'Wöchentlich', saveBadge: '33 % SPAREN', perYear: '/ Jahr', perMonth: '/ Monat', + perWeek: '/ Woche', breaksDownTo: 'Macht nur', // Nur der Wert, kein ganzer Satz: das Label links sagt bereits // "Macht nur" - der Satz "entspricht X pro Monat" doppelte das und @@ -204,6 +249,8 @@ const getBillingCopy = (language: Language) => { `Heute fällig: ${today} - dann ${later} am ${date}`, detailsToggle: 'Fragen, die du jetzt vielleicht hast', trialReminder: 'Wir erinnern dich 2 Tage vor Ablauf der Testphase.', + // 3-Tage-Trial: eine 2-Tage-Vorwarnung waere fast deckungsgleich mit dem Start. + trialReminderWeekly: 'Wir erinnern dich 1 Tag vor Ablauf der Testphase.', cancelPath: 'Kündigen in 2 Taps über die iOS-Einstellungen - Anleitung findest du in der App.', planCardPriceTrial: (price: string) => `7 Tage gratis, dann ${price}/Jahr`, planCardPriceMonthly: (price: string) => `${price}/Monat`, @@ -236,6 +283,7 @@ const getBillingCopy = (language: Language) => { proPlanPrice: '4.99 EUR / Mes', proPlanPriceBare: '4,99 €', proYearlyPlanPriceBare: '39,99 €', + proWeeklyPlanPriceBare: '2,99 €', proBadgeText: 'RECOMENDADO', proYearlyPlanName: 'Pro', proYearlyPlanPrice: '39.99 EUR / Año', @@ -288,9 +336,13 @@ const getBillingCopy = (language: Language) => { badgeFlexible: 'FLEXIBLE', planYearlyName: 'Anual - 7 días gratis', planMonthlyName: 'Mensual', + planWeeklyName: 'Semanal - 3 días gratis', + planYearlyNamePlain: 'Anual', + planWeeklyNamePlain: 'Semanal', saveBadge: 'AHORRA 33 %', perYear: '/ año', perMonth: '/ mes', + perWeek: '/ semana', breaksDownTo: 'Sale a solo', equivalentValue: (price: string) => `${price} / mes`, faqQ1: '"Pero hay herramientas gratuitas."', @@ -305,6 +357,7 @@ const getBillingCopy = (language: Language) => { `Hoy pagas: ${today} - luego ${later} el ${date}`, detailsToggle: 'Preguntas que quizá tengas ahora', trialReminder: 'Te avisamos 2 días antes de que termine la prueba.', + trialReminderWeekly: 'Te avisamos 1 día antes de que termine la prueba.', cancelPath: 'Cancela en 2 toques desde los Ajustes de iOS - te explicamos cómo en la app.', planCardPriceTrial: (price: string) => `7 días gratis, luego ${price}/año`, planCardPriceMonthly: (price: string) => `${price}/mes`, @@ -337,6 +390,7 @@ const getBillingCopy = (language: Language) => { proPlanPrice: '4.99 EUR / Month', proPlanPriceBare: '€4.99', proYearlyPlanPriceBare: '€39.99', + proWeeklyPlanPriceBare: '€2.99', proBadgeText: 'RECOMMENDED', proYearlyPlanName: 'Pro', proYearlyPlanPrice: '39.99 EUR / Year', @@ -389,9 +443,13 @@ const getBillingCopy = (language: Language) => { badgeFlexible: 'FLEXIBLE', planYearlyName: 'Yearly - 7 days free', planMonthlyName: 'Monthly', + planWeeklyName: 'Weekly - 3 days free', + planYearlyNamePlain: 'Yearly', + planWeeklyNamePlain: 'Weekly', saveBadge: 'SAVE 33%', perYear: '/ year', perMonth: '/ month', + perWeek: '/ week', breaksDownTo: 'Breaks down to just', equivalentValue: (price: string) => `${price} / month`, faqQ1: '"But free tools exist."', @@ -406,6 +464,7 @@ const getBillingCopy = (language: Language) => { `Due today: ${today} - then ${later} on ${date}`, detailsToggle: 'Questions you might have right now', trialReminder: 'We remind you 2 days before the trial ends.', + trialReminderWeekly: 'We remind you 1 day before the trial ends.', cancelPath: 'Cancel in 2 taps via iOS Settings - we show you how inside the app.', planCardPriceTrial: (price: string) => `Free for 7 days, then ${price}/year`, planCardPriceMonthly: (price: string) => `${price}/month`, @@ -440,8 +499,11 @@ export default function BillingScreen() { const [storeError, setStoreError] = useState(null); const [subscriptionPackages, setSubscriptionPackages] = useState({}); const [topupProducts, setTopupProducts] = useState({}); - // Monthly is the default; the 7-day trial (yearly plan) must be opted into via the toggle. - const [selectedPaywallPlan, setSelectedPaywallPlan] = useState('monthly'); + // Leeres Objekt = noch nichts geprueft, also auch keine Trial-Zusage zeigen. + const [trialEligibility, setTrialEligibility] = useState({}); + // Weekly ist vorausgewaehlt: niedrigste Einstiegshuerde. Yearly steht + // gleichwertig darunter, Monthly als dritte Zeile. + const [selectedPaywallPlan, setSelectedPaywallPlan] = useState('weekly'); // Cancel Flow State const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); @@ -468,13 +530,21 @@ export default function BillingScreen() { try { ensureRevenueCatConfigured(); - const [offerings, topups] = await Promise.all([ + const [offerings, topups, eligibility] = await Promise.all([ Purchases.getOfferings(), Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), + // Eigener catch: faellt die Pruefung aus, laufen Offerings und Top-ups + // trotzdem durch - die Paywall verspricht dann nur eben keinen Trial. + Purchases.checkTrialOrIntroductoryPriceEligibility(TRIAL_PRODUCTS).catch((error) => { + console.warn('[Billing] Trial eligibility check failed, falling back to no-trial pricing', error); + return {} as Record; + }), ]); if (cancelled) return; + setTrialEligibility(readTrialEligibility(eligibility)); + const currentOffering = offerings.current; const resolvedPackages = resolveSubscriptionPackages(currentOffering); if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) { @@ -508,14 +578,41 @@ export default function BillingScreen() { }; }, [isExpoGo]); - const trialEnabled = selectedPaywallPlan === 'yearly'; + const selectedProductId = PRODUCT_BY_PAYWALL_PLAN[selectedPaywallPlan]; + const selectedTrialDays = TRIAL_DAYS_BY_PAYWALL_PLAN[selectedPaywallPlan]; + + // Ein Plan zeigt seine Testphase nur, wenn Apple sie diesem Nutzer auch gewaehrt. + // In Expo Go gibt es keinen Store - dort bleibt der Simulationspfad wie gehabt. + const planHasTrial = (plan: PaywallPlanId) => { + if (TRIAL_DAYS_BY_PAYWALL_PLAN[plan] <= 0) return false; + if (isExpoGo) return true; + return trialEligibility[PRODUCT_BY_PAYWALL_PLAN[plan]] === true; + }; + + const trialEnabled = planHasTrial(selectedPaywallPlan); + + // Fuer die Kaufpfade: dort steht die Produkt-ID fest, nicht der Paywall-Plan. + const productHasTrial = (productId: PurchaseProductId) => { + if (!isSubscriptionProductId(productId)) return false; + if (!TRIAL_PRODUCTS.includes(productId)) return false; + if (isExpoGo) return true; + return trialEligibility[productId] === true; + }; useEffect(() => { try { posthog.capture('paywall_viewed', { plan_id: planId, context: onboardingContext ? 'onboarding' : 'in_app', - trial_enabled: trialEnabled, + // trial_enabled = der Plan sieht eine Testphase vor, + // trial_eligible = Apple gewaehrt sie diesem Nutzer auch. Getrennt, weil + // sonst nicht unterscheidbar ist, ob eine schlechte Conversion am Preis + // oder an einer bereits verbrauchten Testphase liegt. + trial_enabled: selectedTrialDays > 0, + trial_eligible: trialEnabled, + // Mit drei Plaenen reicht das Trial-Flag nicht mehr, um Weekly von + // Yearly zu unterscheiden. + selected_plan: selectedPaywallPlan, }); } catch {} if (showPaywallPlans) { @@ -526,20 +623,40 @@ export default function BillingScreen() { }); } catch {} } - }, [posthog, planId, session?.serverUserId, showPaywallPlans, onboardingContext, trialEnabled]); + }, [posthog, planId, session?.serverUserId, showPaywallPlans, onboardingContext, trialEnabled, selectedPaywallPlan]); + const weeklyPackage = subscriptionPackages.weekly_pro; const monthlyPackage = subscriptionPackages.monthly_pro; const yearlyPackage = subscriptionPackages.yearly_pro; // Fallback ohne Periode - sonst entsteht "4.99 EUR / Month/month". + const weeklyPrice = weeklyPackage?.product.priceString ?? copy.proWeeklyPlanPriceBare; const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPriceBare; const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPriceBare; + const selectedPrice = selectedPaywallPlan === 'weekly' + ? weeklyPrice + : selectedPaywallPlan === 'yearly' ? yearlyPrice : monthlyPrice; + + // Die Karten rendern nur, wenn ihr RevenueCat-Package existiert. Ohne diesen + // Fallback koennte die Vorauswahl auf einen unsichtbaren Plan zeigen: die Paywall + // haette dann keine markierte Option und der CTA kaufte ins Leere. Monthly ist der + // letzte Ausweg - dessen Zeile wird immer gerendert. + useEffect(() => { + if (isExpoGo || !storeReady) return; + if (selectedPaywallPlan === 'weekly' && !weeklyPackage) { + setSelectedPaywallPlan(yearlyPackage ? 'yearly' : 'monthly'); + return; + } + if (selectedPaywallPlan === 'yearly' && !yearlyPackage) { + setSelectedPaywallPlan(weeklyPackage ? 'weekly' : 'monthly'); + } + }, [isExpoGo, storeReady, selectedPaywallPlan, weeklyPackage, yearlyPackage]); const trialEndDate = useMemo(() => { const date = new Date(); - date.setDate(date.getDate() + 7); + date.setDate(date.getDate() + selectedTrialDays); const locale = language === 'de' ? 'de-DE' : language === 'es' ? 'es-ES' : 'en-US'; return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); - }, [language]); + }, [language, selectedTrialDays]); const topupLabels = useMemo(() => ({ topup_small: topupProducts.topup_small ? `${TOPUP_CREDITS_BY_PRODUCT.topup_small} Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall, @@ -595,9 +712,11 @@ export default function BillingScreen() { setIsUpdating(true); try { await simulatePurchase(productId); - if (productId === 'monthly_pro' || productId === 'yearly_pro') { + if (isSubscriptionProductId(productId)) { posthog.capture('subscription_started', { product_id: productId, simulated: true }); - posthog.capture('trial_started', { product_id: productId, simulated: true }); + if (productHasTrial(productId)) { + posthog.capture('trial_started', { product_id: productId, simulated: true }); + } setSubModalVisible(false); router.replace(postPurchaseRoute); } else { @@ -626,7 +745,7 @@ export default function BillingScreen() { if (isExpoGo) { // ExpoGo has no native RevenueCat - use simulation for development only setIsUpdating(false); - if (productId === 'monthly_pro' || productId === 'yearly_pro') { + if (isSubscriptionProductId(productId)) { Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [ { text: copy.continueWithoutPro, style: 'cancel' }, { text: copy.expoGoSimulate, onPress: () => completeExpoGoSimulation(productId) }, @@ -637,13 +756,13 @@ export default function BillingScreen() { return; } else { ensureRevenueCatConfigured(); - if (productId === 'monthly_pro' || productId === 'yearly_pro') { + if (isSubscriptionProductId(productId)) { if (planId === 'pro') { await openAppleSubscriptions(); setSubModalVisible(false); return; } - const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage; + const selectedPackage = subscriptionPackages[productId]; const latestOffering = !selectedPackage ? await Purchases.getOfferings().then((offerings) => offerings.current) : null; @@ -660,7 +779,9 @@ export default function BillingScreen() { ?? await Purchases.getCustomerInfo(); void syncRevenueCatState(customerInfo as any, 'subscription_purchase'); posthog.capture('subscription_started', { product_id: productId }); - posthog.capture('trial_started', { product_id: productId }); + if (productHasTrial(productId)) { + posthog.capture('trial_started', { product_id: productId }); + } setSubModalVisible(false); setTimeout(() => router.replace(postPurchaseRoute), 0); return; @@ -743,14 +864,80 @@ export default function BillingScreen() { } }; - // Jahrespreis -> Monatsaequivalent, mit Waehrungssymbol aus dem Store-Preis. - const yearlyMonthlyEquivalent = (() => { - const numeric = parseFloat(yearlyPrice.replace(/[^0-9.,]/g, '').replace(',', '.')); + // Store-Preis -> Monatsaequivalent, mit Waehrungssymbol aus dem Store-Preis. + // periodsPerYear: 1 fuer das Jahresabo, 52 fuer das Wochenabo. + const toMonthlyEquivalent = (price: string, periodsPerYear: number) => { + const numeric = parseFloat(price.replace(/[^0-9.,]/g, '').replace(',', '.')); if (!Number.isFinite(numeric) || numeric <= 0) return null; - const symbol = yearlyPrice.replace(/[0-9.,\s]/g, '') || '€'; - const perMonth = (numeric / 12).toFixed(2).replace('.', language === 'en' ? '.' : ','); + const symbol = price.replace(/[0-9.,\s]/g, '') || '€'; + const perMonth = ((numeric * periodsPerYear) / 12).toFixed(2).replace('.', language === 'en' ? '.' : ','); return `${perMonth} ${symbol}`.trim(); - })(); + }; + + // Nur fuers Jahresabo: dort macht die Umrechnung den grossen Betrag greifbar. + // Fuer Weekly und Monthly steht der Periodenpreis unveraendert an der Karte. + const yearlyMonthlyEquivalent = toMonthlyEquivalent(yearlyPrice, 1); + const selectedMonthlyEquivalent = selectedPaywallPlan === 'yearly' ? yearlyMonthlyEquivalent : null; + + // Rendert eine der beiden prominenten Planoptionen. Fehlt das RevenueCat-Package + // (z. B. weil das Wochenabo im Offering noch nicht freigeschaltet ist), faellt die + // Karte ersatzlos weg - die Paywall bleibt dann bei den restlichen Plaenen, statt + // einen Kauf anzubieten, der nur fehlschlagen kann. + const renderPlanCard = (plan: Exclude) => { + const isYearly = plan === 'yearly'; + const hasPackage = Boolean(isYearly ? yearlyPackage : weeklyPackage); + if (!isExpoGo && !hasPackage) { + return null; + } + + const isSelected = selectedPaywallPlan === plan; + const showsTrial = planHasTrial(plan); + const planName = isYearly + ? (showsTrial ? copy.planYearlyName : copy.planYearlyNamePlain) + : (showsTrial ? copy.planWeeklyName : copy.planWeeklyNamePlain); + return ( + setSelectedPaywallPlan(plan)} + activeOpacity={0.86} + accessibilityRole="radio" + accessibilityState={{ selected: isSelected }} + style={[ + styles.planCard, + { + borderColor: isSelected ? colors.primary : colors.border, + backgroundColor: isSelected ? colors.surfaceMuted : 'transparent', + shadowColor: colors.primary, + }, + isSelected && styles.planCardSelected, + ]} + > + + + + + {planName} + + + {isYearly ? copy.saveBadge : copy.badgeFlexible} + + + + + {isYearly ? yearlyPrice : weeklyPrice} + + + {isYearly ? copy.perYear : copy.perWeek} + + + + + ); + }; if (showPaywallPlans) { return ( @@ -790,105 +977,84 @@ export default function BillingScreen() { ))} - {/* EINE Angebotskarte statt Toggle + Preiskarte. Der Plan, - der Preis und der Kaufbutton stehen zusammen, die - Alternative darunter als Textlink. Damit entfaellt der - Switch, der die Preisaenderung nie sichtbar gemacht hat. */} - + {renderPlanCard('weekly')} + {renderPlanCard('yearly')} + + + setSelectedPaywallPlan('monthly')} + activeOpacity={0.86} + accessibilityRole="radio" + accessibilityState={{ selected: selectedPaywallPlan === 'monthly' }} style={[ - styles.offerCardOuter, + styles.planRowMonthly, { - backgroundColor: colors.surfaceMuted, - borderColor: colors.primary, - // Dezenter Glow statt Leuchtrahmen. - shadowColor: colors.primary, + borderColor: selectedPaywallPlan === 'monthly' ? colors.primary : colors.border, + backgroundColor: selectedPaywallPlan === 'monthly' ? colors.surfaceMuted : 'transparent', }, ]} > - - - {trialEnabled ? copy.badgeMostPopular : copy.badgeFlexible} + + + {`${copy.planMonthlyName} · ${monthlyPrice} ${copy.perMonth}`} + + + + {/* Monatsaequivalent - nur beim Jahresabo. */} + {selectedMonthlyEquivalent ? ( + + + {copy.breaksDownTo} + + + {copy.equivalentValue(selectedMonthlyEquivalent)} + ) : null} - - - - {trialEnabled ? copy.planYearlyName : copy.planMonthlyName} - - {trialEnabled ? ( - {copy.saveBadge} - ) : null} - - - - {trialEnabled ? yearlyPrice : monthlyPrice} - - - {trialEnabled ? copy.perYear : copy.perMonth} - - - - - {trialEnabled && yearlyMonthlyEquivalent ? ( - - - {copy.breaksDownTo} - + handlePurchase(selectedProductId)} + disabled={isUpdating || !storeReady || Boolean(storeError)} + activeOpacity={0.86} + > + {isUpdating || !storeReady ? ( + + ) : ( + <> - {copy.equivalentValue(yearlyMonthlyEquivalent)} + {trialEnabled ? copy.ctaTrial : copy.ctaMonthly} - - ) : null} - - handlePurchase(trialEnabled ? 'yearly_pro' : 'monthly_pro')} - disabled={isUpdating || !storeReady || Boolean(storeError)} - activeOpacity={0.86} - > - {isUpdating || !storeReady ? ( - - ) : ( - <> - - {trialEnabled ? copy.ctaTrial : copy.ctaMonthly} - - - - )} - - - {/* Planwechsel als Textlink - kein Schalter, sondern eine - sichtbar formulierte Alternative inkl. Preis. */} - setSelectedPaywallPlan(trialEnabled ? 'monthly' : 'yearly')} - style={styles.offerAltLink} - > - - {trialEnabled - ? copy.altMonthly(monthlyPrice) - : copy.altYearly(yearlyPrice)} - - - + + + )} + {trialEnabled ? ( - {copy.dueSummary(copy.dueTodayAmount, yearlyPrice, trialEndDate)} + {copy.dueSummary(copy.dueTodayAmount, selectedPrice, trialEndDate)} ) : null} @@ -954,7 +1120,9 @@ export default function BillingScreen() { {/* Belief 6: Kuendigung und Verlaengerung stehen sichtbar vor der Entscheidung, nicht im Kleingedruckten. */} - {trialEnabled ? `${copy.cancelAnytime} · ${copy.trialReminder}` : copy.cancelAnytime} + {trialEnabled + ? `${copy.cancelAnytime} · ${selectedPaywallPlan === 'weekly' ? copy.trialReminderWeekly : copy.trialReminder}` + : copy.cancelAnytime} {copy.cancelPath} @@ -1354,6 +1522,33 @@ const styles = StyleSheet.create({ offerSaveText: { fontSize: 12.5, fontWeight: '800', letterSpacing: 0.4 }, offerPrice: { fontSize: 25, fontWeight: '900', letterSpacing: -0.4 }, offerPricePeriod: { fontSize: 12.5, fontWeight: '600' }, + planPicker: { gap: 10, marginBottom: 10 }, + planCard: { + borderRadius: 18, + borderWidth: 1.5, + paddingHorizontal: 16, + paddingVertical: 14, + }, + planCardSelected: { + // Dezenter Glow auf der gewaehlten Karte, gleiche Sprache wie zuvor die + // einzelne Angebotskarte. + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.34, + shadowRadius: 20, + elevation: 8, + }, + planCardRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + planRowMonthly: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + borderRadius: 14, + borderWidth: 1, + paddingHorizontal: 16, + paddingVertical: 12, + marginBottom: 4, + }, + planRowMonthlyText: { flex: 1, fontSize: 14, fontWeight: '700' }, offerBreakdown: { flexDirection: 'row', alignItems: 'center', diff --git a/context/AppContext.tsx b/context/AppContext.tsx index 1e72cae..5bc0681 100644 --- a/context/AppContext.tsx +++ b/context/AppContext.tsx @@ -87,7 +87,7 @@ const isColorPalette = (v: string): v is ColorPalette => v === 'forest' || v === 'ocean' || v === 'sunset' || v === 'mono'; const isLanguage = (v: string): v is Language => v === 'de' || v === 'en' || v === 'es'; const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro'; -const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set(['monthly_pro', 'yearly_pro']); +const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set(['weekly_pro', 'monthly_pro', 'yearly_pro']); const summarizeRevenueCatCustomerInfo = (customerInfo: RevenueCatCustomerInfo) => { const activeEntitlements = customerInfo?.entitlements?.active || {}; @@ -463,7 +463,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children cycleStartedAt: now.toISOString(), cycleEndsAt: renewsAt || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(), }, - availableProducts: ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'], + availableProducts: ['weekly_pro', 'monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'], }; } diff --git a/docs/superpowers/plans/2026-08-09-weekly-subscription-plan.md b/docs/superpowers/plans/2026-08-09-weekly-subscription-plan.md new file mode 100644 index 0000000..7d4a538 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-weekly-subscription-plan.md @@ -0,0 +1,320 @@ +# Plan: Wochen-Abo (`weekly_pro`) als dritte Abo-Auswahl + +Stand: 2026-08-09. Ziel: neben `monthly_pro` (4,99 €/Monat) und `yearly_pro` (39,99 €/Jahr) ein +Wochen-Abo als Einstiegsoption auf der Paywall. + +--- + +## 0. Entscheidungen vorab (blockieren alles andere) + +| # | Frage | Status | Konsequenz wenn anders | +|---|-------|--------|------------------------| +| D1 | Preis? | **ENTSCHIEDEN: 2,99 €/Woche** (≈ 12,95 €/Monat, bewusst teurer pro Monat als das Monatsabo) | — | +| D2 | Credits? | **ENTSCHIEDEN: gleich wie Pro, 100/Monatszyklus** (= Option A, kein Schema-Change) | — | +| D3 | Trial? | **ENTSCHIEDEN: 3 Tage gratis** auf weekly (7 Tage wären bei einem Wochenprodukt eine komplette Gratisperiode) | — | +| D4 | Android? | OFFEN — nur wenn Play-Release aktiv ist | Sonst Play-Console-Schritte streichen | + +> **D3 zieht Arbeit nach sich, die es ohne Trial nicht gäbe:** siehe Abschnitt 5.4 +> (Trial-Eligibility). Das ist nach dem Paywall-Umbau der zweitgrößte Posten im Plan. + +### Warum D2 so wichtig ist + +Der Credit-Zyklus im Backend ist **hart auf den UTC-Kalendermonat verdrahtet** +([billing.js:44-62](server/lib/billing.js:44)) und `billing_accounts` hat **keine Spalte, die +Monats- von Wochen-Pro unterscheidet** — nur `plan = 'free' | 'pro'` +([billing.js:815-826](server/lib/billing.js:815)). + +- **Option A (empfohlen, D2 = 100 Credits):** kein Schema-Change, kein Migrations-Risiko. + Backend-Aufwand ≈ 4 Zeilen. +- **Option B (eigenes Wochen-Kontingent):** braucht (1) neue Spalte `plan_tier`/`product_id` + inkl. `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` in `ensureBillingSchema`, (2) `getCycleBounds` + muss vom Account abhängen statt global Kalendermonat zu sein, (3) `alignAccountToCurrentCycle` + und `isAllowedMonthlyAllowance` müssen den neuen Tier kennen, sonst wird die Allowance beim + nächsten Request **stillschweigend auf 100 zurückgesetzt** ([billing.js:147-168](server/lib/billing.js:147)). + Rechne mit dem 3–4-fachen Aufwand. + +> **Bekannte Kante bei Option A:** Wer am 30. eines Monats eine Woche kauft, bekommt am 1. den +> nächsten 100er-Block — also 200 Credits für eine Woche Zahlung. Der gleiche Effekt existiert +> heute schon beim Monatsabo, ist beim Wochenabo aber ausgeprägter. Bewusst akzeptieren oder +> Option B wählen. + +--- + +## Fortschritt (Stand 2026-08-10) + +**Erledigt im Code** (`tsc --noEmit` sauber, App-Tests 121 grün, Server-Tests 26 grün): + +- Abschnitt 4 komplett — `billing.js` (3 Konstanten) + `discord.js` Label. +- Abschnitt 5.1 komplett — `contracts.ts`, `AppContext.tsx` (beide Stellen). +- Abschnitt 6, Mock-Teil — `mockBackendService.ts`; `simulatePurchase` nutzt jetzt das + Produkt-Set statt hartkodierter Vergleiche, plus `RENEWAL_DAYS_BY_SUBSCRIPTION` (weekly = 7 Tage). +- Abschnitt 5.2, Plumbing — `SubscriptionProductId`, neuer `isSubscriptionProductId()`-Helper + (ersetzt alle drei hartkodierten `monthly_pro || yearly_pro`-Vergleiche), + `resolveSubscriptionPackages` löst `weekly_pro` über `PACKAGE_TYPE.WEEKLY` auf, + Package-Auswahl beim Kauf über `subscriptionPackages[productId]`. +- Abschnitt 5.2, UI — **Layout-Entscheidung: Weekly + Yearly prominent, Monthly als eigene + anklickbare Zeile darunter.** Zwei Karten mit Radio-Indikator, ein CTA darunter. + **Reihenfolge Weekly → Yearly, Vorauswahl = Weekly.** Weekly-Karte rendert nur, wenn das + RevenueCat-Package existiert (`renderPlanCard`), sonst fällt sie ersatzlos weg — mit + Fallback auf Yearly, damit die Vorauswahl nie auf einen unsichtbaren Plan zeigt. +- Abschnitt 5.3 — Copy in de/es/en: `planWeeklyName`, `perWeek`, `proWeeklyPlanPriceBare`, + `trialReminderWeekly` (1 Tag statt 2, weil der Trial nur 3 Tage läuft). +- Abschnitt 7 — `selected_plan` liegt jetzt an `paywall_viewed` an. +- **Monatsäquivalent bleibt dem Jahresabo vorbehalten** (Entscheidung vom 2026-08-10). + `toMonthlyEquivalent(price, periodsPerYear)` ersetzt die alte Jahres-Sonderlogik, wird aber + nur für Yearly aufgerufen. Beim Wochenabo steht ausschließlich „2,99 € / Woche". + Bewusst asymmetrisch: die Umrechnung erscheint dort, wo sie den Preis kleiner wirken lässt. + Rechtlich unkritisch (Preis + Periode sind klar ausgewiesen, mehr verlangt weder Apple noch + die PAngV), aber es ist die Stelle, an der ein genauer Leser Absicht erkennen kann — + relevant, falls Refund-Quote oder Review-Tonfall später auffällig werden. + +- Abschnitt 5.4 komplett — `checkTrialOrIntroductoryPriceEligibility(['weekly_pro','yearly_pro'])` + läuft im selben `Promise.all` wie Offerings und Top-ups (eigener `catch`, damit ein Ausfall + der Prüfung nicht den Store-Load mitreißt). Nur ein klares `ELIGIBLE` gilt als berechtigt — + `UNKNOWN` wird bewusst als „kein Trial" behandelt, so wie es die SDK-Doku empfiehlt. + Nicht berechtigt → die Karte zeigt nur `planWeeklyNamePlain` / `planYearlyNamePlain`, + kein `dueSummary`-Banner, CTA = „Jetzt starten", keine Trial-Erinnerung im Footer. +- `trial_started` feuerte bisher bei **jedem** Abo-Kauf, auch bei Monatlich ohne Testphase. + Feuert jetzt nur noch, wenn das gekaufte Produkt tatsächlich eine Testphase hatte. + `paywall_viewed` unterscheidet zusätzlich `trial_enabled` (Plan sieht Trial vor) von + `trial_eligible` (Apple gewährt ihn diesem Nutzer). + +- Abschnitt 6, Tests — 9 neue Testfälle. Server (`server/test/billing.test.js`): `weekly_pro` + steht in `AVAILABLE_PRODUCTS`, trägt ein gültiges Pro-Entitlement, Monthly/Yearly weiterhin + auch, ein Top-up-Produkt weiterhin **nicht**, und die Allowance liegt bei 100 bzw. 30 im Trial. + Client (`__tests__/services/mockBackendService.test.ts`): Kauf setzt Pro mit 100 Credits und + `renewsAt` +7 Tage, `weekly_pro` ist in `availableProducts`, RevenueCat-Sync erkennt ein + `weekly_pro`-Abo, und ein `weekly_pro`-Trial bekommt nur 30 Credits. + Gegenprobe gemacht: mit zurückgedrehtem `SUPPORTED_SUBSCRIPTION_PRODUCTS` schlägt der + Entitlement-Test fehl — der Test bewacht also tatsächlich die Regression. + +**Offen — alles außerhalb des Codes:** + +- Abschnitte 1–3 — Store- und RevenueCat-Setup (nur du). +- Abschnitt 9 — Sandbox-Verifikation, insbesondere die drei Trial-Szenarien. +- Optional: das „Abo verwalten"-Modal für bestehende Pro-Nutzer listet weiterhin nur + Free / Pro monatlich / Pro jährlich. Unkritisch, weil dort jeder Tap ohnehin in die + iOS-Einstellungen führt. +- Kosmetik: `offerCardOuter`, `offerBadge`, `offerAltLink` & Co. sind jetzt ungenutzte Styles. + +**Hinweis zum Diff:** `services/backend/mockBackendService.ts` hatte gemischte Zeilenenden; +mein Schreibvorgang hat die Datei einheitlich normalisiert. Dadurch zeigt `git diff` dort ~90 +zusätzliche Zeilen, die inhaltlich identisch sind — `git diff --ignore-all-space` weist genau +die beabsichtigten 12 Einfügungen / 4 Löschungen aus. Alle anderen Dateien sind unauffällig. + +--- + +## 1. App Store Connect (Apple) + +- [ ] **Neues Abo im BESTEHENDEN Subscription Group** anlegen (nicht in einer neuen Gruppe — + sonst kein Up-/Downgrade zwischen den Plänen und Apple behandelt sie als parallele Abos). +- [ ] Produkt-ID exakt `weekly_pro` (gleiche Konvention wie `monthly_pro`/`yearly_pro`; der + Client matcht auf `pkg.product.identifier === productId`, [billing.tsx:46](app/profile/billing.tsx:46)). +- [ ] Laufzeit: **1 Woche**. +- [ ] Preis: **2,99 €**, Preisplan für alle Territorien setzen. +- [ ] **Group Ranking / Level:** weekly = niedrigstes Level (unter monthly, unter yearly), damit + Upgrades sofort und Downgrades zum Periodenende greifen. +- [ ] Lokalisierungen: Anzeigename + Beschreibung für **de, es, en**. +- [ ] Review-Screenshot der Paywall hochladen (Apple lehnt sonst ab). +- [ ] **Introductory Offer: 3 Tage kostenlos** auf `weekly_pro` anlegen (Typ „Free Trial"). + Beim Anlegen prüfen, welche Laufzeiten das Dropdown für ein Wochenprodukt überhaupt + anbietet — Apple schränkt die Trial-Dauer relativ zur Abo-Laufzeit ein. +- [ ] ⚠️ **Eligibility-Kollision bewusst machen:** Intro-Offer-Eligibility gilt **pro + Subscription Group**, nicht pro Produkt. Alle drei Abos liegen in derselben Gruppe. + Wer den 3-Tage-Weekly-Trial nimmt, ist damit für den 7-Tage-Yearly-Trial verbrannt — + und umgekehrt. Das muss die Paywall abbilden, siehe Abschnitt 5.4. +- [ ] Status auf „Ready to Submit". **Neue In-App-Käufe werden nur zusammen mit einem + App-Build reviewt** — d. h. ohne neuen Build bleibt das Produkt hängen. +- [ ] App-Beschreibung prüfen: Apple 3.1.2 verlangt Laufzeit + Preis aller Abos in der + Store-Beschreibung + Links zu Terms/Privacy. + +## 2. Google Play (nur bei D4 = ja) + +- [ ] Play Console → Monetarisierung → Abos → neues Abo `weekly_pro`. +- [ ] Basisplan `weekly-autorenewing`, Abrechnungszeitraum 1 Woche, Preis, aktivieren. +- [ ] Auf denselben Entitlement-Tag mappen. + +## 3. RevenueCat + +- [ ] **Products** → neues Produkt `weekly_pro` importieren (App Store, ggf. Play). +- [ ] **Entitlements** → `pro` → `weekly_pro` anhängen. + (Entitlement-ID kommt aus `REVENUECAT_PRO_ENTITLEMENT_ID`, Default `pro` — + [billing.js:7](server/lib/billing.js:7), [AppContext.tsx:56](services/backend/mockBackendService.ts:56).) +- [ ] **Offerings** → das `current`-Offering → Package mit Identifier `$rc_weekly` hinzufügen, + Produkt `weekly_pro` zuweisen. + Der Client löst über `PACKAGE_TYPE.WEEKLY` auf, sobald der Code aus Schritt 5 drin ist. +- [ ] Warten bis RC den Store-Sync bestätigt („Product is available"), sonst kommt das Package + nicht in `Purchases.getOfferings()` an. +- [ ] Webhook: **keine Änderung nötig**, `/api/revenuecat/webhook` bleibt + ([index.js:550](server/index.js:550)). + +## 4. Backend (`server/`) + +Minimal bei Option A — aber **alle drei Stellen sind Pflicht**: + +- [ ] [billing.js:8](server/lib/billing.js:8) — `SUPPORTED_SUBSCRIPTION_PRODUCTS` um `'weekly_pro'` erweitern. +- [ ] [billing.js:10-16](server/lib/billing.js:10) — `TOPUP_CREDITS_BY_PRODUCT` um `weekly_pro: 0`. +- [ ] [billing.js:18](server/lib/billing.js:18) — `AVAILABLE_PRODUCTS` um `'weekly_pro'`. +- [ ] [discord.js:12-13](server/lib/discord.js:12) — `PRODUCT_LABELS` um `weekly_pro: 'Pro (wöchentlich)'`, + sonst steht im Sales-Channel eine rohe Produkt-ID. + +> **Warum Punkt 1 nicht optional ist:** `getValidProEntitlement` +> ([billing.js:332-360](server/lib/billing.js:332)) verwirft ein aktives `pro`-Entitlement, wenn +> die `productIdentifier` nicht in der Menge steht. Ohne den Eintrag bleibt ein Weekly-Käufer +> beim Client-Sync **auf `free`**, bis zufällig der Webhook durchläuft (der Webhook-Pfad greift +> über `entitlement_ids` und würde funktionieren — der direkte Sync nach dem Kauf aber nicht). + +**Trial-Credits prüfen (wegen D3):** `TRIAL_MONTHLY_CREDITS = 30` +([billing.js:4](server/lib/billing.js:4)) wird produktunabhängig vergeben, sobald RevenueCat +`period_type = trial` meldet ([billing.js:72](server/lib/billing.js:72), +[billing.js:367](server/lib/billing.js:367)). Ein 3-Tage-Weekly-Trial gibt damit **30 Credits für +den ganzen Kalendermonat** — mehr, als der Trial wirtschaftlich hergibt. Entweder bewusst +akzeptieren (einfach) oder die Trial-Allowance produktabhängig machen (braucht dieselbe +Tier-Spalte wie Option B). + +Bei Option B zusätzlich: neue Konstante `WEEKLY_PRO_CREDITS`, `getMonthlyAllowanceForPlan` +([billing.js:64](server/lib/billing.js:64)), `isAllowedMonthlyAllowance` +([billing.js:76](server/lib/billing.js:76)), `getCycleBounds`, Spalte + Migration in +`ensureBillingSchema` ([billing.js:812](server/lib/billing.js:812)). + +## 5. Mobile App + +### 5.1 Typen & Sync + +- [ ] [contracts.ts:5](services/backend/contracts.ts:5) — `PurchaseProductId` um `'weekly_pro'`. +- [ ] [AppContext.tsx:90](context/AppContext.tsx:90) — `SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS` + um `'weekly_pro'` (sonst greift die lokale Entitlement-Anwendung nach dem Kauf nicht, + [AppContext.tsx:120-124](context/AppContext.tsx:120)). +- [ ] [AppContext.tsx:466](context/AppContext.tsx:466) — `availableProducts`-Liste. + +### 5.2 Paywall (`app/profile/billing.tsx`) + +- [ ] [:23](app/profile/billing.tsx:23) `SubscriptionProductId` um `'weekly_pro'`. +- [ ] [:27](app/profile/billing.tsx:27) `PaywallPlanId` um `'weekly'`. +- [ ] [:52-67](app/profile/billing.tsx:52) `resolveSubscriptionPackages` — `weekly_pro` über + `PACKAGE_TYPE.WEEKLY` auflösen; `offering.weekly` in die Kandidatenliste aufnehmen. +- [ ] [:480](app/profile/billing.tsx:480) Warn-Check um das Weekly-Package erweitern. +- [ ] [:444](app/profile/billing.tsx:444) Default-Auswahl festlegen (Empfehlung: `'monthly'` lassen). +- [ ] [:531-536](app/profile/billing.tsx:531) `weeklyPackage` + `weeklyPrice` (Fallback-Copy). +- [ ] [:646](app/profile/billing.tsx:646) Package-Auswahl von Ternary auf Map umstellen. +- [ ] **Drei Stellen mit hartkodiertem `productId === 'monthly_pro' || productId === 'yearly_pro'`** + ([:598](app/profile/billing.tsx:598), [:629](app/profile/billing.tsx:629), + [:640](app/profile/billing.tsx:640)) durch einen `isSubscriptionProductId()`-Helper ersetzen — + sonst läuft ein Weekly-Kauf in den Top-up-Zweig. +- [ ] [:1130](app/profile/billing.tsx:1130) Verwaltungs-Modal (`handlePurchase('monthly_pro')`) prüfen. +- [ ] **UI-Umbau:** Die Paywall zeigt aktuell *eine* Angebotskarte + einen Textlink zur + Alternative ([:875-884](app/profile/billing.tsx:875)). Das ist ein binäres Muster und + trägt keine dritte Option. Ersetzen durch eine kompakte 3-Zeilen-Plan-Auswahl + (weekly / monthly / yearly) über dem CTA. Das ist der größte Einzelposten im ganzen Plan. +- [ ] `yearlyMonthlyEquivalent` ([:747](app/profile/billing.tsx:747)) analog für weekly → + Monatsäquivalent, damit der Preisvergleich ehrlich bleibt. + +### 5.3 Copy — **dreimal**, für de / es / en in `getBillingCopy` ([:103-420](app/profile/billing.tsx:103)) + +Neue Keys: `planWeeklyName`, `perWeek`, `proWeeklyPlanPriceBare` (`2,99 €`), `autoRenewWeekly`, +`altWeekly(price)`, `planCardPriceWeekly(price)`. Pflichtangabe im Text: automatische +wöchentliche Verlängerung + Kündigungsweg (steht heute in `autoRenewMonthly`/`autoRenewYearly`). + +Wegen D3 zusätzlich: `planWeeklyNameTrial` („Wöchentlich – 3 Tage gratis"), +`ctaTrialWeekly`, `dueSummaryWeekly`, `trialReminderWeekly` (die 2-Tage-Vorwarnung aus +`trialReminder` ist bei 3 Tagen Trial knapp — Formulierung anpassen oder auf 1 Tag ziehen), +plus je eine **Nicht-berechtigt-Variante** der Trial-Texte (siehe 5.4). + +### 5.4 Trial-Eligibility — neu, weil D3 = Trial + +Heute hat nur `yearly_pro` einen Trial, deshalb zeigt die Paywall die Trial-Texte +**bedingungslos**: `planYearlyName` („Jährlich - 7 Tage gratis"), das `dueSummary`-Banner +(„Heute fällig: 0,00 € - dann 39,99 € am …", [billing.tsx:887-894](app/profile/billing.tsx:887)) +und `ctaTrial`. **Es gibt keinerlei Eligibility-Prüfung in der Codebase** (verifiziert). + +Mit zwei Trial-Produkten in einer Subscription Group wird das falsch: Nutzer, die ihren +Group-Trial schon verbraucht haben, sehen weiterhin „0,00 € heute" und werden sofort abgebucht. +Das ist ein Refund-/Chargeback-Treiber **und** ein Ablehnungsrisiko nach App-Store-Richtlinie +3.1.2 (irreführende Preisdarstellung). + +- [ ] Beim Laden der Offerings ([billing.tsx:463-509](app/profile/billing.tsx:463)) + `Purchases.checkTrialOrIntroductoryPriceEligibility(['weekly_pro', 'yearly_pro'])` + mitziehen und das Ergebnis in den State legen. +- [ ] Trial-Copy pro Plan an die Eligibility hängen: bei „nicht berechtigt" fällt die Karte auf + reine Preisdarstellung zurück (kein „gratis", kein `dueSummary`-Banner, CTA = `ctaMonthly`-Variante). +- [ ] Ladezustand abfangen: solange die Eligibility unbekannt ist, **keine** Trial-Zusage rendern + (lieber kurz neutral als kurz falsch). +- [ ] Analytics: Eligibility als Property an `paywall_viewed` hängen — sonst sind die + Conversion-Zahlen zwischen „hat Trial gesehen" und „hat keinen gesehen" vermischt. + +Aufwand: ca. ein halber Tag. Ohne diesen Punkt ist D3 nicht releasefähig. + +## 6. Mock & Tests + +- [ ] [mockBackendService.ts:48-57](services/backend/mockBackendService.ts:48) — beide Konstanten. +- [ ] [mockBackendService.ts:258](services/backend/mockBackendService.ts:258) — `availableProducts`. +- [ ] [mockBackendService.ts:1089](services/backend/mockBackendService.ts:1089) — Abo-Zweig in `simulatePurchase`. +- [ ] `__tests__/services/mockBackendService.test.ts` erweitern. +- [ ] `server/test/billing.test.js`: Test, dass `weekly_pro` das Pro-Entitlement erteilt + (aktuell keine Produkt-IDs in dieser Datei — neuer Test). +- [ ] `npm run test` + `npx tsc --noEmit`. + +## 7. Analytics + +- [ ] [billing.tsx:511-529](app/profile/billing.tsx:511) — `trial_enabled` ist ein Boolean, abgeleitet + aus `selectedPaywallPlan === 'yearly'`. Mit drei Plänen ist Weekly im Funnel nicht von + Monthly unterscheidbar. **`selected_plan: PaywallPlanId` als Property ergänzen** und die + PostHog-Funnels darauf umstellen. +- [ ] `subscription_started` / `purchase_initiated` tragen bereits `product_id` — ok. + +## 8. Rechtliches / Landing + +- [ ] [PrivacyContent.tsx](greenlns-landing/app/privacy/PrivacyContent.tsx) und Terms prüfen: Werden + Abo-Laufzeiten dort einzeln aufgezählt? Dann Wochenabo ergänzen. +- [ ] App-Store-Beschreibung (siehe Abschnitt 1, letzter Punkt). +- [ ] Die Landing Page listet aktuell **keine** Preise — nichts zu tun, außer du willst es dort zeigen. + +## 9. Build, Test, Release + +- [ ] Dev-Build/TestFlight — Expo Go kann keine echten Käufe (`Constants.appOwnership === 'expo'` + → Simulationspfad, [billing.tsx:88](app/profile/billing.tsx:88)). +- [ ] Sandbox-Tester: Wochenabo kaufen → prüfen dass + (a) Paywall alle drei Preise aus dem Store zieht (nicht die Fallback-Copy), + (b) Plan sofort auf Pro springt (Client-Sync, Abschnitt 4/5.1), + (c) Credits stimmen, + (d) RevenueCat-Dashboard das Event zeigt, + (e) Discord-Sales-Webhook „Pro (wöchentlich)" meldet, + (f) „Käufe wiederherstellen" funktioniert. +- [ ] **Trial-Szenarien mit zwei frischen Sandbox-Accounts** (Eligibility lässt sich pro Account + nicht zurücksetzen — pro Durchlauf ein neuer Tester): + (a) frischer Account → Weekly zeigt „3 Tage gratis", Abbuchung erst an Tag 3; + (b) Account, der den Weekly-Trial verbraucht hat → Yearly zeigt **kein** „7 Tage gratis" + und **kein** „Heute fällig 0,00 €"; + (c) umgekehrt: Yearly-Trial verbraucht → Weekly zeigt keinen Trial. + Fällt (b) oder (c) durch, ist der Release blockiert (siehe 5.4). +- [ ] Upgrade weekly → monthly/yearly im Sandbox testen (Group Ranking). +- [ ] Backend deployen (Root-`docker-compose.yml`), **vor** dem App-Release — sonst laufen erste + Käufe gegen ein Backend, das `weekly_pro` nicht kennt. +- [ ] Build & Submit: + `npx eas-cli build:version:set -p ios` → `npx eas-cli build -p ios --profile production` + → `npx eas-cli submit -p ios --latest` +- [ ] Beim Submit die neue Subscription im Review mit einreichen. + +--- + +## Reihenfolge + +1. D1–D4 entscheiden +2. App Store Connect + RevenueCat (Vorlaufzeit: Store-Sync + Review) +3. Backend (Abschnitt 4) → deployen +4. App (5 + 6 + 7) +5. Sandbox-Verifikation (9) +6. Release + +## Größte Stolperfallen + +1. **Trial-Eligibility gilt pro Subscription Group.** Zwei Trial-Produkte in einer Gruppe, aber + null Eligibility-Prüfung im Code → die Paywall verspricht „0,00 € heute" an Leute, die sofort + abgebucht werden. Abschnitt 5.4, blockiert den Release. +2. `SUPPORTED_SUBSCRIPTION_PRODUCTS` an **beiden** Stellen (Server + AppContext) — sonst bleibt + der Käufer nach dem Kauf auf `free`. +3. Die drei hartkodierten `monthly_pro || yearly_pro`-Vergleiche in `billing.tsx`. +4. Der binäre Paywall-Toggle trägt keine dritte Option — echte UI-Arbeit, nicht nur ein String. +5. Neue IAPs brauchen einen App-Build im Review. +6. `TRIAL_MONTHLY_CREDITS = 30` gilt auch für den 3-Tage-Weekly-Trial. +7. Bei Option B: `isAllowedMonthlyAllowance` überschreibt abweichende Allowances still. diff --git a/server/lib/billing.js b/server/lib/billing.js index 81557f3..425fe36 100644 --- a/server/lib/billing.js +++ b/server/lib/billing.js @@ -5,9 +5,10 @@ const TRIAL_MONTHLY_CREDITS = 30; const PRO_MONTHLY_CREDITS = 100; const TOPUP_DEFAULT_CREDITS = 100; const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro'; -const SUPPORTED_SUBSCRIPTION_PRODUCTS = new Set(['monthly_pro', 'yearly_pro']); +const SUPPORTED_SUBSCRIPTION_PRODUCTS = new Set(['weekly_pro', 'monthly_pro', 'yearly_pro']); const TOPUP_CREDITS_BY_PRODUCT = { + weekly_pro: 0, monthly_pro: 0, yearly_pro: 0, topup_small: 30, @@ -15,7 +16,7 @@ const TOPUP_CREDITS_BY_PRODUCT = { topup_large: 250, }; -const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large']; +const AVAILABLE_PRODUCTS = ['weekly_pro', 'monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large']; const nowIso = () => new Date().toISOString(); @@ -872,4 +873,6 @@ module.exports = { getAvailableCredits, consumeCredits, buildBillingSummary, + getValidProEntitlement, + applyRevenueCatEntitlementState, }; diff --git a/server/lib/discord.js b/server/lib/discord.js index d5731c1..27ca8e1 100644 --- a/server/lib/discord.js +++ b/server/lib/discord.js @@ -9,6 +9,7 @@ const PURCHASE_EVENT_TYPES = new Set([ ]); const PLAN_NAMES_BY_PRODUCT = { + weekly_pro: 'Pro (wöchentlich)', monthly_pro: 'Pro (monatlich)', yearly_pro: 'Pro (jährlich)', topup_small: 'Top-up Small (30 Credits)', diff --git a/server/test/billing.test.js b/server/test/billing.test.js index fdf4360..e086c00 100644 --- a/server/test/billing.test.js +++ b/server/test/billing.test.js @@ -7,6 +7,9 @@ const { consumeCredits, ensureSufficientCredits, getMonthlyAllowanceForPlan, + getValidProEntitlement, + applyRevenueCatEntitlementState, + AVAILABLE_PRODUCTS, } = require('../lib/billing'); const NOW = new Date('2026-07-06T12:00:00Z'); @@ -88,3 +91,42 @@ test('ensureSufficientCredits passes when balance covers cost', () => { const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1, topupBalance: 0 }); assert.doesNotThrow(() => ensureSufficientCredits(account, 2)); }); + +const proEntitlement = (productIdentifier, extra = {}) => ({ + entitlements: { active: { pro: { productIdentifier, ...extra } } }, + allPurchasedProductIdentifiers: [productIdentifier], +}); + +test('weekly_pro is an offered product', () => { + assert.ok(AVAILABLE_PRODUCTS.includes('weekly_pro')); +}); + +test('weekly_pro backs a valid pro entitlement', () => { + // Ohne diesen Eintrag in SUPPORTED_SUBSCRIPTION_PRODUCTS verwirft der Sync das + // aktive Entitlement und der Wochenabonnent bleibt nach dem Kauf auf 'free'. + const entitlement = getValidProEntitlement(proEntitlement('weekly_pro')); + assert.ok(entitlement); + assert.equal(entitlement.productIdentifier, 'weekly_pro'); +}); + +test('monthly_pro and yearly_pro still back a valid pro entitlement', () => { + assert.ok(getValidProEntitlement(proEntitlement('monthly_pro'))); + assert.ok(getValidProEntitlement(proEntitlement('yearly_pro'))); +}); + +test('an unrelated product does not back a pro entitlement', () => { + // Regressionsschutz: die Produktliste darf sich nicht zu einem Freibrief entwickeln. + assert.equal(getValidProEntitlement(proEntitlement('topup_small')), null); +}); + +test('weekly_pro grants the full pro allowance, its trial only the trial allowance', () => { + const paid = freeAccount(); + applyRevenueCatEntitlementState(paid, { active: true, isTrial: false, renewsAt: null }); + assert.equal(paid.plan, 'pro'); + assert.equal(paid.monthlyAllowance, 100); + + const trial = freeAccount(); + applyRevenueCatEntitlementState(trial, { active: true, isTrial: true, renewsAt: null }); + assert.equal(trial.plan, 'pro'); + assert.equal(trial.monthlyAllowance, 30); +}); diff --git a/services/backend/contracts.ts b/services/backend/contracts.ts index 9a760d7..451b2d7 100644 --- a/services/backend/contracts.ts +++ b/services/backend/contracts.ts @@ -2,7 +2,7 @@ import { CareInfo, IdentificationResult, Language, PlantHealthCheck } from '../. export type PlanId = 'free' | 'pro'; export type BillingProvider = 'mock' | 'revenuecat' | 'stripe'; -export type PurchaseProductId = 'monthly_pro' | 'yearly_pro' | 'topup_small' | 'topup_medium' | 'topup_large'; +export type PurchaseProductId = 'weekly_pro' | 'monthly_pro' | 'yearly_pro' | 'topup_small' | 'topup_medium' | 'topup_large'; export type SimulatedWebhookEvent = | 'entitlement_granted' | 'entitlement_revoked' diff --git a/services/backend/mockBackendService.ts b/services/backend/mockBackendService.ts index 9863f0b..a4ab06c 100644 --- a/services/backend/mockBackendService.ts +++ b/services/backend/mockBackendService.ts @@ -46,6 +46,7 @@ const PRO_SIMULATED_DELAY_MS = 280; const TOPUP_DEFAULT_CREDITS = 100; const TOPUP_CREDITS_BY_PRODUCT: Record = { + weekly_pro: 0, monthly_pro: 0, yearly_pro: 0, topup_small: 30, @@ -53,8 +54,15 @@ const TOPUP_CREDITS_BY_PRODUCT: Record = { topup_large: 250, }; +// Nur für die Mock-Simulation: wie weit renewsAt nach einem Kauf in der Zukunft liegt. +const RENEWAL_DAYS_BY_SUBSCRIPTION: Record = { + weekly_pro: 7, + monthly_pro: 30, + yearly_pro: 365, +}; + const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro'; -const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set(['monthly_pro', 'yearly_pro']); +const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set(['weekly_pro', 'monthly_pro', 'yearly_pro']); interface MockAccountRecord { userId: string; @@ -255,7 +263,7 @@ const buildBillingSummary = (account: MockAccountRecord): BillingSummary => { cycleStartedAt: account.cycleStartedAt, cycleEndsAt: account.cycleEndsAt, }, - availableProducts: ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'], + availableProducts: ['weekly_pro', 'monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'], }; }; @@ -827,56 +835,56 @@ export const mockBackendService = { }; } - let creditsCharged = 0; - const modelPath: string[] = []; - - if (request.userId === 'guest') { - modelPath.push('guest-demo-no-credit'); - } else { - creditsCharged += consumeCreditsWithIdempotency( - account, - stores.idempotency, - chargeKey('scan-primary', request.userId, request.idempotencyKey), - SCAN_PRIMARY_COST, - ); - } + let creditsCharged = 0; + const modelPath: string[] = []; + + if (request.userId === 'guest') { + modelPath.push('guest-demo-no-credit'); + } else { + creditsCharged += consumeCreditsWithIdempotency( + account, + stores.idempotency, + chargeKey('scan-primary', request.userId, request.idempotencyKey), + SCAN_PRIMARY_COST, + ); + } let usedOpenAi = false; let result: IdentificationResult = getMockPlantByImage(request.imageUri, request.language, false); - if (openAiScanService.isConfigured()) { - const openAiPrimary = await openAiScanService.identifyPlant( - request.imageUri, - request.language, - 'primary', - 'pro', - ); - if (openAiPrimary) { - result = openAiPrimary; - usedOpenAi = true; - modelPath.push('openai-primary'); - } else { - if (request.userId === 'guest') { - throw new BackendApiError( - 'PROVIDER_ERROR', - 'AI demo scan failed. Please try again with a clearer plant photo.', - 502, - ); - } - result = getMockPlantByImage(request.imageUri, request.language, false); - modelPath.push('openai-primary-failed'); - modelPath.push('mock-primary-fallback'); - } - } else { - if (request.userId === 'guest') { - throw new BackendApiError( - 'PROVIDER_ERROR', - 'AI demo scan is unavailable. Please configure OpenAI before enabling guest demo scans.', - 502, - ); - } - modelPath.push('mock-primary'); - } + if (openAiScanService.isConfigured()) { + const openAiPrimary = await openAiScanService.identifyPlant( + request.imageUri, + request.language, + 'primary', + 'pro', + ); + if (openAiPrimary) { + result = openAiPrimary; + usedOpenAi = true; + modelPath.push('openai-primary'); + } else { + if (request.userId === 'guest') { + throw new BackendApiError( + 'PROVIDER_ERROR', + 'AI demo scan failed. Please try again with a clearer plant photo.', + 502, + ); + } + result = getMockPlantByImage(request.imageUri, request.language, false); + modelPath.push('openai-primary-failed'); + modelPath.push('mock-primary-fallback'); + } + } else { + if (request.userId === 'guest') { + throw new BackendApiError( + 'PROVIDER_ERROR', + 'AI demo scan is unavailable. Please configure OpenAI before enabling guest demo scans.', + 502, + ); + } + modelPath.push('mock-primary'); + } const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD; if (shouldReview && account.plan === 'pro') { @@ -1086,7 +1094,7 @@ export const mockBackendService = { const cachedResponse = readIdempotentResponse(stores.idempotency, idemEndpointKey); if (cachedResponse) return cachedResponse; - if (request.productId === 'monthly_pro' || request.productId === 'yearly_pro') { + if (SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS.has(request.productId)) { const now = new Date(); const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); account.plan = 'pro'; @@ -1095,7 +1103,7 @@ export const mockBackendService = { account.usedThisCycle = 0; account.cycleStartedAt = cycleStartedAt.toISOString(); account.cycleEndsAt = cycleEndsAt.toISOString(); - account.renewsAt = addDays(now, 30).toISOString(); + account.renewsAt = addDays(now, RENEWAL_DAYS_BY_SUBSCRIPTION[request.productId] ?? 30).toISOString(); } else { const credits = TOPUP_CREDITS_BY_PRODUCT[request.productId]; account.topupBalance += credits;