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 <noreply@anthropic.com>
This commit is contained in:
Timo
2026-08-10 12:50:12 +02:00
parent 5da8027b68
commit e3a28b0a1c
9 changed files with 800 additions and 161 deletions

View File

@@ -241,6 +241,76 @@ describe('mockBackendService billing simulation', () => {
expect(response.billing.entitlement.renewsAt).toBe('2026-04-30T00:00:00.000Z'); 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 () => { it('limits RevenueCat trial entitlement to trial credits', async () => {
const response = await mockBackendService.syncRevenueCatState({ const response = await mockBackendService.syncRevenueCatState({
userId: 'test-user-rc-trial', userId: 'test-user-rc-trial',

View File

@@ -6,6 +6,7 @@ import { useRouter, useLocalSearchParams } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import Purchases, { import Purchases, {
INTRO_ELIGIBILITY_STATUS,
LOG_LEVEL, LOG_LEVEL,
PACKAGE_TYPE, PACKAGE_TYPE,
PRODUCT_CATEGORY, PRODUCT_CATEGORY,
@@ -20,11 +21,47 @@ import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types'; import { Language } from '../../types';
import { PurchaseProductId } from '../../services/backend/contracts'; import { PurchaseProductId } from '../../services/backend/contracts';
type SubscriptionProductId = 'monthly_pro' | 'yearly_pro'; type SubscriptionProductId = 'weekly_pro' | 'monthly_pro' | 'yearly_pro';
type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>; type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>;
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>; type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>; type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
type PaywallPlanId = 'monthly' | 'yearly'; type PaywallPlanId = 'weekly' | 'monthly' | 'yearly';
const PRODUCT_BY_PAYWALL_PLAN: Record<PaywallPlanId, SubscriptionProductId> = {
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<PaywallPlanId, number> = {
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<Record<SubscriptionProductId, boolean>>;
// 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<string, { status: INTRO_ELIGIBILITY_STATUS }>,
): TrialEligibility => {
return TRIAL_PRODUCTS.reduce<TrialEligibility>((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<TopupProductId, number> = { const TOPUP_CREDITS_BY_PRODUCT: Record<TopupProductId, number> = {
@@ -55,12 +92,14 @@ const resolveSubscriptionPackages = (offering: PurchasesOffering | null): Subscr
} }
const availablePackages = [ const availablePackages = [
offering.weekly,
offering.monthly, offering.monthly,
offering.annual, offering.annual,
...offering.availablePackages, ...offering.availablePackages,
].filter((value): value is PurchasesPackage => Boolean(value)); ].filter((value): value is PurchasesPackage => Boolean(value));
return { 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)), monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)),
yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)), 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. // Nackter Betrag: planCardPriceTrial/-Monthly haengen die Periode selbst an.
proPlanPriceBare: '4,99 €', proPlanPriceBare: '4,99 €',
proYearlyPlanPriceBare: '39,99 €', proYearlyPlanPriceBare: '39,99 €',
proWeeklyPlanPriceBare: '2,99 €',
proBadgeText: 'EMPFOHLEN', proBadgeText: 'EMPFOHLEN',
proYearlyPlanName: 'Pro', proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39,99 € / Jahr', proYearlyPlanPrice: '39,99 € / Jahr',
@@ -184,9 +224,14 @@ const getBillingCopy = (language: Language) => {
badgeFlexible: 'FLEXIBEL', badgeFlexible: 'FLEXIBEL',
planYearlyName: 'Jährlich - 7 Tage gratis', planYearlyName: 'Jährlich - 7 Tage gratis',
planMonthlyName: 'Monatlich', 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', saveBadge: '33 % SPAREN',
perYear: '/ Jahr', perYear: '/ Jahr',
perMonth: '/ Monat', perMonth: '/ Monat',
perWeek: '/ Woche',
breaksDownTo: 'Macht nur', breaksDownTo: 'Macht nur',
// Nur der Wert, kein ganzer Satz: das Label links sagt bereits // Nur der Wert, kein ganzer Satz: das Label links sagt bereits
// "Macht nur" - der Satz "entspricht X pro Monat" doppelte das und // "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}`, `Heute fällig: ${today} - dann ${later} am ${date}`,
detailsToggle: 'Fragen, die du jetzt vielleicht hast', detailsToggle: 'Fragen, die du jetzt vielleicht hast',
trialReminder: 'Wir erinnern dich 2 Tage vor Ablauf der Testphase.', 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.', 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`, planCardPriceTrial: (price: string) => `7 Tage gratis, dann ${price}/Jahr`,
planCardPriceMonthly: (price: string) => `${price}/Monat`, planCardPriceMonthly: (price: string) => `${price}/Monat`,
@@ -236,6 +283,7 @@ const getBillingCopy = (language: Language) => {
proPlanPrice: '4.99 EUR / Mes', proPlanPrice: '4.99 EUR / Mes',
proPlanPriceBare: '4,99 €', proPlanPriceBare: '4,99 €',
proYearlyPlanPriceBare: '39,99 €', proYearlyPlanPriceBare: '39,99 €',
proWeeklyPlanPriceBare: '2,99 €',
proBadgeText: 'RECOMENDADO', proBadgeText: 'RECOMENDADO',
proYearlyPlanName: 'Pro', proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Año', proYearlyPlanPrice: '39.99 EUR / Año',
@@ -288,9 +336,13 @@ const getBillingCopy = (language: Language) => {
badgeFlexible: 'FLEXIBLE', badgeFlexible: 'FLEXIBLE',
planYearlyName: 'Anual - 7 días gratis', planYearlyName: 'Anual - 7 días gratis',
planMonthlyName: 'Mensual', planMonthlyName: 'Mensual',
planWeeklyName: 'Semanal - 3 días gratis',
planYearlyNamePlain: 'Anual',
planWeeklyNamePlain: 'Semanal',
saveBadge: 'AHORRA 33 %', saveBadge: 'AHORRA 33 %',
perYear: '/ año', perYear: '/ año',
perMonth: '/ mes', perMonth: '/ mes',
perWeek: '/ semana',
breaksDownTo: 'Sale a solo', breaksDownTo: 'Sale a solo',
equivalentValue: (price: string) => `${price} / mes`, equivalentValue: (price: string) => `${price} / mes`,
faqQ1: '"Pero hay herramientas gratuitas."', faqQ1: '"Pero hay herramientas gratuitas."',
@@ -305,6 +357,7 @@ const getBillingCopy = (language: Language) => {
`Hoy pagas: ${today} - luego ${later} el ${date}`, `Hoy pagas: ${today} - luego ${later} el ${date}`,
detailsToggle: 'Preguntas que quizá tengas ahora', detailsToggle: 'Preguntas que quizá tengas ahora',
trialReminder: 'Te avisamos 2 días antes de que termine la prueba.', 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.', 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`, planCardPriceTrial: (price: string) => `7 días gratis, luego ${price}/año`,
planCardPriceMonthly: (price: string) => `${price}/mes`, planCardPriceMonthly: (price: string) => `${price}/mes`,
@@ -337,6 +390,7 @@ const getBillingCopy = (language: Language) => {
proPlanPrice: '4.99 EUR / Month', proPlanPrice: '4.99 EUR / Month',
proPlanPriceBare: '€4.99', proPlanPriceBare: '€4.99',
proYearlyPlanPriceBare: '€39.99', proYearlyPlanPriceBare: '€39.99',
proWeeklyPlanPriceBare: '€2.99',
proBadgeText: 'RECOMMENDED', proBadgeText: 'RECOMMENDED',
proYearlyPlanName: 'Pro', proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Year', proYearlyPlanPrice: '39.99 EUR / Year',
@@ -389,9 +443,13 @@ const getBillingCopy = (language: Language) => {
badgeFlexible: 'FLEXIBLE', badgeFlexible: 'FLEXIBLE',
planYearlyName: 'Yearly - 7 days free', planYearlyName: 'Yearly - 7 days free',
planMonthlyName: 'Monthly', planMonthlyName: 'Monthly',
planWeeklyName: 'Weekly - 3 days free',
planYearlyNamePlain: 'Yearly',
planWeeklyNamePlain: 'Weekly',
saveBadge: 'SAVE 33%', saveBadge: 'SAVE 33%',
perYear: '/ year', perYear: '/ year',
perMonth: '/ month', perMonth: '/ month',
perWeek: '/ week',
breaksDownTo: 'Breaks down to just', breaksDownTo: 'Breaks down to just',
equivalentValue: (price: string) => `${price} / month`, equivalentValue: (price: string) => `${price} / month`,
faqQ1: '"But free tools exist."', faqQ1: '"But free tools exist."',
@@ -406,6 +464,7 @@ const getBillingCopy = (language: Language) => {
`Due today: ${today} - then ${later} on ${date}`, `Due today: ${today} - then ${later} on ${date}`,
detailsToggle: 'Questions you might have right now', detailsToggle: 'Questions you might have right now',
trialReminder: 'We remind you 2 days before the trial ends.', 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.', 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`, planCardPriceTrial: (price: string) => `Free for 7 days, then ${price}/year`,
planCardPriceMonthly: (price: string) => `${price}/month`, planCardPriceMonthly: (price: string) => `${price}/month`,
@@ -440,8 +499,11 @@ export default function BillingScreen() {
const [storeError, setStoreError] = useState<string | null>(null); const [storeError, setStoreError] = useState<string | null>(null);
const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({}); const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({});
const [topupProducts, setTopupProducts] = useState<TopupProducts>({}); const [topupProducts, setTopupProducts] = useState<TopupProducts>({});
// Monthly is the default; the 7-day trial (yearly plan) must be opted into via the toggle. // Leeres Objekt = noch nichts geprueft, also auch keine Trial-Zusage zeigen.
const [selectedPaywallPlan, setSelectedPaywallPlan] = useState<PaywallPlanId>('monthly'); const [trialEligibility, setTrialEligibility] = useState<TrialEligibility>({});
// Weekly ist vorausgewaehlt: niedrigste Einstiegshuerde. Yearly steht
// gleichwertig darunter, Monthly als dritte Zeile.
const [selectedPaywallPlan, setSelectedPaywallPlan] = useState<PaywallPlanId>('weekly');
// Cancel Flow State // Cancel Flow State
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
@@ -468,13 +530,21 @@ export default function BillingScreen() {
try { try {
ensureRevenueCatConfigured(); ensureRevenueCatConfigured();
const [offerings, topups] = await Promise.all([ const [offerings, topups, eligibility] = await Promise.all([
Purchases.getOfferings(), Purchases.getOfferings(),
Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), 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<string, { status: INTRO_ELIGIBILITY_STATUS }>;
}),
]); ]);
if (cancelled) return; if (cancelled) return;
setTrialEligibility(readTrialEligibility(eligibility));
const currentOffering = offerings.current; const currentOffering = offerings.current;
const resolvedPackages = resolveSubscriptionPackages(currentOffering); const resolvedPackages = resolveSubscriptionPackages(currentOffering);
if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) { if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) {
@@ -508,14 +578,41 @@ export default function BillingScreen() {
}; };
}, [isExpoGo]); }, [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(() => { useEffect(() => {
try { try {
posthog.capture('paywall_viewed', { posthog.capture('paywall_viewed', {
plan_id: planId, plan_id: planId,
context: onboardingContext ? 'onboarding' : 'in_app', 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 {} } catch {}
if (showPaywallPlans) { if (showPaywallPlans) {
@@ -526,20 +623,40 @@ export default function BillingScreen() {
}); });
} catch {} } 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 monthlyPackage = subscriptionPackages.monthly_pro;
const yearlyPackage = subscriptionPackages.yearly_pro; const yearlyPackage = subscriptionPackages.yearly_pro;
// Fallback ohne Periode - sonst entsteht "4.99 EUR / Month/month". // 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 monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPriceBare;
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPriceBare; 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 trialEndDate = useMemo(() => {
const date = new Date(); 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'; const locale = language === 'de' ? 'de-DE' : language === 'es' ? 'es-ES' : 'en-US';
return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
}, [language]); }, [language, selectedTrialDays]);
const topupLabels = useMemo(() => ({ const topupLabels = useMemo(() => ({
topup_small: topupProducts.topup_small ? `${TOPUP_CREDITS_BY_PRODUCT.topup_small} Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall, 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); setIsUpdating(true);
try { try {
await simulatePurchase(productId); await simulatePurchase(productId);
if (productId === 'monthly_pro' || productId === 'yearly_pro') { if (isSubscriptionProductId(productId)) {
posthog.capture('subscription_started', { product_id: productId, simulated: true }); 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); setSubModalVisible(false);
router.replace(postPurchaseRoute); router.replace(postPurchaseRoute);
} else { } else {
@@ -626,7 +745,7 @@ export default function BillingScreen() {
if (isExpoGo) { if (isExpoGo) {
// ExpoGo has no native RevenueCat - use simulation for development only // ExpoGo has no native RevenueCat - use simulation for development only
setIsUpdating(false); setIsUpdating(false);
if (productId === 'monthly_pro' || productId === 'yearly_pro') { if (isSubscriptionProductId(productId)) {
Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [ Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [
{ text: copy.continueWithoutPro, style: 'cancel' }, { text: copy.continueWithoutPro, style: 'cancel' },
{ text: copy.expoGoSimulate, onPress: () => completeExpoGoSimulation(productId) }, { text: copy.expoGoSimulate, onPress: () => completeExpoGoSimulation(productId) },
@@ -637,13 +756,13 @@ export default function BillingScreen() {
return; return;
} else { } else {
ensureRevenueCatConfigured(); ensureRevenueCatConfigured();
if (productId === 'monthly_pro' || productId === 'yearly_pro') { if (isSubscriptionProductId(productId)) {
if (planId === 'pro') { if (planId === 'pro') {
await openAppleSubscriptions(); await openAppleSubscriptions();
setSubModalVisible(false); setSubModalVisible(false);
return; return;
} }
const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage; const selectedPackage = subscriptionPackages[productId];
const latestOffering = !selectedPackage const latestOffering = !selectedPackage
? await Purchases.getOfferings().then((offerings) => offerings.current) ? await Purchases.getOfferings().then((offerings) => offerings.current)
: null; : null;
@@ -660,7 +779,9 @@ export default function BillingScreen() {
?? await Purchases.getCustomerInfo(); ?? await Purchases.getCustomerInfo();
void syncRevenueCatState(customerInfo as any, 'subscription_purchase'); void syncRevenueCatState(customerInfo as any, 'subscription_purchase');
posthog.capture('subscription_started', { product_id: productId }); 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); setSubModalVisible(false);
setTimeout(() => router.replace(postPurchaseRoute), 0); setTimeout(() => router.replace(postPurchaseRoute), 0);
return; return;
@@ -743,14 +864,80 @@ export default function BillingScreen() {
} }
}; };
// Jahrespreis -> Monatsaequivalent, mit Waehrungssymbol aus dem Store-Preis. // Store-Preis -> Monatsaequivalent, mit Waehrungssymbol aus dem Store-Preis.
const yearlyMonthlyEquivalent = (() => { // periodsPerYear: 1 fuer das Jahresabo, 52 fuer das Wochenabo.
const numeric = parseFloat(yearlyPrice.replace(/[^0-9.,]/g, '').replace(',', '.')); const toMonthlyEquivalent = (price: string, periodsPerYear: number) => {
const numeric = parseFloat(price.replace(/[^0-9.,]/g, '').replace(',', '.'));
if (!Number.isFinite(numeric) || numeric <= 0) return null; if (!Number.isFinite(numeric) || numeric <= 0) return null;
const symbol = yearlyPrice.replace(/[0-9.,\s]/g, '') || '€'; const symbol = price.replace(/[0-9.,\s]/g, '') || '€';
const perMonth = (numeric / 12).toFixed(2).replace('.', language === 'en' ? '.' : ','); const perMonth = ((numeric * periodsPerYear) / 12).toFixed(2).replace('.', language === 'en' ? '.' : ',');
return `${perMonth} ${symbol}`.trim(); 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<PaywallPlanId, 'monthly'>) => {
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 (
<TouchableOpacity
key={plan}
onPress={() => 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,
]}
>
<View style={styles.planCardRow}>
<Ionicons
name={isSelected ? 'radio-button-on' : 'radio-button-off'}
size={22}
color={isSelected ? colors.primary : colors.textMuted}
/>
<View style={styles.offerHeaderLeft}>
<Text style={[styles.offerPlanName, { color: colors.text }]}>
{planName}
</Text>
<Text style={[styles.offerSaveText, { color: colors.primary }]}>
{isYearly ? copy.saveBadge : copy.badgeFlexible}
</Text>
</View>
<View style={styles.offerHeaderRight}>
<Text style={[styles.offerPrice, { color: colors.text }]}>
{isYearly ? yearlyPrice : weeklyPrice}
</Text>
<Text style={[styles.offerPricePeriod, { color: colors.textMuted }]}>
{isYearly ? copy.perYear : copy.perWeek}
</Text>
</View>
</View>
</TouchableOpacity>
);
};
if (showPaywallPlans) { if (showPaywallPlans) {
return ( return (
@@ -790,105 +977,84 @@ export default function BillingScreen() {
))} ))}
</View> </View>
{/* EINE Angebotskarte statt Toggle + Preiskarte. Der Plan, {/* Zwei gleichwertige Karten: Woechentlich und Jaehrlich - die
der Preis und der Kaufbutton stehen zusammen, die beiden Plaene mit Testphase. Monatlich steht darunter als
Alternative darunter als Textlink. Damit entfaellt der eigene, ebenfalls anklickbare Zeile: sichtbar vorhanden,
Switch, der die Preisaenderung nie sichtbar gemacht hat. */} aber ohne die Entscheidung zu dritteln. */}
<View <View style={styles.planPicker}>
{renderPlanCard('weekly')}
{renderPlanCard('yearly')}
</View>
<TouchableOpacity
onPress={() => setSelectedPaywallPlan('monthly')}
activeOpacity={0.86}
accessibilityRole="radio"
accessibilityState={{ selected: selectedPaywallPlan === 'monthly' }}
style={[ style={[
styles.offerCardOuter, styles.planRowMonthly,
{ {
backgroundColor: colors.surfaceMuted, borderColor: selectedPaywallPlan === 'monthly' ? colors.primary : colors.border,
borderColor: colors.primary, backgroundColor: selectedPaywallPlan === 'monthly' ? colors.surfaceMuted : 'transparent',
// Dezenter Glow statt Leuchtrahmen.
shadowColor: colors.primary,
}, },
]} ]}
> >
<View style={[styles.offerBadge, { backgroundColor: colors.primary }]}> <Ionicons
<Text style={[styles.offerBadgeText, { color: colors.onPrimary }]}> name={selectedPaywallPlan === 'monthly' ? 'radio-button-on' : 'radio-button-off'}
{trialEnabled ? copy.badgeMostPopular : copy.badgeFlexible} size={20}
color={selectedPaywallPlan === 'monthly' ? colors.primary : colors.textMuted}
/>
<Text style={[styles.planRowMonthlyText, { color: colors.textSecondary }]}>
{`${copy.planMonthlyName} · ${monthlyPrice} ${copy.perMonth}`}
</Text>
</TouchableOpacity>
{/* Monatsaequivalent - nur beim Jahresabo. */}
{selectedMonthlyEquivalent ? (
<View style={[styles.offerBreakdown, { backgroundColor: colors.surface }]}>
<Text style={[styles.offerBreakdownLabel, { color: colors.textSecondary }]}>
{copy.breaksDownTo}
</Text>
<Text
style={[styles.offerBreakdownValue, { color: colors.text }]}
numberOfLines={1}
adjustsFontSizeToFit
>
{copy.equivalentValue(selectedMonthlyEquivalent)}
</Text> </Text>
</View> </View>
) : null}
<View style={styles.offerHeaderRow}> <TouchableOpacity
<View style={styles.offerHeaderLeft}> style={[
<Text style={[styles.offerPlanName, { color: colors.text }]}> styles.offerCta,
{trialEnabled ? copy.planYearlyName : copy.planMonthlyName} { backgroundColor: colors.primary },
</Text> (!storeReady || isUpdating || Boolean(storeError)) && styles.disabledPlanCard,
{trialEnabled ? ( ]}
<Text style={[styles.offerSaveText, { color: colors.primary }]}>{copy.saveBadge}</Text> onPress={() => handlePurchase(selectedProductId)}
) : null} disabled={isUpdating || !storeReady || Boolean(storeError)}
</View> activeOpacity={0.86}
<View style={styles.offerHeaderRight}> >
<Text style={[styles.offerPrice, { color: colors.text }]}> {isUpdating || !storeReady ? (
{trialEnabled ? yearlyPrice : monthlyPrice} <ActivityIndicator color={colors.onPrimary} />
</Text> ) : (
<Text style={[styles.offerPricePeriod, { color: colors.textMuted }]}> <>
{trialEnabled ? copy.perYear : copy.perMonth}
</Text>
</View>
</View>
{trialEnabled && yearlyMonthlyEquivalent ? (
<View style={[styles.offerBreakdown, { backgroundColor: colors.surface }]}>
<Text style={[styles.offerBreakdownLabel, { color: colors.textSecondary }]}>
{copy.breaksDownTo}
</Text>
<Text <Text
style={[styles.offerBreakdownValue, { color: colors.text }]} style={[styles.offerCtaText, { color: colors.onPrimary }]}
numberOfLines={1} maxFontSizeMultiplier={1.2}
adjustsFontSizeToFit
> >
{copy.equivalentValue(yearlyMonthlyEquivalent)} {trialEnabled ? copy.ctaTrial : copy.ctaMonthly}
</Text> </Text>
</View> <Ionicons name="arrow-forward" size={19} color={colors.onPrimary} />
) : null} </>
)}
<TouchableOpacity </TouchableOpacity>
style={[
styles.offerCta,
{ backgroundColor: colors.primary },
(!storeReady || isUpdating || Boolean(storeError)) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase(trialEnabled ? 'yearly_pro' : 'monthly_pro')}
disabled={isUpdating || !storeReady || Boolean(storeError)}
activeOpacity={0.86}
>
{isUpdating || !storeReady ? (
<ActivityIndicator color={colors.onPrimary} />
) : (
<>
<Text
style={[styles.offerCtaText, { color: colors.onPrimary }]}
maxFontSizeMultiplier={1.2}
>
{trialEnabled ? copy.ctaTrial : copy.ctaMonthly}
</Text>
<Ionicons name="arrow-forward" size={19} color={colors.onPrimary} />
</>
)}
</TouchableOpacity>
{/* Planwechsel als Textlink - kein Schalter, sondern eine
sichtbar formulierte Alternative inkl. Preis. */}
<TouchableOpacity
onPress={() => setSelectedPaywallPlan(trialEnabled ? 'monthly' : 'yearly')}
style={styles.offerAltLink}
>
<Text style={[styles.offerAltLinkText, { color: colors.textSecondary }]}>
{trialEnabled
? copy.altMonthly(monthlyPrice)
: copy.altYearly(yearlyPrice)}
</Text>
</TouchableOpacity>
</View>
{trialEnabled ? ( {trialEnabled ? (
<View style={[styles.dueBanner, { backgroundColor: colors.surfaceMuted }]}> <View style={[styles.dueBanner, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="calendar-outline" size={16} color={colors.primary} /> <Ionicons name="calendar-outline" size={16} color={colors.primary} />
<Text style={[styles.dueBannerText, { color: colors.textSecondary }]}> <Text style={[styles.dueBannerText, { color: colors.textSecondary }]}>
{copy.dueSummary(copy.dueTodayAmount, yearlyPrice, trialEndDate)} {copy.dueSummary(copy.dueTodayAmount, selectedPrice, trialEndDate)}
</Text> </Text>
</View> </View>
) : null} ) : null}
@@ -954,7 +1120,9 @@ export default function BillingScreen() {
{/* Belief 6: Kuendigung und Verlaengerung stehen sichtbar {/* Belief 6: Kuendigung und Verlaengerung stehen sichtbar
vor der Entscheidung, nicht im Kleingedruckten. */} vor der Entscheidung, nicht im Kleingedruckten. */}
<Text style={[styles.paywallFooterText, styles.paywallFooterCentered, { color: colors.textMuted }]}> <Text style={[styles.paywallFooterText, styles.paywallFooterCentered, { color: colors.textMuted }]}>
{trialEnabled ? `${copy.cancelAnytime} · ${copy.trialReminder}` : copy.cancelAnytime} {trialEnabled
? `${copy.cancelAnytime} · ${selectedPaywallPlan === 'weekly' ? copy.trialReminderWeekly : copy.trialReminder}`
: copy.cancelAnytime}
</Text> </Text>
<Text style={[styles.paywallFooterText, styles.paywallFooterCentered, { color: colors.textMuted }]}> <Text style={[styles.paywallFooterText, styles.paywallFooterCentered, { color: colors.textMuted }]}>
{copy.cancelPath} {copy.cancelPath}
@@ -1354,6 +1522,33 @@ const styles = StyleSheet.create({
offerSaveText: { fontSize: 12.5, fontWeight: '800', letterSpacing: 0.4 }, offerSaveText: { fontSize: 12.5, fontWeight: '800', letterSpacing: 0.4 },
offerPrice: { fontSize: 25, fontWeight: '900', letterSpacing: -0.4 }, offerPrice: { fontSize: 25, fontWeight: '900', letterSpacing: -0.4 },
offerPricePeriod: { fontSize: 12.5, fontWeight: '600' }, 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: { offerBreakdown: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@@ -87,7 +87,7 @@ const isColorPalette = (v: string): v is ColorPalette =>
v === 'forest' || v === 'ocean' || v === 'sunset' || v === 'mono'; v === 'forest' || v === 'ocean' || v === 'sunset' || v === 'mono';
const isLanguage = (v: string): v is Language => v === 'de' || v === 'en' || v === 'es'; 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 REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set<PurchaseProductId>(['monthly_pro', 'yearly_pro']); const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set<PurchaseProductId>(['weekly_pro', 'monthly_pro', 'yearly_pro']);
const summarizeRevenueCatCustomerInfo = (customerInfo: RevenueCatCustomerInfo) => { const summarizeRevenueCatCustomerInfo = (customerInfo: RevenueCatCustomerInfo) => {
const activeEntitlements = customerInfo?.entitlements?.active || {}; const activeEntitlements = customerInfo?.entitlements?.active || {};
@@ -463,7 +463,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
cycleStartedAt: now.toISOString(), cycleStartedAt: now.toISOString(),
cycleEndsAt: renewsAt || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).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'],
}; };
} }

View File

@@ -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 34-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 13 — 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. D1D4 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.

View File

@@ -5,9 +5,10 @@ const TRIAL_MONTHLY_CREDITS = 30;
const PRO_MONTHLY_CREDITS = 100; const PRO_MONTHLY_CREDITS = 100;
const TOPUP_DEFAULT_CREDITS = 100; const TOPUP_DEFAULT_CREDITS = 100;
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro'; 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 = { const TOPUP_CREDITS_BY_PRODUCT = {
weekly_pro: 0,
monthly_pro: 0, monthly_pro: 0,
yearly_pro: 0, yearly_pro: 0,
topup_small: 30, topup_small: 30,
@@ -15,7 +16,7 @@ const TOPUP_CREDITS_BY_PRODUCT = {
topup_large: 250, 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(); const nowIso = () => new Date().toISOString();
@@ -872,4 +873,6 @@ module.exports = {
getAvailableCredits, getAvailableCredits,
consumeCredits, consumeCredits,
buildBillingSummary, buildBillingSummary,
getValidProEntitlement,
applyRevenueCatEntitlementState,
}; };

View File

@@ -9,6 +9,7 @@ const PURCHASE_EVENT_TYPES = new Set([
]); ]);
const PLAN_NAMES_BY_PRODUCT = { const PLAN_NAMES_BY_PRODUCT = {
weekly_pro: 'Pro (wöchentlich)',
monthly_pro: 'Pro (monatlich)', monthly_pro: 'Pro (monatlich)',
yearly_pro: 'Pro (jährlich)', yearly_pro: 'Pro (jährlich)',
topup_small: 'Top-up Small (30 Credits)', topup_small: 'Top-up Small (30 Credits)',

View File

@@ -7,6 +7,9 @@ const {
consumeCredits, consumeCredits,
ensureSufficientCredits, ensureSufficientCredits,
getMonthlyAllowanceForPlan, getMonthlyAllowanceForPlan,
getValidProEntitlement,
applyRevenueCatEntitlementState,
AVAILABLE_PRODUCTS,
} = require('../lib/billing'); } = require('../lib/billing');
const NOW = new Date('2026-07-06T12:00:00Z'); 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 }); const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1, topupBalance: 0 });
assert.doesNotThrow(() => ensureSufficientCredits(account, 2)); 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);
});

View File

@@ -2,7 +2,7 @@ import { CareInfo, IdentificationResult, Language, PlantHealthCheck } from '../.
export type PlanId = 'free' | 'pro'; export type PlanId = 'free' | 'pro';
export type BillingProvider = 'mock' | 'revenuecat' | 'stripe'; 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 = export type SimulatedWebhookEvent =
| 'entitlement_granted' | 'entitlement_granted'
| 'entitlement_revoked' | 'entitlement_revoked'

View File

@@ -46,6 +46,7 @@ const PRO_SIMULATED_DELAY_MS = 280;
const TOPUP_DEFAULT_CREDITS = 100; const TOPUP_DEFAULT_CREDITS = 100;
const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = { const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
weekly_pro: 0,
monthly_pro: 0, monthly_pro: 0,
yearly_pro: 0, yearly_pro: 0,
topup_small: 30, topup_small: 30,
@@ -53,8 +54,15 @@ const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
topup_large: 250, 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<string, number> = {
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 REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set<PurchaseProductId>(['monthly_pro', 'yearly_pro']); const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set<PurchaseProductId>(['weekly_pro', 'monthly_pro', 'yearly_pro']);
interface MockAccountRecord { interface MockAccountRecord {
userId: string; userId: string;
@@ -255,7 +263,7 @@ const buildBillingSummary = (account: MockAccountRecord): BillingSummary => {
cycleStartedAt: account.cycleStartedAt, cycleStartedAt: account.cycleStartedAt,
cycleEndsAt: account.cycleEndsAt, 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; let creditsCharged = 0;
const modelPath: string[] = []; const modelPath: string[] = [];
if (request.userId === 'guest') { if (request.userId === 'guest') {
modelPath.push('guest-demo-no-credit'); modelPath.push('guest-demo-no-credit');
} else { } else {
creditsCharged += consumeCreditsWithIdempotency( creditsCharged += consumeCreditsWithIdempotency(
account, account,
stores.idempotency, stores.idempotency,
chargeKey('scan-primary', request.userId, request.idempotencyKey), chargeKey('scan-primary', request.userId, request.idempotencyKey),
SCAN_PRIMARY_COST, SCAN_PRIMARY_COST,
); );
} }
let usedOpenAi = false; let usedOpenAi = false;
let result: IdentificationResult = getMockPlantByImage(request.imageUri, request.language, false); let result: IdentificationResult = getMockPlantByImage(request.imageUri, request.language, false);
if (openAiScanService.isConfigured()) { if (openAiScanService.isConfigured()) {
const openAiPrimary = await openAiScanService.identifyPlant( const openAiPrimary = await openAiScanService.identifyPlant(
request.imageUri, request.imageUri,
request.language, request.language,
'primary', 'primary',
'pro', 'pro',
); );
if (openAiPrimary) { if (openAiPrimary) {
result = openAiPrimary; result = openAiPrimary;
usedOpenAi = true; usedOpenAi = true;
modelPath.push('openai-primary'); modelPath.push('openai-primary');
} else { } else {
if (request.userId === 'guest') { if (request.userId === 'guest') {
throw new BackendApiError( throw new BackendApiError(
'PROVIDER_ERROR', 'PROVIDER_ERROR',
'AI demo scan failed. Please try again with a clearer plant photo.', 'AI demo scan failed. Please try again with a clearer plant photo.',
502, 502,
); );
} }
result = getMockPlantByImage(request.imageUri, request.language, false); result = getMockPlantByImage(request.imageUri, request.language, false);
modelPath.push('openai-primary-failed'); modelPath.push('openai-primary-failed');
modelPath.push('mock-primary-fallback'); modelPath.push('mock-primary-fallback');
} }
} else { } else {
if (request.userId === 'guest') { if (request.userId === 'guest') {
throw new BackendApiError( throw new BackendApiError(
'PROVIDER_ERROR', 'PROVIDER_ERROR',
'AI demo scan is unavailable. Please configure OpenAI before enabling guest demo scans.', 'AI demo scan is unavailable. Please configure OpenAI before enabling guest demo scans.',
502, 502,
); );
} }
modelPath.push('mock-primary'); modelPath.push('mock-primary');
} }
const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD; const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD;
if (shouldReview && account.plan === 'pro') { if (shouldReview && account.plan === 'pro') {
@@ -1086,7 +1094,7 @@ export const mockBackendService = {
const cachedResponse = readIdempotentResponse<SimulatePurchaseResponse>(stores.idempotency, idemEndpointKey); const cachedResponse = readIdempotentResponse<SimulatePurchaseResponse>(stores.idempotency, idemEndpointKey);
if (cachedResponse) return cachedResponse; 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 now = new Date();
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
account.plan = 'pro'; account.plan = 'pro';
@@ -1095,7 +1103,7 @@ export const mockBackendService = {
account.usedThisCycle = 0; account.usedThisCycle = 0;
account.cycleStartedAt = cycleStartedAt.toISOString(); account.cycleStartedAt = cycleStartedAt.toISOString();
account.cycleEndsAt = cycleEndsAt.toISOString(); account.cycleEndsAt = cycleEndsAt.toISOString();
account.renewsAt = addDays(now, 30).toISOString(); account.renewsAt = addDays(now, RENEWAL_DAYS_BY_SUBSCRIPTION[request.productId] ?? 30).toISOString();
} else { } else {
const credits = TOPUP_CREDITS_BY_PRODUCT[request.productId]; const credits = TOPUP_CREDITS_BY_PRODUCT[request.productId];
account.topupBalance += credits; account.topupBalance += credits;