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

@@ -2,7 +2,7 @@ import { CareInfo, IdentificationResult, Language, PlantHealthCheck } from '../.
export type PlanId = 'free' | 'pro';
export type BillingProvider = 'mock' | 'revenuecat' | 'stripe';
export type PurchaseProductId = 'monthly_pro' | 'yearly_pro' | 'topup_small' | 'topup_medium' | 'topup_large';
export type PurchaseProductId = 'weekly_pro' | 'monthly_pro' | 'yearly_pro' | 'topup_small' | 'topup_medium' | 'topup_large';
export type SimulatedWebhookEvent =
| 'entitlement_granted'
| 'entitlement_revoked'

View File

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