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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user