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:
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
320
docs/superpowers/plans/2026-08-09-weekly-subscription-plan.md
Normal file
320
docs/superpowers/plans/2026-08-09-weekly-subscription-plan.md
Normal 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 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.
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user