import AsyncStorage from '@react-native-async-storage/async-storage'; import { BackendApiError, BillingProvider, BillingSummary, HealthCheckRequest, HealthCheckResponse, PlanId, PurchaseProductId, ScanPlantRequest, ScanPlantResponse, SemanticSearchRequest, SemanticSearchResponse, SimulatePurchaseRequest, SimulatePurchaseResponse, SimulateWebhookRequest, SimulateWebhookResponse, isBackendApiError, } from './contracts'; import { getMockPlantByImage, searchMockCatalog } from './mockCatalog'; import { openAiScanService } from './openAiScanService'; 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 SCAN_PRIMARY_COST = 1; const SCAN_REVIEW_COST = 1; const SEMANTIC_SEARCH_COST = 2; const HEALTH_CHECK_COST = 2; 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_CREDITS_BY_PRODUCT: Record = { monthly_pro: 0, yearly_pro: 0, topup_small: 25, topup_medium: 75, topup_large: 200, }; interface MockAccountRecord { userId: string; plan: PlanId; provider: BillingProvider; cycleStartedAt: string; cycleEndsAt: string; monthlyAllowance: number; usedThisCycle: number; topupBalance: number; renewsAt: string | null; updatedAt: string; } interface IdempotencyRecord { response: unknown; createdAt: string; } type AccountStore = Record; type IdempotencyStore = Record; const userLocks = new Map>(); const nowIso = (): string => new Date().toISOString(); const startOfUtcMonth = (date: Date): Date => { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0)); }; const addUtcMonths = (date: Date, months: number): Date => { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, 0, 0, 0, 0)); }; const addDays = (date: Date, days: number): Date => { const result = new Date(date.getTime()); result.setUTCDate(result.getUTCDate() + days); return result; }; const getCycleBounds = (now: Date) => { const cycleStartedAt = startOfUtcMonth(now); const cycleEndsAt = addUtcMonths(cycleStartedAt, 1); 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 getSimulatedDelay = (plan: PlanId): number => { return plan === 'pro' ? PRO_SIMULATED_DELAY_MS : FREE_SIMULATED_DELAY_MS; }; const sleep = async (ms: number): Promise => { if (ms <= 0) return; await new Promise(resolve => setTimeout(resolve, ms)); }; const withUserLock = async (userId: string, worker: () => Promise): Promise => { const previousLock = userLocks.get(userId) || Promise.resolve(); let releaseLock: () => void = () => {}; const activeLock = new Promise((resolve) => { releaseLock = resolve; }); userLocks.set(userId, activeLock); await previousLock; try { return await worker(); } finally { releaseLock(); if (userLocks.get(userId) === activeLock) { userLocks.delete(userId); } } }; const readJson = async (key: string, fallbackValue: T): Promise => { try { const raw = await AsyncStorage.getItem(key); if (!raw) return fallbackValue; return JSON.parse(raw) as T; } catch (error) { console.error(`Failed to read mock backend key ${key}`, error); return fallbackValue; } }; const writeJson = async (key: string, value: T): Promise => { try { await AsyncStorage.setItem(key, JSON.stringify(value)); } catch (error) { console.error(`Failed to write mock backend key ${key}`, error); } }; const loadStores = async (): Promise<{ accounts: AccountStore; idempotency: IdempotencyStore }> => { const [accounts, idempotency] = await Promise.all([ readJson(MOCK_ACCOUNT_STORE_KEY, {}), readJson(MOCK_IDEMPOTENCY_STORE_KEY, {}), ]); return { accounts, idempotency }; }; const persistStores = async (stores: { accounts: AccountStore; idempotency: IdempotencyStore }): Promise => { await Promise.all([ writeJson(MOCK_ACCOUNT_STORE_KEY, stores.accounts), writeJson(MOCK_IDEMPOTENCY_STORE_KEY, stores.idempotency), ]); }; const buildDefaultAccount = (userId: string, now: Date): MockAccountRecord => { const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); const allowance = getMonthlyAllowanceForPlan('free', userId); return { userId, plan: 'free', provider: 'mock', cycleStartedAt: cycleStartedAt.toISOString(), cycleEndsAt: cycleEndsAt.toISOString(), monthlyAllowance: allowance, usedThisCycle: 0, topupBalance: 0, renewsAt: null, updatedAt: nowIso(), }; }; const alignAccountToCurrentCycle = (account: MockAccountRecord, now: Date): MockAccountRecord => { const next = { ...account }; const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan, next.userId); if (next.monthlyAllowance !== expectedMonthlyAllowance) { next.monthlyAllowance = expectedMonthlyAllowance; } if (!next.renewsAt && next.plan === 'pro' && next.provider === 'mock') { next.renewsAt = addDays(now, 30).toISOString(); } const cycleEndsAtMs = new Date(next.cycleEndsAt).getTime(); if (Number.isNaN(cycleEndsAtMs) || now.getTime() >= cycleEndsAtMs) { const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); next.cycleStartedAt = cycleStartedAt.toISOString(); next.cycleEndsAt = cycleEndsAt.toISOString(); next.usedThisCycle = 0; next.monthlyAllowance = expectedMonthlyAllowance; } return next; }; const getOrCreateAccount = (stores: { accounts: AccountStore }, userId: string): MockAccountRecord => { const now = new Date(); const existing = stores.accounts[userId] || buildDefaultAccount(userId, now); const aligned = alignAccountToCurrentCycle(existing, now); stores.accounts[userId] = aligned; return aligned; }; const getAvailableCredits = (account: MockAccountRecord): number => { const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle); return monthlyRemaining + Math.max(0, account.topupBalance); }; const buildBillingSummary = (account: MockAccountRecord): BillingSummary => { return { entitlement: { plan: account.plan, provider: account.provider, status: 'active', renewsAt: account.renewsAt, }, credits: { monthlyAllowance: account.monthlyAllowance, usedThisCycle: account.usedThisCycle, topupBalance: account.topupBalance, available: getAvailableCredits(account), cycleStartedAt: account.cycleStartedAt, cycleEndsAt: account.cycleEndsAt, }, availableProducts: ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'], }; }; const readIdempotentResponse = (store: IdempotencyStore, key: string): T | null => { const record = store[key]; if (!record) return null; return record.response as T; }; const writeIdempotentResponse = (store: IdempotencyStore, key: string, value: T): void => { store[key] = { response: value, createdAt: nowIso(), }; }; const consumeCredits = (account: MockAccountRecord, cost: number): number => { if (cost <= 0) return 0; const available = getAvailableCredits(account); if (available < cost) { throw new BackendApiError( 'INSUFFICIENT_CREDITS', `Insufficient credits. Required ${cost}, available ${available}.`, 402, { required: cost, available }, ); } let remaining = cost; const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle); if (monthlyRemaining > 0) { const monthlyUsage = Math.min(monthlyRemaining, remaining); account.usedThisCycle += monthlyUsage; remaining -= monthlyUsage; } if (remaining > 0 && account.topupBalance > 0) { const topupUsage = Math.min(account.topupBalance, remaining); account.topupBalance -= topupUsage; remaining -= topupUsage; } return cost; }; const consumeCreditsWithIdempotency = ( account: MockAccountRecord, idempotencyStore: IdempotencyStore, key: string, cost: number, ): number => { const existing = readIdempotentResponse<{ charged: number }>(idempotencyStore, key); if (existing) return existing.charged; const charged = consumeCredits(account, cost); writeIdempotentResponse(idempotencyStore, key, { charged }); return charged; }; const endpointKey = (scope: string, userId: string, idempotencyKey: string): string => { return `endpoint:${scope}:${userId}:${idempotencyKey}`; }; const chargeKey = (scope: string, userId: string, idempotencyKey: string): string => { return `charge:${scope}:${userId}:${idempotencyKey}`; }; const hashString = (value: string): number => { let hash = 0; for (let i = 0; i < value.length; i += 1) { hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0; } return Math.abs(hash); }; const clampConfidence = (value: number): number => { return Math.max(0.05, Math.min(0.99, Number(value.toFixed(2)))); }; const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: number): PlantHealthCheck => { const source = [ request.imageUri, request.plantContext?.name || '', request.plantContext?.botanicalName || '', request.plantContext?.careInfo?.light || '', request.plantContext?.careInfo?.temp || '', ].join('|'); const hash = hashString(source); const score = 40 + (hash % 56); const status: PlantHealthCheck['status'] = score >= 75 ? 'healthy' : score >= 55 ? 'watch' : 'critical'; const confidenceBase = 0.45 + ((hash % 20) / 100); const confidenceMid = 0.35 + (((hash >> 2) % 20) / 100); const confidenceLow = 0.25 + (((hash >> 4) % 20) / 100); if (request.language === 'de') { const likelyIssues = status === 'critical' ? [ { title: 'Moegliche Ueberwaesserung', confidence: clampConfidence(confidenceBase + 0.22), details: 'Gelbe, weiche Blaetter koennen auf zu nasse Erde hindeuten.', }, { title: 'Wurzelstress', confidence: clampConfidence(confidenceMid + 0.15), details: 'Pruefe, ob das Substrat verdichtet ist oder unangenehm riecht.', }, { title: 'Lichtmangel', confidence: clampConfidence(confidenceLow + 0.1), details: 'Zu wenig Licht kann zu Vergilbung und schwaecherem Wuchs fuehren.', }, ] : status === 'watch' ? [ { title: 'Leichter Naehrstoffmangel', confidence: clampConfidence(confidenceBase), details: 'Ein Teil der Vergilbung kann durch fehlende Naehrstoffe entstehen.', }, { title: 'Unregelmaessiges Giessen', confidence: clampConfidence(confidenceMid), details: 'Zu grosse Schwankungen zwischen trocken und sehr nass belasten die Pflanze.', }, { title: 'Standortstress', confidence: clampConfidence(confidenceLow), details: 'Zugluft oder haeufige Standortwechsel koennen Blattreaktionen ausloesen.', }, ] : [ { title: 'Leichte Lichtanpassung noetig', confidence: clampConfidence(confidenceBase - 0.1), details: 'Einige Blaetter zeigen milde Stressanzeichen, insgesamt wirkt die Pflanze stabil.', }, { title: 'Naechsten Duengertermin beobachten', confidence: clampConfidence(confidenceMid - 0.1), details: 'Bei weiterem Vergilben Duengung in kleiner Dosis einplanen.', }, { title: 'Normale Blattalterung', confidence: clampConfidence(confidenceLow - 0.05), details: 'Aeltere untere Blaetter duerfen gelegentlich gelb werden.', }, ]; const actionsNow = status === 'critical' ? [ 'Giessen fuer 3-5 Tage pausieren und Feuchtigkeit tief im Topf pruefen.', 'Gelbe oder matschige Blaetter sauber entfernen.', 'Topf auf gute Drainage pruefen; stehendes Wasser sofort entfernen.', ] : status === 'watch' ? [ 'Giessrhythmus fuer die naechsten 7 Tage konsistent halten.', 'Pflanze heller, aber ohne harte Mittagssonne stellen.', 'Auf Schaedlinge an Blattunterseiten kontrollieren.', ] : [ 'Aktuellen Pflegeplan beibehalten.', 'Nur bei deutlich trockener Erde giessen.', 'Gelbe Altblaetter nach Bedarf entfernen.', ]; const plan7Days = status === 'critical' ? [ 'Tag 1: Feuchtigkeit messen und Uebertopf entleeren.', 'Tag 3: Blattfarbe und Festigkeit erneut pruefen.', 'Tag 5: Bei nasser Erde Umtopfen mit luftiger Mischung erwaegen.', 'Tag 7: Neuen Foto-Health-Check ausfuehren.', ] : [ 'Tag 1: Standort und Lichtdauer notieren.', 'Tag 3: Leichte Drehung fuer gleichmaessigen Wuchs.', 'Tag 5: Bodenfeuchte vor Giessen kontrollieren.', 'Tag 7: Vergleichsfoto erstellen.', ]; return { generatedAt: nowIso(), overallHealthScore: score, status, likelyIssues, actionsNow, plan7Days, creditsCharged, imageUri: request.imageUri, }; } if (request.language === 'es') { const likelyIssues = status === 'critical' ? [ { title: 'Posible exceso de riego', confidence: clampConfidence(confidenceBase + 0.22), details: 'Hojas amarillas y blandas pueden indicar demasiada humedad.', }, { title: 'Estres de raiz', confidence: clampConfidence(confidenceMid + 0.15), details: 'Revisa si el sustrato esta compacto o con mal olor.', }, { title: 'Falta de luz', confidence: clampConfidence(confidenceLow + 0.1), details: 'La luz insuficiente puede causar amarilleo y crecimiento lento.', }, ] : status === 'watch' ? [ { title: 'Deficit leve de nutrientes', confidence: clampConfidence(confidenceBase), details: 'Parte del amarilleo puede venir de nutricion insuficiente.', }, { title: 'Riego irregular', confidence: clampConfidence(confidenceMid), details: 'Cambios bruscos entre seco y muy humedo estresan la planta.', }, { title: 'Estres de ubicacion', confidence: clampConfidence(confidenceLow), details: 'Corrientes de aire o cambios frecuentes pueden afectar las hojas.', }, ] : [ { title: 'Ajuste suave de luz', confidence: clampConfidence(confidenceBase - 0.1), details: 'Se observan signos leves, pero el estado general es bueno.', }, { title: 'Monitorear fertilizacion', confidence: clampConfidence(confidenceMid - 0.1), details: 'Si continua el amarilleo, aplicar dosis pequena de fertilizante.', }, { title: 'Envejecimiento normal', confidence: clampConfidence(confidenceLow - 0.05), details: 'Hojas inferiores viejas pueden amarillear de forma natural.', }, ]; const actionsNow = status === 'critical' ? [ 'Pausar riego 3-5 dias y comprobar humedad profunda.', 'Retirar hojas amarillas o blandas con herramienta limpia.', 'Verificar drenaje y eliminar agua acumulada.', ] : status === 'watch' ? [ 'Mantener riego estable durante 7 dias.', 'Mover a una zona mas luminosa sin sol fuerte directo.', 'Revisar plagas en el reverso de las hojas.', ] : [ 'Mantener rutina actual de cuidado.', 'Regar solo cuando el sustrato este claramente seco.', 'Retirar hojas amarillas antiguas si hace falta.', ]; const plan7Days = status === 'critical' ? [ 'Dia 1: Medir humedad y vaciar agua retenida.', 'Dia 3: Revisar color y firmeza de hojas.', 'Dia 5: Si sigue muy humedo, considerar trasplante con mezcla aireada.', 'Dia 7: Repetir health-check con foto nueva.', ] : [ 'Dia 1: Registrar ubicacion y horas de luz.', 'Dia 3: Girar planta ligeramente para crecimiento uniforme.', 'Dia 5: Comprobar humedad antes de regar.', 'Dia 7: Tomar foto de comparacion.', ]; return { generatedAt: nowIso(), overallHealthScore: score, status, likelyIssues, actionsNow, plan7Days, creditsCharged, imageUri: request.imageUri, }; } const likelyIssues = status === 'critical' ? [ { title: 'Possible overwatering', confidence: clampConfidence(confidenceBase + 0.22), details: 'Yellow and soft leaves can indicate excess moisture.', }, { title: 'Root stress', confidence: clampConfidence(confidenceMid + 0.15), details: 'Check if the substrate is compacted or has a sour smell.', }, { title: 'Low light stress', confidence: clampConfidence(confidenceLow + 0.1), details: 'Insufficient light can trigger yellowing and slower growth.', }, ] : status === 'watch' ? [ { title: 'Mild nutrient deficiency', confidence: clampConfidence(confidenceBase), details: 'Part of the yellowing may come from missing nutrients.', }, { title: 'Inconsistent watering', confidence: clampConfidence(confidenceMid), details: 'Large swings between dry and wet can stress foliage.', }, { title: 'Placement stress', confidence: clampConfidence(confidenceLow), details: 'Drafts or frequent location changes can affect leaves.', }, ] : [ { title: 'Minor light adjustment', confidence: clampConfidence(confidenceBase - 0.1), details: 'Mild stress signs are present, but overall condition looks stable.', }, { title: 'Monitor next feeding', confidence: clampConfidence(confidenceMid - 0.1), details: 'If yellowing continues, apply a light fertilizer dose.', }, { title: 'Normal leaf aging', confidence: clampConfidence(confidenceLow - 0.05), details: 'Older lower leaves can yellow naturally over time.', }, ]; const actionsNow = status === 'critical' ? [ 'Pause watering for 3-5 days and check deep soil moisture.', 'Remove yellow or mushy leaves with clean tools.', 'Ensure good drainage and empty standing water.', ] : status === 'watch' ? [ 'Keep watering schedule stable for 7 days.', 'Move to brighter indirect light.', 'Inspect leaf undersides for pests.', ] : [ 'Keep the current care routine.', 'Water only when soil is clearly dry.', 'Trim older yellow leaves if needed.', ]; const plan7Days = status === 'critical' ? [ 'Day 1: Check moisture and remove excess water.', 'Day 3: Re-check leaf color and firmness.', 'Day 5: If still soggy, repot into an airy mix.', 'Day 7: Run another health-check photo.', ] : [ 'Day 1: Note light duration and placement.', 'Day 3: Rotate plant slightly for even growth.', 'Day 5: Check soil moisture before watering.', 'Day 7: Take a comparison photo.', ]; return { generatedAt: nowIso(), overallHealthScore: score, status, likelyIssues, actionsNow, plan7Days, creditsCharged, imageUri: request.imageUri, }; }; export const mockBackendService = { getBillingSummary: async (userId: string): Promise => { return withUserLock(userId, async () => { const stores = await loadStores(); const account = getOrCreateAccount(stores, userId); account.updatedAt = nowIso(); await persistStores(stores); return buildBillingSummary(account); }); }, scanPlant: async (request: ScanPlantRequest): Promise => { const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => { const stores = await loadStores(); const account = getOrCreateAccount(stores, request.userId); const idemEndpointKey = endpointKey('scan', request.userId, request.idempotencyKey); const cachedResponse = readIdempotentResponse(stores.idempotency, idemEndpointKey); if (cachedResponse) { return { response: cachedResponse, simulatedDelayMs: getSimulatedDelay(account.plan), }; } let creditsCharged = 0; const modelPath: string[] = []; 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', account.plan === 'pro' ? 'pro' : 'free', ); if (openAiPrimary) { result = openAiPrimary; usedOpenAi = true; modelPath.push('openai-primary'); } else { result = getMockPlantByImage(request.imageUri, request.language, false); modelPath.push('openai-primary-failed'); modelPath.push('mock-primary-fallback'); } } else { modelPath.push('mock-primary'); } const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD; if (shouldReview && account.plan === 'pro') { try { creditsCharged += consumeCreditsWithIdempotency( account, stores.idempotency, chargeKey('scan-review', request.userId, request.idempotencyKey), SCAN_REVIEW_COST, ); if (usedOpenAi) { const openAiReview = await openAiScanService.identifyPlant( request.imageUri, request.language, 'review', account.plan === 'pro' ? 'pro' : 'free', ); if (openAiReview) { result = openAiReview; modelPath.push('openai-review'); } else { modelPath.push('openai-review-failed'); } } else { result = getMockPlantByImage(request.imageUri, request.language, true); modelPath.push('mock-review'); } } catch (error) { if (isBackendApiError(error) && error.code === 'INSUFFICIENT_CREDITS') { modelPath.push('review-skipped-insufficient-credits'); } else { throw error; } } } else if (shouldReview) { modelPath.push('review-skipped-free-plan'); } account.updatedAt = nowIso(); const response: ScanPlantResponse = { result, creditsCharged, modelPath, billing: buildBillingSummary(account), }; writeIdempotentResponse(stores.idempotency, idemEndpointKey, response); await persistStores(stores); return { response, simulatedDelayMs: getSimulatedDelay(account.plan), }; }); await sleep(simulatedDelayMs); return response; }, semanticSearch: async (request: SemanticSearchRequest): Promise => { const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => { const stores = await loadStores(); const account = getOrCreateAccount(stores, request.userId); const idemEndpointKey = endpointKey('semantic-search', request.userId, request.idempotencyKey); const cachedResponse = readIdempotentResponse(stores.idempotency, idemEndpointKey); if (cachedResponse) { return { response: cachedResponse, simulatedDelayMs: getSimulatedDelay(account.plan), }; } const normalizedQuery = request.query.trim(); if (!normalizedQuery) { const noResultResponse: SemanticSearchResponse = { status: 'no_results', results: [], creditsCharged: 0, billing: buildBillingSummary(account), }; writeIdempotentResponse(stores.idempotency, idemEndpointKey, noResultResponse); await persistStores(stores); return { response: noResultResponse, simulatedDelayMs: getSimulatedDelay(account.plan), }; } const creditsCharged = consumeCreditsWithIdempotency( account, stores.idempotency, chargeKey('semantic-search', request.userId, request.idempotencyKey), SEMANTIC_SEARCH_COST, ); const results = searchMockCatalog(request.query, request.language, 18); account.updatedAt = nowIso(); const response: SemanticSearchResponse = { status: results.length > 0 ? 'success' : 'no_results', results, creditsCharged, billing: buildBillingSummary(account), }; writeIdempotentResponse(stores.idempotency, idemEndpointKey, response); await persistStores(stores); return { response, simulatedDelayMs: getSimulatedDelay(account.plan), }; }); await sleep(simulatedDelayMs); return response; }, healthCheck: async (request: HealthCheckRequest): Promise => { const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => { const stores = await loadStores(); const account = getOrCreateAccount(stores, request.userId); const idemEndpointKey = endpointKey('health-check', request.userId, request.idempotencyKey); const cachedResponse = readIdempotentResponse(stores.idempotency, idemEndpointKey); if (cachedResponse) { return { response: cachedResponse, simulatedDelayMs: getSimulatedDelay(account.plan), }; } 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( 'PROVIDER_ERROR', 'OpenAI health check is unavailable. Please configure EXPO_PUBLIC_OPENAI_API_KEY.', 502, ); } const aiAnalysis = await openAiScanService.analyzePlantHealth( normalizedImageUri, request.language, request.plantContext, ); if (!aiAnalysis) { throw new BackendApiError( 'PROVIDER_ERROR', 'OpenAI health check failed. Please verify API key, network access, and image quality.', 502, ); } const creditsCharged = consumeCreditsWithIdempotency( account, stores.idempotency, chargeKey('health-check', request.userId, request.idempotencyKey), HEALTH_CHECK_COST, ); const healthCheck: PlantHealthCheck = { generatedAt: nowIso(), overallHealthScore: aiAnalysis.overallHealthScore, status: aiAnalysis.status, likelyIssues: aiAnalysis.likelyIssues, actionsNow: aiAnalysis.actionsNow, plan7Days: aiAnalysis.plan7Days, creditsCharged, imageUri: normalizedImageUri, }; account.updatedAt = nowIso(); const response: HealthCheckResponse = { healthCheck, creditsCharged, billing: buildBillingSummary(account), }; writeIdempotentResponse(stores.idempotency, idemEndpointKey, response); await persistStores(stores); return { response, simulatedDelayMs: getSimulatedDelay(account.plan), }; }); await sleep(simulatedDelayMs); return response; }, simulatePurchase: async (request: SimulatePurchaseRequest): Promise => { return withUserLock(request.userId, async () => { const stores = await loadStores(); const account = getOrCreateAccount(stores, request.userId); const idemEndpointKey = endpointKey('simulate-purchase', request.userId, request.idempotencyKey); const cachedResponse = readIdempotentResponse(stores.idempotency, idemEndpointKey); if (cachedResponse) return cachedResponse; if (request.productId === 'monthly_pro' || request.productId === 'yearly_pro') { const now = new Date(); const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); account.plan = 'pro'; account.provider = 'mock'; account.monthlyAllowance = PRO_MONTHLY_CREDITS; account.usedThisCycle = 0; account.cycleStartedAt = cycleStartedAt.toISOString(); account.cycleEndsAt = cycleEndsAt.toISOString(); account.renewsAt = addDays(now, 30).toISOString(); } else { const credits = TOPUP_CREDITS_BY_PRODUCT[request.productId]; account.topupBalance += credits; } account.updatedAt = nowIso(); const response: SimulatePurchaseResponse = { appliedProduct: request.productId, billing: buildBillingSummary(account), }; writeIdempotentResponse(stores.idempotency, idemEndpointKey, response); await persistStores(stores); return response; }); }, simulateWebhook: async (request: SimulateWebhookRequest): Promise => { return withUserLock(request.userId, async () => { const stores = await loadStores(); const account = getOrCreateAccount(stores, request.userId); const idemEndpointKey = endpointKey('simulate-webhook', request.userId, request.idempotencyKey); const cachedResponse = readIdempotentResponse(stores.idempotency, idemEndpointKey); if (cachedResponse) return cachedResponse; if (request.event === 'entitlement_granted') { const now = new Date(); const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); account.plan = 'pro'; account.provider = 'revenuecat'; account.monthlyAllowance = PRO_MONTHLY_CREDITS; account.usedThisCycle = 0; account.cycleStartedAt = cycleStartedAt.toISOString(); account.cycleEndsAt = cycleEndsAt.toISOString(); account.renewsAt = addDays(now, 30).toISOString(); } if (request.event === 'entitlement_revoked') { const now = new Date(); const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); account.plan = 'free'; account.provider = 'revenuecat'; account.monthlyAllowance = FREE_MONTHLY_CREDITS; account.usedThisCycle = 0; account.cycleStartedAt = cycleStartedAt.toISOString(); account.cycleEndsAt = cycleEndsAt.toISOString(); account.renewsAt = null; } if (request.event === 'topup_granted') { const credits = Math.max(1, request.payload?.credits || TOPUP_DEFAULT_CREDITS); account.topupBalance += credits; } if (request.event === 'credits_depleted') { account.usedThisCycle = account.monthlyAllowance; account.topupBalance = 0; } account.updatedAt = nowIso(); const response: SimulateWebhookResponse = { event: request.event, billing: buildBillingSummary(account), }; writeIdempotentResponse(stores.idempotency, idemEndpointKey, response); await persistStores(stores); return response; }); }, };