Hard paywall

This commit is contained in:
2026-04-28 20:35:53 +02:00
parent 05efbb9910
commit 86631a9bc0
15 changed files with 15251 additions and 14164 deletions

View File

@@ -29,9 +29,10 @@ import { IdentificationResult, PlantHealthCheck } from '../../types';
const MOCK_ACCOUNT_STORE_KEY = 'greenlens_mock_backend_accounts_v1';
const MOCK_IDEMPOTENCY_STORE_KEY = 'greenlens_mock_backend_idempotency_v1';
const FREE_MONTHLY_CREDITS = 15;
const GUEST_TRIAL_CREDITS = 5;
const PRO_MONTHLY_CREDITS = 250;
const FREE_MONTHLY_CREDITS = 0;
const GUEST_TRIAL_CREDITS = 0;
const TRIAL_MONTHLY_CREDITS = 30;
const PRO_MONTHLY_CREDITS = 100;
const SCAN_PRIMARY_COST = 1;
const SCAN_REVIEW_COST = 1;
@@ -42,14 +43,14 @@ const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
const FREE_SIMULATED_DELAY_MS = 1100;
const PRO_SIMULATED_DELAY_MS = 280;
const TOPUP_DEFAULT_CREDITS = 60;
const TOPUP_DEFAULT_CREDITS = 100;
const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
monthly_pro: 0,
yearly_pro: 0,
topup_small: 25,
topup_medium: 120,
topup_large: 300,
topup_small: 30,
topup_medium: 100,
topup_large: 250,
};
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
@@ -100,10 +101,27 @@ const getCycleBounds = (now: Date) => {
return { cycleStartedAt, cycleEndsAt };
};
const getMonthlyAllowanceForPlan = (plan: PlanId, userId?: string): number => {
if (userId === 'guest') return GUEST_TRIAL_CREDITS;
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
};
const getMonthlyAllowanceForPlan = (plan: PlanId, userId?: string): number => {
if (userId === 'guest') return GUEST_TRIAL_CREDITS;
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
};
const getRevenueCatPeriodType = (source?: RevenueCatEntitlementInfo | null): string => {
return String(source?.periodType || source?.period_type || '').trim().toLowerCase();
};
const isRevenueCatTrial = (source?: RevenueCatEntitlementInfo | null): boolean => {
return getRevenueCatPeriodType(source) === 'trial';
};
const isAllowedMonthlyAllowance = (account: MockAccountRecord): boolean => {
if (account.userId === 'guest') return account.monthlyAllowance === GUEST_TRIAL_CREDITS;
if (account.plan === 'pro') {
return account.monthlyAllowance === PRO_MONTHLY_CREDITS
|| account.monthlyAllowance === TRIAL_MONTHLY_CREDITS;
}
return account.monthlyAllowance === FREE_MONTHLY_CREDITS;
};
const getSimulatedDelay = (plan: PlanId): number => {
return plan === 'pro' ? PRO_SIMULATED_DELAY_MS : FREE_SIMULATED_DELAY_MS;
@@ -185,11 +203,11 @@ const buildDefaultAccount = (userId: string, now: Date): MockAccountRecord => {
};
const alignAccountToCurrentCycle = (account: MockAccountRecord, now: Date): MockAccountRecord => {
const next = { ...account };
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan, next.userId);
if (next.monthlyAllowance !== expectedMonthlyAllowance) {
next.monthlyAllowance = expectedMonthlyAllowance;
}
const next = { ...account };
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan, next.userId);
if (!isAllowedMonthlyAllowance(next)) {
next.monthlyAllowance = expectedMonthlyAllowance;
}
if (!next.renewsAt && next.plan === 'pro' && next.provider === 'mock') {
next.renewsAt = addDays(now, 30).toISOString();
@@ -215,10 +233,11 @@ const getOrCreateAccount = (stores: { accounts: AccountStore }, userId: string):
return aligned;
};
const getAvailableCredits = (account: MockAccountRecord): number => {
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
return monthlyRemaining + Math.max(0, account.topupBalance);
};
const getAvailableCredits = (account: MockAccountRecord): number => {
if (account.plan !== 'pro') return 0;
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
return monthlyRemaining + Math.max(0, account.topupBalance);
};
const buildBillingSummary = (account: MockAccountRecord): BillingSummary => {
return {
@@ -296,10 +315,18 @@ const writeIdempotentResponse = <T,>(store: IdempotencyStore, key: string, value
};
};
const consumeCredits = (account: MockAccountRecord, cost: number): number => {
if (cost <= 0) return 0;
const available = getAvailableCredits(account);
const consumeCredits = (account: MockAccountRecord, cost: number): number => {
if (cost <= 0) return 0;
if (account.plan !== 'pro') {
throw new BackendApiError(
'INSUFFICIENT_CREDITS',
`Insufficient credits. Required ${cost}, available 0.`,
402,
{ required: cost, available: 0 },
);
}
const available = getAvailableCredits(account);
if (available < cost) {
throw new BackendApiError(
'INSUFFICIENT_CREDITS',
@@ -323,8 +350,18 @@ const consumeCredits = (account: MockAccountRecord, cost: number): number => {
remaining -= topupUsage;
}
return cost;
};
return cost;
};
const ensureActiveProEntitlement = (account: MockAccountRecord, requiredCredits: number): void => {
if (account.plan === 'pro') return;
throw new BackendApiError(
'INSUFFICIENT_CREDITS',
`Insufficient credits. Required ${requiredCredits}, available 0.`,
402,
{ required: requiredCredits, available: 0 },
);
};
const consumeCreditsWithIdempotency = (
account: MockAccountRecord,
@@ -705,10 +742,29 @@ export const mockBackendService = {
});
if (source !== 'topup_purchase') {
account.plan = proEntitlement ? 'pro' : 'free';
const now = new Date();
const previousPlan = account.plan;
const previousMonthlyAllowance = account.monthlyAllowance;
const nextPlan = proEntitlement ? 'pro' : 'free';
const nextMonthlyAllowance = proEntitlement && isRevenueCatTrial(proEntitlement)
? TRIAL_MONTHLY_CREDITS
: getMonthlyAllowanceForPlan(nextPlan, account.userId);
const planChanged = previousPlan !== nextPlan;
const trialConvertedToPaid = previousPlan === 'pro'
&& previousMonthlyAllowance === TRIAL_MONTHLY_CREDITS
&& nextMonthlyAllowance === PRO_MONTHLY_CREDITS;
account.plan = nextPlan;
account.provider = 'revenuecat';
account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan, account.userId);
account.monthlyAllowance = nextMonthlyAllowance;
account.renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null;
if (planChanged || trialConvertedToPaid) {
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
account.cycleStartedAt = cycleStartedAt.toISOString();
account.cycleEndsAt = cycleEndsAt.toISOString();
account.usedThisCycle = 0;
}
}
for (const transaction of normalizeRevenueCatTransactions(request.customerInfo)) {
@@ -916,12 +972,14 @@ export const mockBackendService = {
}
const normalizedImageUri = request.imageUri.trim();
if (!normalizedImageUri) {
throw new BackendApiError('BAD_REQUEST', 'Health check requires an image URI.', 400);
}
if (!openAiScanService.isConfigured()) {
throw new BackendApiError(
if (!normalizedImageUri) {
throw new BackendApiError('BAD_REQUEST', 'Health check requires an image URI.', 400);
}
ensureActiveProEntitlement(account, HEALTH_CHECK_COST);
if (!openAiScanService.isConfigured()) {
throw new BackendApiError(
'PROVIDER_ERROR',
'OpenAI health check is unavailable. Please configure EXPO_PUBLIC_OPENAI_API_KEY.',
502,