1149 lines
44 KiB
TypeScript
1149 lines
44 KiB
TypeScript
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import {
|
|
BackendApiError,
|
|
BillingProvider,
|
|
BillingSummary,
|
|
HealthCheckRequest,
|
|
HealthCheckResponse,
|
|
PlanId,
|
|
PurchaseProductId,
|
|
RevenueCatCustomerInfo,
|
|
RevenueCatEntitlementInfo,
|
|
RevenueCatNonSubscriptionTransaction,
|
|
RevenueCatSyncSource,
|
|
ScanPlantRequest,
|
|
ScanPlantResponse,
|
|
SemanticSearchRequest,
|
|
SemanticSearchResponse,
|
|
SimulatePurchaseRequest,
|
|
SimulatePurchaseResponse,
|
|
SimulateWebhookRequest,
|
|
SimulateWebhookResponse,
|
|
SyncRevenueCatStateResponse,
|
|
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 = 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 = 0;
|
|
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 = 100;
|
|
|
|
const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
|
|
monthly_pro: 0,
|
|
yearly_pro: 0,
|
|
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';
|
|
const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set<PurchaseProductId>(['monthly_pro', 'yearly_pro']);
|
|
|
|
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<string, MockAccountRecord>;
|
|
type IdempotencyStore = Record<string, IdempotencyRecord>;
|
|
|
|
const userLocks = new Map<string, Promise<void>>();
|
|
|
|
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 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;
|
|
};
|
|
|
|
const sleep = async (ms: number): Promise<void> => {
|
|
if (ms <= 0) return;
|
|
await new Promise(resolve => setTimeout(resolve, ms));
|
|
};
|
|
|
|
const withUserLock = async <T>(userId: string, worker: () => Promise<T>): Promise<T> => {
|
|
const previousLock = userLocks.get(userId) || Promise.resolve();
|
|
let releaseLock: () => void = () => {};
|
|
const activeLock = new Promise<void>((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 <T,>(key: string, fallbackValue: T): Promise<T> => {
|
|
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 <T,>(key: string, value: T): Promise<void> => {
|
|
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<AccountStore>(MOCK_ACCOUNT_STORE_KEY, {}),
|
|
readJson<IdempotencyStore>(MOCK_IDEMPOTENCY_STORE_KEY, {}),
|
|
]);
|
|
return { accounts, idempotency };
|
|
};
|
|
|
|
const persistStores = async (stores: { accounts: AccountStore; idempotency: IdempotencyStore }): Promise<void> => {
|
|
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 (!isAllowedMonthlyAllowance(next)) {
|
|
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 => {
|
|
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 {
|
|
entitlement: {
|
|
plan: account.plan,
|
|
provider: account.provider,
|
|
status: account.plan === 'pro' ? 'active' : 'inactive',
|
|
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 normalizeRevenueCatTransactions = (
|
|
customerInfo: RevenueCatCustomerInfo,
|
|
): RevenueCatNonSubscriptionTransaction[] => {
|
|
const nonSubscriptions = customerInfo?.nonSubscriptions || {};
|
|
return Object.values(nonSubscriptions).flatMap((entries) => Array.isArray(entries) ? entries : []);
|
|
};
|
|
|
|
const summarizeRevenueCatCustomerInfo = (customerInfo: RevenueCatCustomerInfo) => {
|
|
const activeEntitlements = customerInfo?.entitlements?.active || {};
|
|
return {
|
|
appUserId: customerInfo?.appUserId ?? null,
|
|
originalAppUserId: customerInfo?.originalAppUserId ?? null,
|
|
activeEntitlements: Object.entries(activeEntitlements).map(([id, entitlement]) => ({
|
|
id,
|
|
productIdentifier: entitlement?.productIdentifier ?? null,
|
|
expirationDate: entitlement?.expirationDate || entitlement?.expiresDate || null,
|
|
})),
|
|
allPurchasedProductIdentifiers: customerInfo?.allPurchasedProductIdentifiers ?? [],
|
|
nonSubscriptionTransactions: normalizeRevenueCatTransactions(customerInfo).map((transaction) => ({
|
|
productIdentifier: transaction?.productIdentifier ?? null,
|
|
transactionIdentifier: transaction?.transactionIdentifier || transaction?.transactionId || null,
|
|
})),
|
|
};
|
|
};
|
|
|
|
const getValidProEntitlement = (customerInfo: RevenueCatCustomerInfo): RevenueCatEntitlementInfo | null => {
|
|
const activeEntitlements = customerInfo?.entitlements?.active || {};
|
|
const proEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID];
|
|
if (!proEntitlement) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
proEntitlement.productIdentifier
|
|
&& SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS.has(proEntitlement.productIdentifier as PurchaseProductId)
|
|
) {
|
|
return proEntitlement;
|
|
}
|
|
|
|
console.warn('[Billing][Mock] Ignoring unsupported RevenueCat pro entitlement', summarizeRevenueCatCustomerInfo(customerInfo));
|
|
return null;
|
|
};
|
|
|
|
const readIdempotentResponse = <T,>(store: IdempotencyStore, key: string): T | null => {
|
|
const record = store[key];
|
|
if (!record) return null;
|
|
return record.response as T;
|
|
};
|
|
|
|
const writeIdempotentResponse = <T,>(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;
|
|
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',
|
|
`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 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,
|
|
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,
|
|
analysisSummary: status === 'critical'
|
|
? 'Die Pflanze zeigt mehrere Stresssignale, die schnell stabilisiert werden sollten. Der wichtigste Verdacht ist zu viel Feuchtigkeit im Wurzelbereich, kombiniert mit schwacher Lichtversorgung. Achte besonders auf weiche gelbe Blaetter, dunkle Stellen am Stiel und Erde, die lange nass bleibt. Wenn diese Zeichen zunehmen, kann die Pflanze innerhalb weniger Tage weiter an Blattspannung verlieren. Die Diagnose ist ein Mock-Ergebnis, aber der Plan ist bewusst konkret. Pruefe zuerst Drainage und Substrat, bevor du Duenger oder einen kompletten Standortwechsel einsetzt.'
|
|
: status === 'watch'
|
|
? 'Die Pflanze wirkt nicht akut gefaehrdet, zeigt aber erkennbare Pflege-Signale, die beobachtet werden sollten. Wahrscheinlich spielen Giessrhythmus, Licht und leichte Naehrstoffversorgung zusammen. Einzelne gelbliche oder matte Blaetter sind noch kein Notfall, koennen aber ein fruehes Muster anzeigen. Entscheidend ist, ob neue Blaetter stabil bleiben und ob die Erde zwischen den Wassergaben gleichmaessig abtrocknet. Der Plan fokussiert auf konstante Bedingungen statt hektische Eingriffe. Ein Vergleichsfoto nach einer Woche zeigt, ob die Anpassungen wirken.'
|
|
: 'Die Pflanze wirkt insgesamt stabil und braucht eher Feintuning als Rettungsmassnahmen. Einzelne Blattreaktionen koennen normale Alterung oder leichte Standortanpassung sein. Der Score spricht dafuer, dass keine akute Ursache dominiert. Beobachte trotzdem neue Flecken, haengende Triebe und Veraenderungen an den unteren Blaettern. Halte die Routine konstant, damit du echte Veraenderungen leichter erkennst. Nutze den naechsten Check als Verlaufskontrolle statt als Notfallmassnahme.',
|
|
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,
|
|
analysisSummary: status === 'critical'
|
|
? 'La planta muestra varias senales de estres que conviene estabilizar pronto. La sospecha principal es demasiada humedad en la zona de raices, combinada con luz insuficiente. Observa hojas amarillas blandas, manchas oscuras en tallos y sustrato que permanece mojado demasiado tiempo. Si estas senales aumentan, la planta puede perder firmeza en pocos dias. El diagnostico es simulado, pero el plan es concreto. Revisa drenaje y sustrato antes de fertilizar o cambiar toda la ubicacion.'
|
|
: status === 'watch'
|
|
? 'La planta no parece en peligro inmediato, pero muestra senales que deben observarse. Probablemente influyen el ritmo de riego, la luz y una nutricion ligera. Algunas hojas amarillas o apagadas no son una emergencia, pero pueden indicar un patron temprano. Lo importante es ver si las hojas nuevas se mantienen firmes y si el sustrato seca de forma regular. El plan prioriza condiciones constantes, no cambios bruscos. Una foto comparativa en una semana mostrara si los ajustes funcionan.'
|
|
: 'La planta parece estable y necesita pequenos ajustes mas que medidas de rescate. Algunas hojas pueden reflejar envejecimiento normal o adaptacion al lugar. El puntaje indica que no domina una causa urgente. Aun asi, observa manchas nuevas, tallos caidos y cambios en hojas inferiores. Mantén la rutina constante para detectar cambios reales. Usa el proximo chequeo como comparacion de evolucion.',
|
|
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,
|
|
analysisSummary: status === 'critical'
|
|
? 'The plant shows multiple stress signals that should be stabilized soon. The main suspicion is excess moisture around the roots, possibly combined with weak light. Watch for soft yellow leaves, dark stem areas, and soil that stays wet too long. If those signs increase, the plant may lose more leaf firmness within a few days. This is a mock diagnosis, but the plan is intentionally concrete. Check drainage and substrate before fertilizing or changing the whole routine.'
|
|
: status === 'watch'
|
|
? 'The plant does not look like an immediate emergency, but it has visible care signals worth tracking. Watering cadence, light level, and mild nutrition are the most likely levers. A few yellow or dull leaves are not automatically severe, but they can show an early pattern. The key is whether new leaves stay firm and whether soil dries predictably between watering. The plan focuses on stable conditions instead of abrupt changes. A comparison photo after one week will show whether the adjustments are working.'
|
|
: 'The plant looks broadly stable and needs fine-tuning rather than rescue care. Minor leaf reactions may reflect normal aging or placement adjustment. The score suggests no urgent single cause is dominating. Still, monitor new spots, drooping stems, and changes on lower leaves. Keep the routine steady so real changes are easier to see. Use the next check as a trend comparison rather than an emergency intervention.',
|
|
likelyIssues,
|
|
actionsNow,
|
|
plan7Days,
|
|
creditsCharged,
|
|
imageUri: request.imageUri,
|
|
};
|
|
};
|
|
|
|
export const mockBackendService = {
|
|
getBillingSummary: async (userId: string): Promise<BillingSummary> => {
|
|
return withUserLock(userId, async () => {
|
|
const stores = await loadStores();
|
|
const account = getOrCreateAccount(stores, userId);
|
|
account.updatedAt = nowIso();
|
|
await persistStores(stores);
|
|
return buildBillingSummary(account);
|
|
});
|
|
},
|
|
|
|
syncRevenueCatState: async (request: {
|
|
userId: string;
|
|
customerInfo: RevenueCatCustomerInfo;
|
|
source?: RevenueCatSyncSource;
|
|
}): Promise<SyncRevenueCatStateResponse> => {
|
|
return withUserLock(request.userId, async () => {
|
|
const stores = await loadStores();
|
|
const account = getOrCreateAccount(stores, request.userId);
|
|
const proEntitlement = getValidProEntitlement(request.customerInfo);
|
|
const source = request.source || 'app_init';
|
|
|
|
console.log('[Billing][Mock] Syncing RevenueCat customer info', {
|
|
source,
|
|
customerInfo: summarizeRevenueCatCustomerInfo(request.customerInfo),
|
|
});
|
|
|
|
if (source !== 'topup_purchase') {
|
|
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 = 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)) {
|
|
const productId = transaction.productIdentifier as PurchaseProductId | undefined;
|
|
const transactionId = transaction.transactionIdentifier || transaction.transactionId;
|
|
if (!productId || !transactionId || !productId.startsWith('topup_')) {
|
|
continue;
|
|
}
|
|
|
|
const idempotencyKey = `revenuecat-topup:${transactionId}`;
|
|
if (stores.idempotency[idempotencyKey]) {
|
|
continue;
|
|
}
|
|
|
|
account.topupBalance += TOPUP_CREDITS_BY_PRODUCT[productId] || 0;
|
|
writeIdempotentResponse(stores.idempotency, idempotencyKey, { transactionId, productId });
|
|
}
|
|
|
|
account.updatedAt = nowIso();
|
|
await persistStores(stores);
|
|
return {
|
|
billing: buildBillingSummary(account),
|
|
syncedAt: nowIso(),
|
|
};
|
|
});
|
|
},
|
|
|
|
scanPlant: async (request: ScanPlantRequest): Promise<ScanPlantResponse> => {
|
|
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<ScanPlantResponse>(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<SemanticSearchResponse> => {
|
|
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<SemanticSearchResponse>(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<HealthCheckResponse> => {
|
|
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<HealthCheckResponse>(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);
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
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,
|
|
analysisSummary: aiAnalysis.analysisSummary,
|
|
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<SimulatePurchaseResponse> => {
|
|
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<SimulatePurchaseResponse>(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<SimulateWebhookResponse> => {
|
|
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<SimulateWebhookResponse>(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;
|
|
});
|
|
},
|
|
};
|