Fehler webhook discod

This commit is contained in:
2026-07-02 23:28:30 +02:00
parent 3505bc149d
commit 41327f9557
21 changed files with 15637 additions and 47071 deletions

View File

@@ -37,13 +37,13 @@ export interface BillingSummary {
availableProducts: PurchaseProductId[];
}
export interface RevenueCatEntitlementInfo {
productIdentifier?: string;
expirationDate?: string | null;
expiresDate?: string | null;
periodType?: string | null;
period_type?: string | null;
}
export interface RevenueCatEntitlementInfo {
productIdentifier?: string;
expirationDate?: string | null;
expiresDate?: string | null;
periodType?: string | null;
period_type?: string | null;
}
export interface RevenueCatNonSubscriptionTransaction {
productIdentifier?: string;
@@ -59,6 +59,7 @@ export interface RevenueCatCustomerInfo {
active: Record<string, RevenueCatEntitlementInfo>;
};
nonSubscriptions?: Record<string, RevenueCatNonSubscriptionTransaction[]>;
nonSubscriptionTransactions?: RevenueCatNonSubscriptionTransaction[];
allPurchasedProductIdentifiers?: string[];
latestExpirationDate?: string | null;
}

View File

@@ -2,26 +2,26 @@ 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';
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';
@@ -29,32 +29,32 @@ 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 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 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_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']);
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;
@@ -101,27 +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 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 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;
@@ -203,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 (!isAllowedMonthlyAllowance(next)) {
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();
@@ -233,20 +233,20 @@ const getOrCreateAccount = (stores: { accounts: AccountStore }, userId: string):
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 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,
},
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,
@@ -256,51 +256,57 @@ const buildBillingSummary = (account: MockAccountRecord): BillingSummary => {
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 normalizeRevenueCatTransactions = (
customerInfo: RevenueCatCustomerInfo,
): RevenueCatNonSubscriptionTransaction[] => {
// react-native-purchases sends a flat `nonSubscriptionTransactions` array;
// the RevenueCat REST API uses a `nonSubscriptions` record keyed by product.
const flat = Array.isArray(customerInfo?.nonSubscriptionTransactions)
? customerInfo.nonSubscriptionTransactions
: [];
const nonSubscriptions = customerInfo?.nonSubscriptions || {};
const grouped = Object.values(nonSubscriptions).flatMap((entries) => Array.isArray(entries) ? entries : []);
return [...flat, ...grouped];
};
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];
@@ -315,18 +321,18 @@ const writeIdempotentResponse = <T,>(store: IdempotencyStore, key: string, value
};
};
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);
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',
@@ -350,18 +356,18 @@ const consumeCredits = (account: MockAccountRecord, cost: number): number => {
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 },
);
};
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,
@@ -505,18 +511,18 @@ const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: numbe
'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,
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,
};
@@ -609,18 +615,18 @@ const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: numbe
'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,
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,
};
@@ -712,102 +718,102 @@ const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: numbe
'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,
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> => {
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);
@@ -987,14 +993,14 @@ export const mockBackendService = {
}
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(
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,
@@ -1021,13 +1027,13 @@ export const mockBackendService = {
HEALTH_CHECK_COST,
);
const healthCheck: PlantHealthCheck = {
generatedAt: nowIso(),
overallHealthScore: aiAnalysis.overallHealthScore,
status: aiAnalysis.status,
analysisSummary: aiAnalysis.analysisSummary,
likelyIssues: aiAnalysis.likelyIssues,
actionsNow: aiAnalysis.actionsNow,
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,