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

@@ -0,0 +1,76 @@
# Discord-Nachrichten reparieren — einfache Anleitung
> ⚠️ Datei nicht ins Git committen (enthält die Webhook-URLs).
> Nach dem Erledigen löschen.
## Was ist das Problem? (in 3 Sätzen)
1. Es gibt ZWEI Nachrichten-Arten: 📲 "Neuer User" und 🛍️ "Neuer Kauf".
2. Für BEIDE fehlen wahrscheinlich die zwei Discord-URLs auf dem Server
(der Code selbst ist schon deployed).
3. Für die KAUF-Nachricht fehlt zusätzlich eine Einstellung im
RevenueCat-Dashboard: RevenueCat weiß gar nicht, dass es deinem Server
Käufe melden soll. Deshalb kam bei deinem Testkauf nichts an.
---
## Schritt 1: Auf dem Server — die zwei URLs eintragen
SSH auf den Server, dann die `.env`-Datei öffnen, die neben
`greenlns-landing/docker-compose.yml` liegt:
```bash
cd <repo>/greenlns-landing
nano .env
```
Diese zwei Zeilen ans Ende einfügen:
```
DISCORD_WEBHOOK_SALES_URL=https://discord.com/api/webhooks/1522232511047008367/SiBPKT1uQsOeFmAHHcLGIddXmUgPE87y4C3wPO4bTGVUgH5DxVWMEhy4gjpBCioQkDl3
DISCORD_WEBHOOK_DOWNLOADS_URL=https://discord.com/api/webhooks/1522232783555133560/7DElZz6q5X-OVsXnco3L_jmXGGwiVMoZcV8Tnz60DmCj2as_HUA3wY8k_mVGSflYsRPL
```
Speichern (in nano: Strg+O, Enter, Strg+X), dann neu starten:
```bash
git pull
docker compose up -d --build api
```
✅ Danach funktioniert die 📲 "Neuer User"-Nachricht.
---
## Schritt 2: Im RevenueCat-Dashboard — Webhook anlegen
Im Browser: RevenueCat Dashboard → dein Projekt →
**Integrations****Webhooks****+ New**
Dort genau das eintragen:
| Feld | Wert |
|----------------------|-----------------------------------------------------|
| Webhook URL | `https://greenlenspro.com/api/revenuecat/webhook` |
| Authorization header | `greenlens-rc-webhook-2026` |
| Environment | **All environments** (wichtig! sonst kein TestFlight)|
✅ Danach funktioniert die 🛍️ "Neuer Kauf"-Nachricht.
---
## Schritt 3: Testen
1. Claude Bescheid sagen → er macht einen Test-Signup von außen.
→ 📲 Nachricht muss im Downloads-Channel erscheinen.
2. In TestFlight einen Kauf machen.
→ 🛍️ "Neuer Kauf (Sandbox)" muss im Sales-Channel erscheinen.
---
## Gut zu wissen
- Der Kauf von heute wird NICHT nachträglich gemeldet — einfach neu testen.
- TestFlight-Käufe sind immer mit "(Sandbox)" markiert (kein echtes Geld).
- Abo-Verlängerungen heißen "🔁 Abo verlängert".
- Discord-Fehler stehen absichtlich in keinem Log (still ignorieren, wie gewünscht).

View File

@@ -1,6 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { mockBackendService } from '../../services/backend/mockBackendService'; import { mockBackendService } from '../../services/backend/mockBackendService';
import { openAiScanService } from '../../services/backend/openAiScanService'; import { openAiScanService } from '../../services/backend/openAiScanService';
jest.mock('@react-native-async-storage/async-storage', () => ({ jest.mock('@react-native-async-storage/async-storage', () => ({
getItem: jest.fn(), getItem: jest.fn(),
@@ -12,7 +12,7 @@ const asyncStorageMemory: Record<string, string> = {};
const mockedAsyncStorage = AsyncStorage as jest.Mocked<typeof AsyncStorage>; const mockedAsyncStorage = AsyncStorage as jest.Mocked<typeof AsyncStorage>;
const runScan = async (userId: string, idempotencyKey: string) => { const runScan = async (userId: string, idempotencyKey: string) => {
const settledPromise = mockBackendService.scanPlant({ const settledPromise = mockBackendService.scanPlant({
userId, userId,
idempotencyKey, idempotencyKey,
@@ -27,33 +27,33 @@ const runScan = async (userId: string, idempotencyKey: string) => {
const settled = await settledPromise; const settled = await settledPromise;
if (!settled.ok) throw settled.error; if (!settled.ok) throw settled.error;
return settled.value; return settled.value;
}; };
const runHealthCheck = async (userId: string, idempotencyKey: string) => { const runHealthCheck = async (userId: string, idempotencyKey: string) => {
const settledPromise = mockBackendService.healthCheck({ const settledPromise = mockBackendService.healthCheck({
userId, userId,
idempotencyKey, idempotencyKey,
imageUri: `data:image/jpeg;base64,${idempotencyKey}`, imageUri: `data:image/jpeg;base64,${idempotencyKey}`,
language: 'en', language: 'en',
plantContext: { plantContext: {
name: 'Monstera', name: 'Monstera',
botanicalName: 'Monstera deliciosa', botanicalName: 'Monstera deliciosa',
careInfo: { careInfo: {
waterIntervalDays: 7, waterIntervalDays: 7,
light: 'Bright indirect light', light: 'Bright indirect light',
temp: '18-24C', temp: '18-24C',
}, },
}, },
}).then( }).then(
value => ({ ok: true as const, value }), value => ({ ok: true as const, value }),
error => ({ ok: false as const, error }), error => ({ ok: false as const, error }),
); );
await Promise.resolve(); await Promise.resolve();
await jest.runAllTimersAsync(); await jest.runAllTimersAsync();
const settled = await settledPromise; const settled = await settledPromise;
if (!settled.ok) throw settled.error; if (!settled.ok) throw settled.error;
return settled.value; return settled.value;
}; };
describe('mockBackendService billing simulation', () => { describe('mockBackendService billing simulation', () => {
beforeEach(() => { beforeEach(() => {
@@ -75,11 +75,11 @@ describe('mockBackendService billing simulation', () => {
}); });
}); });
afterEach(() => { afterEach(() => {
jest.useRealTimers(); jest.useRealTimers();
jest.restoreAllMocks(); jest.restoreAllMocks();
jest.clearAllMocks(); jest.clearAllMocks();
}); });
it('keeps simulatePurchase idempotent for same idempotency key', async () => { it('keeps simulatePurchase idempotent for same idempotency key', async () => {
const userId = 'test-user-idempotency'; const userId = 'test-user-idempotency';
@@ -96,324 +96,342 @@ describe('mockBackendService billing simulation', () => {
productId: 'topup_small', productId: 'topup_small',
}); });
expect(first.billing.credits.topupBalance).toBe(30); expect(first.billing.credits.topupBalance).toBe(30);
expect(second.billing.credits.topupBalance).toBe(30); expect(second.billing.credits.topupBalance).toBe(30);
}); });
it('consumes plan credits before topup credits', async () => { it('consumes plan credits before topup credits', async () => {
const userId = 'test-user-credit-order'; const userId = 'test-user-credit-order';
await mockBackendService.simulatePurchase({ await mockBackendService.simulatePurchase({
userId, userId,
idempotencyKey: 'sub-order-1', idempotencyKey: 'sub-order-1',
productId: 'monthly_pro', productId: 'monthly_pro',
}); });
await mockBackendService.simulatePurchase({ await mockBackendService.simulatePurchase({
userId, userId,
idempotencyKey: 'topup-order-1', idempotencyKey: 'topup-order-1',
productId: 'topup_small', productId: 'topup_small',
}); });
let lastScan = await runScan(userId, 'scan-order-0'); let lastScan = await runScan(userId, 'scan-order-0');
expect(lastScan.billing.credits.usedThisCycle).toBe(1); expect(lastScan.billing.credits.usedThisCycle).toBe(1);
expect(lastScan.billing.credits.topupBalance).toBe(30); expect(lastScan.billing.credits.topupBalance).toBe(30);
let scanIndex = 1; let scanIndex = 1;
while ( while (
lastScan.billing.credits.usedThisCycle < 100 lastScan.billing.credits.usedThisCycle < 100
&& lastScan.billing.credits.topupBalance === 30 && lastScan.billing.credits.topupBalance === 30
&& scanIndex < 150 && scanIndex < 150
) { ) {
lastScan = await runScan(userId, `scan-order-${scanIndex}`); lastScan = await runScan(userId, `scan-order-${scanIndex}`);
scanIndex += 1; scanIndex += 1;
} }
if (lastScan.billing.credits.topupBalance === 30) { if (lastScan.billing.credits.topupBalance === 30) {
lastScan = await runScan(userId, `scan-order-${scanIndex}`); lastScan = await runScan(userId, `scan-order-${scanIndex}`);
} }
expect(lastScan.billing.credits.usedThisCycle).toBe(100); expect(lastScan.billing.credits.usedThisCycle).toBe(100);
expect(lastScan.billing.credits.topupBalance).toBeLessThan(30); expect(lastScan.billing.credits.topupBalance).toBeLessThan(30);
expect(lastScan.billing.credits.topupBalance).toBeGreaterThanOrEqual(0); expect(lastScan.billing.credits.topupBalance).toBeGreaterThanOrEqual(0);
}); });
it('can deplete all available credits via webhook simulation', async () => { it('can deplete all available credits via webhook simulation', async () => {
const userId = 'test-user-deplete-credits'; const userId = 'test-user-deplete-credits';
await mockBackendService.simulatePurchase({ await mockBackendService.simulatePurchase({
userId, userId,
idempotencyKey: 'topup-deplete-1', idempotencyKey: 'topup-deplete-1',
productId: 'topup_small', productId: 'topup_small',
}); });
const response = await mockBackendService.simulateWebhook({ const response = await mockBackendService.simulateWebhook({
userId, userId,
idempotencyKey: 'webhook-deplete-1', idempotencyKey: 'webhook-deplete-1',
event: 'credits_depleted', event: 'credits_depleted',
}); });
expect(response.billing.credits.available).toBe(0); expect(response.billing.credits.available).toBe(0);
expect(response.billing.credits.topupBalance).toBe(0); expect(response.billing.credits.topupBalance).toBe(0);
expect(response.billing.credits.usedThisCycle).toBe(response.billing.credits.monthlyAllowance); expect(response.billing.credits.usedThisCycle).toBe(response.billing.credits.monthlyAllowance);
}); });
it('does not double-charge scan when idempotency key is reused', async () => { it('does not double-charge scan when idempotency key is reused', async () => {
const userId = 'test-user-scan-idempotency'; const userId = 'test-user-scan-idempotency';
await mockBackendService.simulatePurchase({ await mockBackendService.simulatePurchase({
userId, userId,
idempotencyKey: 'sub-scan-idempotency', idempotencyKey: 'sub-scan-idempotency',
productId: 'monthly_pro', productId: 'monthly_pro',
}); });
const first = await runScan(userId, 'scan-abc'); const first = await runScan(userId, 'scan-abc');
const second = await runScan(userId, 'scan-abc'); const second = await runScan(userId, 'scan-abc');
expect(first.creditsCharged).toBeGreaterThan(0); expect(first.creditsCharged).toBeGreaterThan(0);
expect(second.creditsCharged).toBe(first.creditsCharged); expect(second.creditsCharged).toBe(first.creditsCharged);
expect(second.billing.credits.available).toBe(first.billing.credits.available); expect(second.billing.credits.available).toBe(first.billing.credits.available);
}); });
it('charges one credit for a normal scan after a two-credit health check', async () => { it('charges one credit for a normal scan after a two-credit health check', async () => {
const userId = 'test-user-health-then-scan-cost'; const userId = 'test-user-health-then-scan-cost';
jest.spyOn(openAiScanService, 'isConfigured').mockReturnValue(true); jest.spyOn(openAiScanService, 'isConfigured').mockReturnValue(true);
jest.spyOn(openAiScanService, 'analyzePlantHealth').mockResolvedValue({ jest.spyOn(openAiScanService, 'analyzePlantHealth').mockResolvedValue({
overallHealthScore: 72, overallHealthScore: 72,
status: 'watch', status: 'watch',
analysisSummary: 'Mild stress signs are visible.', analysisSummary: 'Mild stress signs are visible.',
likelyIssues: [ likelyIssues: [
{ {
title: 'Watering stress', title: 'Watering stress',
confidence: 0.62, confidence: 0.62,
details: 'The leaf texture suggests inconsistent watering.', details: 'The leaf texture suggests inconsistent watering.',
}, },
], ],
actionsNow: ['Check soil moisture before watering.'], actionsNow: ['Check soil moisture before watering.'],
plan7Days: ['Take a comparison photo in one week.'], plan7Days: ['Take a comparison photo in one week.'],
}); });
await mockBackendService.simulatePurchase({ await mockBackendService.simulatePurchase({
userId, userId,
idempotencyKey: 'sub-health-then-scan-cost', idempotencyKey: 'sub-health-then-scan-cost',
productId: 'monthly_pro', productId: 'monthly_pro',
}); });
const healthCheck = await runHealthCheck(userId, 'health-cost-1'); const healthCheck = await runHealthCheck(userId, 'health-cost-1');
expect(healthCheck.creditsCharged).toBe(2); expect(healthCheck.creditsCharged).toBe(2);
expect(healthCheck.billing.credits.usedThisCycle).toBe(2); expect(healthCheck.billing.credits.usedThisCycle).toBe(2);
const scan = await runScan(userId, 'scan-after-health-cost-1'); const scan = await runScan(userId, 'scan-after-health-cost-1');
expect(scan.modelPath).toContain('mock-review'); expect(scan.modelPath).toContain('mock-review');
expect(scan.creditsCharged).toBe(1); expect(scan.creditsCharged).toBe(1);
expect(scan.billing.credits.usedThisCycle).toBe(3); expect(scan.billing.credits.usedThisCycle).toBe(3);
}); });
it('blocks free users from real scans', async () => { it('blocks free users from real scans', async () => {
const userId = 'test-user-credit-limit'; const userId = 'test-user-credit-limit';
let successfulScans = 0; let successfulScans = 0;
let errorCode: string | null = null; let errorCode: string | null = null;
try { try {
await runScan(userId, 'scan-free-hard-paywall'); await runScan(userId, 'scan-free-hard-paywall');
successfulScans += 1; successfulScans += 1;
} catch (error) { } catch (error) {
errorCode = (error as { code?: string }).code || null; errorCode = (error as { code?: string }).code || null;
} }
expect(errorCode).toBe('INSUFFICIENT_CREDITS'); expect(errorCode).toBe('INSUFFICIENT_CREDITS');
expect(successfulScans).toBe(0); expect(successfulScans).toBe(0);
}); });
it('syncs pro entitlement from RevenueCat customer info', async () => { it('syncs pro entitlement from RevenueCat customer info', async () => {
const response = await mockBackendService.syncRevenueCatState({ const response = await mockBackendService.syncRevenueCatState({
userId: 'test-user-rc-pro', userId: 'test-user-rc-pro',
customerInfo: { customerInfo: {
entitlements: { entitlements: {
active: { active: {
pro: { pro: {
productIdentifier: 'monthly_pro', productIdentifier: 'monthly_pro',
expirationDate: '2026-04-30T00:00:00.000Z', expirationDate: '2026-04-30T00:00:00.000Z',
}, },
}, },
}, },
nonSubscriptions: {}, nonSubscriptions: {},
}, },
}); });
expect(response.billing.entitlement.plan).toBe('pro'); expect(response.billing.entitlement.plan).toBe('pro');
expect(response.billing.entitlement.status).toBe('active'); expect(response.billing.entitlement.status).toBe('active');
expect(response.billing.entitlement.renewsAt).toBe('2026-04-30T00:00:00.000Z'); expect(response.billing.entitlement.renewsAt).toBe('2026-04-30T00:00:00.000Z');
}); });
it('limits RevenueCat trial entitlement to trial credits', async () => { it('limits RevenueCat trial entitlement to trial credits', async () => {
const response = await mockBackendService.syncRevenueCatState({ const response = await mockBackendService.syncRevenueCatState({
userId: 'test-user-rc-trial', userId: 'test-user-rc-trial',
customerInfo: { customerInfo: {
entitlements: { entitlements: {
active: { active: {
pro: { pro: {
productIdentifier: 'monthly_pro', productIdentifier: 'monthly_pro',
expirationDate: '2026-04-30T00:00:00.000Z', expirationDate: '2026-04-30T00:00:00.000Z',
periodType: 'TRIAL', periodType: 'TRIAL',
}, },
}, },
}, },
nonSubscriptions: {}, nonSubscriptions: {},
}, },
}); });
expect(response.billing.entitlement.plan).toBe('pro'); expect(response.billing.entitlement.plan).toBe('pro');
expect(response.billing.credits.monthlyAllowance).toBe(30); expect(response.billing.credits.monthlyAllowance).toBe(30);
expect(response.billing.credits.available).toBe(30); expect(response.billing.credits.available).toBe(30);
}); });
it('resets trial usage when RevenueCat trial converts to paid pro', async () => { it('resets trial usage when RevenueCat trial converts to paid pro', async () => {
const userId = 'test-user-rc-trial-converts'; const userId = 'test-user-rc-trial-converts';
await mockBackendService.syncRevenueCatState({ await mockBackendService.syncRevenueCatState({
userId, userId,
customerInfo: { customerInfo: {
entitlements: { entitlements: {
active: { active: {
pro: { pro: {
productIdentifier: 'monthly_pro', productIdentifier: 'monthly_pro',
expirationDate: '2026-04-30T00:00:00.000Z', expirationDate: '2026-04-30T00:00:00.000Z',
periodType: 'TRIAL', periodType: 'TRIAL',
}, },
}, },
}, },
nonSubscriptions: {}, nonSubscriptions: {},
}, },
}); });
const trialScan = await runScan(userId, 'trial-conversion-scan'); const trialScan = await runScan(userId, 'trial-conversion-scan');
expect(trialScan.billing.credits.usedThisCycle).toBeGreaterThan(0); expect(trialScan.billing.credits.usedThisCycle).toBeGreaterThan(0);
const paidResponse = await mockBackendService.syncRevenueCatState({ const paidResponse = await mockBackendService.syncRevenueCatState({
userId, userId,
customerInfo: { customerInfo: {
entitlements: { entitlements: {
active: { active: {
pro: { pro: {
productIdentifier: 'monthly_pro', productIdentifier: 'monthly_pro',
expirationDate: '2026-05-30T00:00:00.000Z', expirationDate: '2026-05-30T00:00:00.000Z',
periodType: 'NORMAL', periodType: 'NORMAL',
}, },
}, },
}, },
nonSubscriptions: {}, nonSubscriptions: {},
}, },
}); });
expect(paidResponse.billing.credits.monthlyAllowance).toBe(100); expect(paidResponse.billing.credits.monthlyAllowance).toBe(100);
expect(paidResponse.billing.credits.usedThisCycle).toBe(0); expect(paidResponse.billing.credits.usedThisCycle).toBe(0);
expect(paidResponse.billing.credits.available).toBe(100); expect(paidResponse.billing.credits.available).toBe(100);
}); });
it('credits RevenueCat top-up transactions only once', async () => { it('credits RevenueCat top-up transactions only once', async () => {
const userId = 'test-user-rc-topup'; const userId = 'test-user-rc-topup';
await mockBackendService.syncRevenueCatState({ await mockBackendService.syncRevenueCatState({
userId, userId,
customerInfo: { customerInfo: {
entitlements: { active: {} }, entitlements: { active: {} },
nonSubscriptions: { nonSubscriptions: {
topup_small: [ topup_small: [
{ {
productIdentifier: 'topup_small', productIdentifier: 'topup_small',
transactionIdentifier: 'rc-topup-1', transactionIdentifier: 'rc-topup-1',
}, },
], ],
}, },
}, },
}); });
const second = await mockBackendService.syncRevenueCatState({ const second = await mockBackendService.syncRevenueCatState({
userId, userId,
customerInfo: { customerInfo: {
entitlements: { active: {} }, entitlements: { active: {} },
nonSubscriptions: { nonSubscriptions: {
topup_small: [ topup_small: [
{ {
productIdentifier: 'topup_small', productIdentifier: 'topup_small',
transactionIdentifier: 'rc-topup-1', transactionIdentifier: 'rc-topup-1',
}, },
], ],
}, },
}, },
}); });
expect(second.billing.credits.topupBalance).toBe(30); expect(second.billing.credits.topupBalance).toBe(30);
}); });
it('ignores malformed pro entitlements coming from top-up customer info', async () => { it('credits top-ups from the flat nonSubscriptionTransactions array shape (react-native-purchases)', async () => {
const response = await mockBackendService.syncRevenueCatState({ const response = await mockBackendService.syncRevenueCatState({
userId: 'test-user-rc-topup-misconfigured-entitlement', userId: 'test-user-rc-topup-flat-shape',
source: 'topup_purchase', source: 'topup_purchase',
customerInfo: { customerInfo: {
entitlements: { entitlements: { active: {} },
active: { nonSubscriptionTransactions: [
pro: { {
productIdentifier: 'topup_small', productIdentifier: 'topup_small',
expirationDate: '2026-04-30T00:00:00.000Z', transactionIdentifier: 'rc-topup-flat-1',
}, },
}, ],
}, },
nonSubscriptions: { });
topup_small: [
{ expect(response.billing.credits.topupBalance).toBe(30);
productIdentifier: 'topup_small', });
transactionIdentifier: 'rc-topup-malformed-1',
}, it('ignores malformed pro entitlements coming from top-up customer info', async () => {
], const response = await mockBackendService.syncRevenueCatState({
}, userId: 'test-user-rc-topup-misconfigured-entitlement',
}, source: 'topup_purchase',
}); customerInfo: {
entitlements: {
expect(response.billing.entitlement.plan).toBe('free'); active: {
expect(response.billing.entitlement.status).toBe('inactive'); pro: {
expect(response.billing.credits.topupBalance).toBe(30); productIdentifier: 'topup_small',
expect(response.billing.credits.available).toBe(0); expirationDate: '2026-04-30T00:00:00.000Z',
}); },
},
it('does not downgrade an existing pro user during a top-up sync', async () => { },
const userId = 'test-user-rc-pro-topup'; nonSubscriptions: {
topup_small: [
await mockBackendService.syncRevenueCatState({ {
userId, productIdentifier: 'topup_small',
source: 'subscription_purchase', transactionIdentifier: 'rc-topup-malformed-1',
customerInfo: { },
entitlements: { ],
active: { },
pro: { },
productIdentifier: 'monthly_pro', });
expirationDate: '2026-04-30T00:00:00.000Z',
}, expect(response.billing.entitlement.plan).toBe('free');
}, expect(response.billing.entitlement.status).toBe('inactive');
}, expect(response.billing.credits.topupBalance).toBe(30);
nonSubscriptions: {}, expect(response.billing.credits.available).toBe(0);
}, });
});
it('does not downgrade an existing pro user during a top-up sync', async () => {
const response = await mockBackendService.syncRevenueCatState({ const userId = 'test-user-rc-pro-topup';
userId,
source: 'topup_purchase', await mockBackendService.syncRevenueCatState({
customerInfo: { userId,
entitlements: { source: 'subscription_purchase',
active: { customerInfo: {
pro: { entitlements: {
productIdentifier: 'topup_small', active: {
expirationDate: '2026-04-30T00:00:00.000Z', pro: {
}, productIdentifier: 'monthly_pro',
}, expirationDate: '2026-04-30T00:00:00.000Z',
}, },
nonSubscriptions: { },
topup_small: [ },
{ nonSubscriptions: {},
productIdentifier: 'topup_small', },
transactionIdentifier: 'rc-topup-pro-1', });
},
], const response = await mockBackendService.syncRevenueCatState({
}, userId,
}, source: 'topup_purchase',
}); customerInfo: {
entitlements: {
expect(response.billing.entitlement.plan).toBe('pro'); active: {
expect(response.billing.credits.available).toBe(130); pro: {
expect(response.billing.credits.topupBalance).toBe(30); productIdentifier: 'topup_small',
}); expirationDate: '2026-04-30T00:00:00.000Z',
}); },
},
},
nonSubscriptions: {
topup_small: [
{
productIdentifier: 'topup_small',
transactionIdentifier: 'rc-topup-pro-1',
},
],
},
},
});
expect(response.billing.entitlement.plan).toBe('pro');
expect(response.billing.credits.available).toBe(130);
expect(response.billing.credits.topupBalance).toBe(30);
});
});

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "GreenLens", "name": "GreenLens",
"slug": "greenlens", "slug": "greenlens",
"version": "2.2.7", "version": "2.2.8",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
@@ -16,11 +16,10 @@
"**/*" "**/*"
], ],
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"usesAppleSignIn": true, "usesAppleSignIn": true,
"bundleIdentifier": "com.greenlens.app", "bundleIdentifier": "com.greenlens.app",
"buildNumber": "42", "infoPlist": {
"infoPlist": {
"NSCameraUsageDescription": "GreenLens needs camera access to identify plants.", "NSCameraUsageDescription": "GreenLens needs camera access to identify plants.",
"NSPhotoLibraryUsageDescription": "GreenLens needs photo library access to identify plants from your gallery.", "NSPhotoLibraryUsageDescription": "GreenLens needs photo library access to identify plants from your gallery.",
"ITSAppUsesNonExemptEncryption": false "ITSAppUsesNonExemptEncryption": false
@@ -50,18 +49,18 @@
[ [
"expo-share-intent", "expo-share-intent",
{ {
"iosActivationRules": { "iosActivationRules": {
"NSExtensionActivationSupportsText": true, "NSExtensionActivationSupportsText": true,
"NSExtensionActivationSupportsWebURLWithMaxCount": 1, "NSExtensionActivationSupportsWebURLWithMaxCount": 1,
"NSExtensionActivationSupportsWebPageWithMaxCount": 1, "NSExtensionActivationSupportsWebPageWithMaxCount": 1,
"NSExtensionActivationSupportsImageWithMaxCount": 1 "NSExtensionActivationSupportsImageWithMaxCount": 1
}, },
"androidIntentFilters": ["text/*", "image/*"], "androidIntentFilters": ["text/*", "image/*"],
"iosShareExtensionName": "GreenLens Share", "iosShareExtensionName": "GreenLens Share",
"iosAppGroupIdentifier": "group.com.greenlens.app", "iosAppGroupIdentifier": "group.com.greenlens.app",
"preprocessorInjectJS": "try{function glAddCandidate(list,value){if(value&&typeof value==='string'&&list.indexOf(value)===-1){list.push(value)}} function glSrcsetCandidate(value){if(!value||typeof value!=='string')return null;var parts=value.split(',').map(function(item){return item.trim().split(/\\s+/)[0]}).filter(Boolean);return parts.length?parts[parts.length-1]:null} function glEach(selector,callback){var nodes=document.querySelectorAll(selector);for(var i=0;i<nodes.length;i++){callback(nodes[i])}} var glCandidates=[];['og:image','og:image:url','og:image:secure_url','twitter:image','twitter:image:src','image'].forEach(function(key){glAddCandidate(glCandidates,metas[key])}); glEach('meta[itemprop=\"image\"]',function(meta){glAddCandidate(glCandidates,meta.getAttribute('content'))}); glEach('img',function(img){glAddCandidate(glCandidates,img.currentSrc);glAddCandidate(glCandidates,img.src);glAddCandidate(glCandidates,img.getAttribute('data-src'));glAddCandidate(glCandidates,img.getAttribute('data-original'));glAddCandidate(glCandidates,img.getAttribute('data-lazy-src'));glAddCandidate(glCandidates,glSrcsetCandidate(img.getAttribute('srcset')))}); glEach('picture source,source',function(source){glAddCandidate(glCandidates,source.src);glAddCandidate(glCandidates,source.getAttribute('src'));glAddCandidate(glCandidates,glSrcsetCandidate(source.getAttribute('srcset')))}); glEach('video',function(video){glAddCandidate(glCandidates,video.getAttribute('poster'))}); glEach('[style*=\"background-image\"]',function(el){var bg=(el.style&&el.style.backgroundImage)||'';var match=bg.match(/url\\([\"']?([^\"')]+)[\"']?\\)/);if(match){glAddCandidate(glCandidates,match[1])}});metas['greenlens:imageCandidates']=JSON.stringify(glCandidates.slice(0,12));metas['greenlens:imageCandidateCount']=String(glCandidates.length);metas['og:image']=metas['og:image']||glCandidates[0]}catch(e){metas['greenlens:preprocessorError']=String(e)}" "preprocessorInjectJS": "try{function glAddCandidate(list,value){if(value&&typeof value==='string'&&list.indexOf(value)===-1){list.push(value)}} function glSrcsetCandidate(value){if(!value||typeof value!=='string')return null;var parts=value.split(',').map(function(item){return item.trim().split(/\\s+/)[0]}).filter(Boolean);return parts.length?parts[parts.length-1]:null} function glEach(selector,callback){var nodes=document.querySelectorAll(selector);for(var i=0;i<nodes.length;i++){callback(nodes[i])}} var glCandidates=[];['og:image','og:image:url','og:image:secure_url','twitter:image','twitter:image:src','image'].forEach(function(key){glAddCandidate(glCandidates,metas[key])}); glEach('meta[itemprop=\"image\"]',function(meta){glAddCandidate(glCandidates,meta.getAttribute('content'))}); glEach('img',function(img){glAddCandidate(glCandidates,img.currentSrc);glAddCandidate(glCandidates,img.src);glAddCandidate(glCandidates,img.getAttribute('data-src'));glAddCandidate(glCandidates,img.getAttribute('data-original'));glAddCandidate(glCandidates,img.getAttribute('data-lazy-src'));glAddCandidate(glCandidates,glSrcsetCandidate(img.getAttribute('srcset')))}); glEach('picture source,source',function(source){glAddCandidate(glCandidates,source.src);glAddCandidate(glCandidates,source.getAttribute('src'));glAddCandidate(glCandidates,glSrcsetCandidate(source.getAttribute('srcset')))}); glEach('video',function(video){glAddCandidate(glCandidates,video.getAttribute('poster'))}); glEach('[style*=\"background-image\"]',function(el){var bg=(el.style&&el.style.backgroundImage)||'';var match=bg.match(/url\\([\"']?([^\"')]+)[\"']?\\)/);if(match){glAddCandidate(glCandidates,match[1])}});metas['greenlens:imageCandidates']=JSON.stringify(glCandidates.slice(0,12));metas['greenlens:imageCandidateCount']=String(glCandidates.length);metas['og:image']=metas['og:image']||glCandidates[0]}catch(e){metas['greenlens:preprocessorError']=String(e)}"
} }
], ],
"expo-camera", "expo-camera",
"expo-apple-authentication", "expo-apple-authentication",
"expo-image-picker", "expo-image-picker",

View File

@@ -10,16 +10,16 @@ import {
ColorPalette, ColorPalette,
} from '../types'; } from '../types';
import { ImageCacheService } from '../services/imageCacheService'; import { ImageCacheService } from '../services/imageCacheService';
import { getTranslation } from '../utils/translations'; import { getTranslation } from '../utils/translations';
import { backendApiClient } from '../services/backend/backendApiClient'; import { backendApiClient } from '../services/backend/backendApiClient';
import { import {
BillingSummary, BillingSummary,
PurchaseProductId, PurchaseProductId,
RevenueCatCustomerInfo, RevenueCatCustomerInfo,
RevenueCatEntitlementInfo, RevenueCatEntitlementInfo,
RevenueCatSyncSource, RevenueCatSyncSource,
SimulatedWebhookEvent, SimulatedWebhookEvent,
} from '../services/backend/contracts'; } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency'; import { createIdempotencyKey } from '../utils/idempotency';
import { AuthService, AuthSession } from '../services/authService'; import { AuthService, AuthSession } from '../services/authService';
import { PlantsDb, SettingsDb, LexiconHistoryDb, AppMetaDb } from '../services/database'; import { PlantsDb, SettingsDb, LexiconHistoryDb, AppMetaDb } from '../services/database';
@@ -32,9 +32,9 @@ interface AppState {
colorPalette: ColorPalette; colorPalette: ColorPalette;
profileName: string; profileName: string;
profileImageUri: string | null; profileImageUri: string | null;
billingSummary: BillingSummary | null; billingSummary: BillingSummary | null;
isActivatingEntitlement: boolean; isActivatingEntitlement: boolean;
resolvedScheme: AppColorScheme; resolvedScheme: AppColorScheme;
isDarkMode: boolean; isDarkMode: boolean;
isInitializing: boolean; isInitializing: boolean;
isLoadingPlants: boolean; isLoadingPlants: boolean;
@@ -48,11 +48,11 @@ interface AppState {
changeLanguage: (lang: Language) => void; changeLanguage: (lang: Language) => void;
savePlant: (result: IdentificationResult, imageUri: string, overrideSession?: AuthSession) => Promise<void>; savePlant: (result: IdentificationResult, imageUri: string, overrideSession?: AuthSession) => Promise<void>;
deletePlant: (id: string) => Promise<void>; deletePlant: (id: string) => Promise<void>;
updatePlant: (plant: Plant) => void; updatePlant: (plant: Plant) => void;
refreshPlants: () => void; refreshPlants: () => void;
refreshBillingSummary: () => Promise<void>; refreshBillingSummary: () => Promise<void>;
syncRevenueCatState: (customerInfo: RevenueCatCustomerInfo, source?: RevenueCatSyncSource) => Promise<BillingSummary | null>; syncRevenueCatState: (customerInfo: RevenueCatCustomerInfo, source?: RevenueCatSyncSource) => Promise<BillingSummary | null>;
simulatePurchase: (productId: PurchaseProductId) => Promise<void>; simulatePurchase: (productId: PurchaseProductId) => Promise<void>;
simulateWebhookEvent: (event: SimulatedWebhookEvent, payload?: { credits?: number }) => Promise<void>; simulateWebhookEvent: (event: SimulatedWebhookEvent, payload?: { credits?: number }) => Promise<void>;
getLexiconSearchHistory: () => string[]; getLexiconSearchHistory: () => string[];
saveLexiconSearchQuery: (query: string) => void; saveLexiconSearchQuery: (query: string) => void;
@@ -78,47 +78,49 @@ export const useApp = () => {
return ctx; return ctx;
}; };
const isAppearanceMode = (v: string): v is AppearanceMode => const isAppearanceMode = (v: string): v is AppearanceMode =>
v === 'system' || v === 'light' || v === 'dark'; v === 'system' || v === 'light' || v === 'dark';
const isColorPalette = (v: string): v is ColorPalette => const isColorPalette = (v: string): v is ColorPalette =>
v === 'forest' || v === 'ocean' || v === 'sunset' || v === 'mono'; v === 'forest' || v === 'ocean' || v === 'sunset' || v === 'mono';
const isLanguage = (v: string): v is Language => v === 'de' || v === 'en' || v === 'es'; const isLanguage = (v: string): v is Language => v === 'de' || v === 'en' || v === 'es';
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro'; const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set<PurchaseProductId>(['monthly_pro', 'yearly_pro']); const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set<PurchaseProductId>(['monthly_pro', 'yearly_pro']);
const summarizeRevenueCatCustomerInfo = (customerInfo: RevenueCatCustomerInfo) => { const summarizeRevenueCatCustomerInfo = (customerInfo: RevenueCatCustomerInfo) => {
const activeEntitlements = customerInfo?.entitlements?.active || {}; const activeEntitlements = customerInfo?.entitlements?.active || {};
return { return {
appUserId: customerInfo?.appUserId ?? null, appUserId: customerInfo?.appUserId ?? null,
originalAppUserId: customerInfo?.originalAppUserId ?? null, originalAppUserId: customerInfo?.originalAppUserId ?? null,
activeEntitlements: Object.entries(activeEntitlements).map(([id, entitlement]) => ({ activeEntitlements: Object.entries(activeEntitlements).map(([id, entitlement]) => ({
id, id,
productIdentifier: entitlement?.productIdentifier ?? null, productIdentifier: entitlement?.productIdentifier ?? null,
expirationDate: entitlement?.expirationDate || entitlement?.expiresDate || null, expirationDate: entitlement?.expirationDate || entitlement?.expiresDate || null,
})), })),
allPurchasedProductIdentifiers: customerInfo?.allPurchasedProductIdentifiers ?? [], allPurchasedProductIdentifiers: customerInfo?.allPurchasedProductIdentifiers ?? [],
nonSubscriptionTransactions: Object.values(customerInfo?.nonSubscriptions || {}).flatMap((entries) => nonSubscriptionTransactions: [
(Array.isArray(entries) ? entries : []).map((transaction) => ({ ...(Array.isArray(customerInfo?.nonSubscriptionTransactions) ? customerInfo.nonSubscriptionTransactions : []),
productIdentifier: transaction?.productIdentifier ?? null, ...Object.values(customerInfo?.nonSubscriptions || {}).flatMap((entries) => (Array.isArray(entries) ? entries : [])),
transactionIdentifier: transaction?.transactionIdentifier || transaction?.transactionId || null, ].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]; const getValidProEntitlement = (customerInfo: RevenueCatCustomerInfo): RevenueCatEntitlementInfo | null => {
if (!proEntitlement) { const activeEntitlements = customerInfo?.entitlements?.active || {};
return null; 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;
} if (proEntitlement.productIdentifier && SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS.has(proEntitlement.productIdentifier as PurchaseProductId)) {
return proEntitlement;
console.warn('[Billing] Ignoring unsupported RevenueCat pro entitlement during local sync', summarizeRevenueCatCustomerInfo(customerInfo)); }
return null;
}; console.warn('[Billing] Ignoring unsupported RevenueCat pro entitlement during local sync', summarizeRevenueCatCustomerInfo(customerInfo));
return null;
};
const getDeviceLanguage = (): Language => { const getDeviceLanguage = (): Language => {
try { try {
@@ -153,9 +155,9 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [guestScanCount, setGuestScanCount] = useState(0); const [guestScanCount, setGuestScanCount] = useState(0);
const [isInitializing, setIsInitializing] = useState(true); const [isInitializing, setIsInitializing] = useState(true);
const [isLoadingPlants, setIsLoadingPlants] = useState(true); const [isLoadingPlants, setIsLoadingPlants] = useState(true);
const [billingSummary, setBillingSummary] = useState<BillingSummary | null>(null); const [billingSummary, setBillingSummary] = useState<BillingSummary | null>(null);
const [isLoadingBilling, setIsLoadingBilling] = useState(true); const [isLoadingBilling, setIsLoadingBilling] = useState(true);
const [isActivatingEntitlement, setIsActivatingEntitlement] = useState(false); const [isActivatingEntitlement, setIsActivatingEntitlement] = useState(false);
const resolvedScheme: AppColorScheme = const resolvedScheme: AppColorScheme =
appearanceMode === 'system' appearanceMode === 'system'
@@ -386,93 +388,93 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const applyRevenueCatCustomerInfoLocally = useCallback(( const applyRevenueCatCustomerInfoLocally = useCallback((
customerInfo: RevenueCatCustomerInfo, customerInfo: RevenueCatCustomerInfo,
source: RevenueCatSyncSource = 'app_init', source: RevenueCatSyncSource = 'app_init',
) => { ) => {
if (source === 'topup_purchase') { if (source === 'topup_purchase') {
return false; return false;
} }
const activeEntitlements = customerInfo?.entitlements?.active || {}; const activeEntitlements = customerInfo?.entitlements?.active || {};
const rawProEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID]; const rawProEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID];
const proEntitlement = getValidProEntitlement(customerInfo); const proEntitlement = getValidProEntitlement(customerInfo);
const isPro = Boolean(proEntitlement); const isPro = Boolean(proEntitlement);
const now = new Date(); const now = new Date();
const renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null; const renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null;
const isTrial = (proEntitlement?.periodType || proEntitlement?.period_type || '').toUpperCase() === 'TRIAL'; const isTrial = (proEntitlement?.periodType || proEntitlement?.period_type || '').toUpperCase() === 'TRIAL';
const monthlyAllowance = isTrial ? 30 : 100; const monthlyAllowance = isTrial ? 30 : 100;
setBillingSummary((prev) => { setBillingSummary((prev) => {
if (!proEntitlement && rawProEntitlement) { if (!proEntitlement && rawProEntitlement) {
return prev; return prev;
} }
if (!prev && isPro) { if (!prev && isPro) {
return { return {
entitlement: { entitlement: {
plan: 'pro', plan: 'pro',
provider: 'revenuecat', provider: 'revenuecat',
status: 'active', status: 'active',
renewsAt, renewsAt,
}, },
credits: { credits: {
monthlyAllowance, monthlyAllowance,
usedThisCycle: 0, usedThisCycle: 0,
topupBalance: 0, topupBalance: 0,
available: monthlyAllowance, available: monthlyAllowance,
cycleStartedAt: now.toISOString(), cycleStartedAt: now.toISOString(),
cycleEndsAt: renewsAt || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(), cycleEndsAt: renewsAt || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(),
}, },
availableProducts: ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'], availableProducts: ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'],
}; };
} }
if (!prev) return prev; if (!prev) return prev;
return { return {
...prev, ...prev,
entitlement: { entitlement: {
...prev.entitlement, ...prev.entitlement,
plan: isPro ? 'pro' : 'free', plan: isPro ? 'pro' : 'free',
provider: 'revenuecat', provider: 'revenuecat',
status: isPro ? 'active' : 'inactive', status: isPro ? 'active' : 'inactive',
renewsAt: proEntitlement?.expirationDate || proEntitlement?.expiresDate || null, renewsAt: proEntitlement?.expirationDate || proEntitlement?.expiresDate || null,
}, },
}; };
}); });
return isPro; return isPro;
}, []); }, []);
const syncRevenueCatState = useCallback(async ( const syncRevenueCatState = useCallback(async (
customerInfo: RevenueCatCustomerInfo, customerInfo: RevenueCatCustomerInfo,
source: RevenueCatSyncSource = 'app_init', source: RevenueCatSyncSource = 'app_init',
) => { ) => {
console.log('[Billing] Syncing RevenueCat customer info', { console.log('[Billing] Syncing RevenueCat customer info', {
source, source,
customerInfo: summarizeRevenueCatCustomerInfo(customerInfo), customerInfo: summarizeRevenueCatCustomerInfo(customerInfo),
}); });
const didActivatePro = applyRevenueCatCustomerInfoLocally(customerInfo, source); const didActivatePro = applyRevenueCatCustomerInfoLocally(customerInfo, source);
const isSubscriptionActivation = source === 'subscription_purchase' && didActivatePro; const isSubscriptionActivation = source === 'subscription_purchase' && didActivatePro;
if (isSubscriptionActivation) { if (isSubscriptionActivation) {
setIsActivatingEntitlement(true); setIsActivatingEntitlement(true);
} }
try { try {
const response = await backendApiClient.syncRevenueCatState({ customerInfo, source }); const response = await backendApiClient.syncRevenueCatState({ customerInfo, source });
setBillingSummary(response.billing); setBillingSummary(response.billing);
return response.billing; return response.billing;
} catch (error) { } catch (error) {
console.error('Failed to sync RevenueCat state with backend', error); console.error('Failed to sync RevenueCat state with backend', error);
return null; return null;
} finally { } finally {
if (isSubscriptionActivation) { if (isSubscriptionActivation) {
setIsActivatingEntitlement(false); setIsActivatingEntitlement(false);
} }
} }
}, [applyRevenueCatCustomerInfoLocally]); }, [applyRevenueCatCustomerInfoLocally]);
const simulatePurchase = useCallback(async (productId: PurchaseProductId) => { const simulatePurchase = useCallback(async (productId: PurchaseProductId) => {
const response = await backendApiClient.simulatePurchase({ const response = await backendApiClient.simulatePurchase({
idempotencyKey: createIdempotencyKey('purchase', productId), idempotencyKey: createIdempotencyKey('purchase', productId),
productId, productId,
@@ -574,9 +576,9 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
colorPalette, colorPalette,
profileName, profileName,
profileImageUri, profileImageUri,
billingSummary, billingSummary,
isActivatingEntitlement, isActivatingEntitlement,
resolvedScheme, resolvedScheme,
isDarkMode, isDarkMode,
isInitializing, isInitializing,
isLoadingPlants, isLoadingPlants,
@@ -589,11 +591,11 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
changeLanguage, changeLanguage,
savePlant, savePlant,
deletePlant, deletePlant,
updatePlant, updatePlant,
refreshPlants, refreshPlants,
refreshBillingSummary, refreshBillingSummary,
syncRevenueCatState, syncRevenueCatState,
simulatePurchase, simulatePurchase,
simulateWebhookEvent, simulateWebhookEvent,
getLexiconSearchHistory, getLexiconSearchHistory,
saveLexiconSearchQuery, saveLexiconSearchQuery,

View File

@@ -20,6 +20,7 @@
} }
}, },
"production": { "production": {
"autoIncrement": true,
"node": "22.18.0", "node": "22.18.0",
"env": { "env": {
"NPM_CONFIG_LEGACY_PEER_DEPS": "true", "NPM_CONFIG_LEGACY_PEER_DEPS": "true",
@@ -29,8 +30,13 @@
"EXPO_PUBLIC_REVENUECAT_IOS_API_KEY": "appl_hrjmLmIUUTojZygbsisNqQqrHbX" "EXPO_PUBLIC_REVENUECAT_IOS_API_KEY": "appl_hrjmLmIUUTojZygbsisNqQqrHbX"
} }
} }
}, },
"submit": { "submit": {
"production": {} "production": {
} "ios": {
"appleId": "knuth.timo@gmail.com",
"ascAppId": "6759843546"
}
}
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,418 +0,0 @@
[
{
"Url": "https://greenlenspro.com",
"Status": 200,
"Lang": "de",
"TitleLen": 45,
"Title": "GreenLens - Pflanzen erkennen \u0026 Pflege planen",
"DescLen": 115,
"Description": "Scanne Pflanzen per Foto, verstehe ihre Bedürfnisse und organisiere Pflege, Erinnerungen und Sammlung in einer App.",
"H1": "Dein Urban Jungle, besser gepflegt.",
"Canonical": "https://greenlenspro.com",
"Words": 694,
"JsonLd": 6
},
{
"Url": "https://greenlenspro.com/support",
"Status": 200,
"Lang": "de",
"TitleLen": 7,
"Title": "Support",
"DescLen": 107,
"Description": "Get support for GreenLens, including contact details, onboarding help, billing guidance, and privacy links.",
"H1": "Help for scans, care plans, billing, and account questions.",
"Canonical": "https://greenlenspro.com/support",
"Words": 365,
"JsonLd": 2
},
{
"Url": "https://greenlenspro.com/plant-identifier-app",
"Status": 200,
"Lang": "de",
"TitleLen": 32,
"Title": "Plant Identifier App — GreenLens",
"DescLen": 178,
"Description": "GreenLens is a plant identifier app that goes beyond the name. Scan any plant, get the species instantly, and move straight to care guidance, health checks, and rescue decisions.",
"H1": "Plant Identifier App",
"Canonical": "https://greenlenspro.com/plant-identifier-app",
"Words": 798,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/plant-disease-identifier",
"Status": 200,
"Lang": "de",
"TitleLen": 36,
"Title": "Plant Disease Identifier — GreenLens",
"DescLen": 190,
"Description": "Use GreenLens to identify plant diseases from visible symptoms. Get a concrete next action — not a list of possibilities — when your plant shows yellow leaves, soft stems, or sudden decline.",
"H1": "Plant Disease Identifier",
"Canonical": "https://greenlenspro.com/plant-disease-identifier",
"Words": 827,
"JsonLd": 6
},
{
"Url": "https://greenlenspro.com/plant-care-app",
"Status": 200,
"Lang": "de",
"TitleLen": 26,
"Title": "Plant Care App — GreenLens",
"DescLen": 163,
"Description": "GreenLens is a plant care app that goes beyond simple watering reminders. It connects care decisions to what your plant actually needs — not to a generic calendar.",
"H1": "Plant Care App",
"Canonical": "https://greenlenspro.com/plant-care-app",
"Words": 793,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/vs/picturethis",
"Status": 200,
"Lang": "de",
"TitleLen": 62,
"Title": "GreenLens vs. PictureThis — Honest Plant App Comparison (2026)",
"DescLen": 149,
"Description": "GreenLens or PictureThis? Compare plant emergency workflows, paywall behavior, care guidance, and diagnosis depth. See which app fits your situation.",
"H1": "GreenLens vs PictureThis",
"Canonical": "https://greenlenspro.com/vs/picturethis",
"Words": 1442,
"JsonLd": 6
},
{
"Url": "https://greenlenspro.com/vs/plantum",
"Status": 200,
"Lang": "de",
"TitleLen": 68,
"Title": "GreenLens vs. Plantum — Plant Triage vs. All-in-One Assistant (2026)",
"DescLen": 143,
"Description": "GreenLens or Plantum? Compare diagnosis depth, beginner clarity, care workflows, and pricing friction. See which plant app fits your situation.",
"H1": "GreenLens vs Plantum",
"Canonical": "https://greenlenspro.com/vs/plantum",
"Words": 1378,
"JsonLd": 6
},
{
"Url": "https://greenlenspro.com/vs/inaturalist",
"Status": 200,
"Lang": "de",
"TitleLen": 65,
"Title": "GreenLens vs. iNaturalist — Plant Care vs. Citizen Science (2026)",
"DescLen": 164,
"Description": "GreenLens or iNaturalist? Plant care, watering reminders, and health diagnosis (GreenLens) vs. biodiversity discovery and community ID (iNaturalist). Find your fit.",
"H1": "GreenLens vs iNaturalist",
"Canonical": "https://greenlenspro.com/vs/inaturalist",
"Words": 1480,
"JsonLd": 6
},
{
"Url": "https://greenlenspro.com/vs/google-lens",
"Status": 200,
"Lang": "de",
"TitleLen": 67,
"Title": "GreenLens vs. Google Lens: Was kommt nach dem Pflanzennamen? (2026)",
"DescLen": 153,
"Description": "Google Lens nennt den Namen — GreenLens gibt dir den nächsten Schritt: Pflegeplan, Gießerinnerung und Diagnose für gelbe Blätter. Kostenloser Vergleich →",
"H1": "GreenLens vs Google Lens",
"Canonical": "https://greenlenspro.com/vs/google-lens",
"Words": 1323,
"JsonLd": 6
},
{
"Url": "https://greenlenspro.com/flower-scanner",
"Status": 200,
"Lang": "de",
"TitleLen": 71,
"Title": "Flower Scanner App Identify Any Flower by Photo Instantly | GreenLens",
"DescLen": 159,
"Description": "Point your camera at any flower and get the name instantly — plus a care plan, watering reminders, and health diagnosis. Free to start, no paywall at the scan.",
"H1": "Flower Scanner",
"Canonical": "https://greenlenspro.com/flower-scanner",
"Words": 845,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/identify-plant-photo",
"Status": 200,
"Lang": "de",
"TitleLen": 58,
"Title": "Identify Plant by Photo Free: Name + Care Plan | GreenLens",
"DescLen": 139,
"Description": "Upload or take a plant photo and get the species name, care plan, watering reminders, and health check in one app. Free to start on iPhone.",
"H1": "Identify Plant by Photo",
"Canonical": "https://greenlenspro.com/identify-plant-photo",
"Words": 904,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/plant-scanner",
"Status": 200,
"Lang": "de",
"TitleLen": 70,
"Title": "Plant Scanner App — Scan Any Plant for Instant ID and Care | GreenLens",
"DescLen": 166,
"Description": "GreenLens is the plant scanner that goes further: scan any plant with your camera, get the species name instantly, then receive a full care plan and health diagnosis.",
"H1": "Plant Scanner",
"Canonical": "https://greenlenspro.com/plant-scanner",
"Words": 800,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/houseplant-identifier",
"Status": 200,
"Lang": "de",
"TitleLen": 70,
"Title": "Houseplant Identifier — Identify Any Indoor Plant by Photo | GreenLens",
"DescLen": 152,
"Description": "GreenLens identifies houseplants by photo in seconds. Get the species name, indoor care plan, watering reminders, and health diagnosis — all in one app.",
"H1": "Houseplant Identifier",
"Canonical": "https://greenlenspro.com/houseplant-identifier",
"Words": 850,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/succulent-identifier",
"Status": 200,
"Lang": "de",
"TitleLen": 66,
"Title": "Succulent Identifier — Identify Any Succulent by Photo | GreenLens",
"DescLen": 160,
"Description": "GreenLens identifies succulents and cacti by photo in seconds. Get the species name, watering schedule, light requirements, and a health check — all in one app.",
"H1": "Succulent Identifier",
"Canonical": "https://greenlenspro.com/succulent-identifier",
"Words": 863,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/best-plant-identification-app",
"Status": 200,
"Lang": "de",
"TitleLen": 59,
"Title": "Best Plant Identification App — Free \u0026 Accurate | GreenLens",
"DescLen": 173,
"Description": "Looking for the best plant identification app? GreenLens identifies 450+ species for free and goes further: care plan, health check, and watering reminders after every scan.",
"H1": "Best Plant Identification App",
"Canonical": "https://greenlenspro.com/best-plant-identification-app",
"Words": 869,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/plant-health-app",
"Status": 200,
"Lang": "de",
"TitleLen": 66,
"Title": "Plant Health App — Diagnose Symptoms \u0026 Save Your Plant | GreenLens",
"DescLen": 174,
"Description": "GreenLens is the plant health app that gives you a concrete next step — not a list of possibilities. Diagnose yellow leaves, root rot signs, and plant emergencies in seconds.",
"H1": "Plant Health App",
"Canonical": "https://greenlenspro.com/plant-health-app",
"Words": 904,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/imprint",
"Status": 200,
"Lang": "de",
"TitleLen": 7,
"Title": "Imprint",
"DescLen": 60,
"Description": "Legal imprint and company contact information for GreenLens.",
"H1": "Impressum",
"Canonical": "https://greenlenspro.com/imprint",
"Words": 161,
"JsonLd": 2
},
{
"Url": "https://greenlenspro.com/privacy",
"Status": 200,
"Lang": "de",
"TitleLen": 14,
"Title": "Privacy Policy",
"DescLen": 98,
"Description": "Learn what personal data GreenLens processes, why it is used, and how to contact us about privacy.",
"H1": "Datenschutzerklaerung",
"Canonical": "https://greenlenspro.com/privacy",
"Words": 257,
"JsonLd": 2
},
{
"Url": "https://greenlenspro.com/terms",
"Status": 200,
"Lang": "de",
"TitleLen": 16,
"Title": "Terms of Service",
"DescLen": 81,
"Description": "Review the current GreenLens terms governing use of the app and related services.",
"H1": "Nutzungsbedingungen",
"Canonical": "https://greenlenspro.com/terms",
"Words": 194,
"JsonLd": 2
},
{
"Url": "https://greenlenspro.com/pflanzen-erkennen-kostenlos",
"Status": 200,
"Lang": "de",
"TitleLen": 60,
"Title": "Pflanzen erkennen kostenlos — App mit Pflegeplan | GreenLens",
"DescLen": 154,
"Description": "GreenLens erkennt Pflanzen kostenlos per Foto und liefert direkt Artname, Pflegeplan und Gießerinnerungen — ohne Umweg und ohne Paywall bei der Erkennung.",
"H1": "Pflanzen erkennen kostenlos",
"Canonical": "https://greenlenspro.com/pflanzen-erkennen-kostenlos",
"Words": 715,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/pflanzen-erkennen-app",
"Status": 200,
"Lang": "de",
"TitleLen": 57,
"Title": "Pflanzen erkennen App kostenlos: Foto scannen | GreenLens",
"DescLen": 133,
"Description": "Erkenne Pflanzen per Foto: Artname, Pflegeplan, Gießerinnerung und Diagnose in einer App. Kostenlos starten mit GreenLens für iPhone.",
"H1": "Pflanzen erkennen App",
"Canonical": "https://greenlenspro.com/pflanzen-erkennen-app",
"Words": 784,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/pflanzen-bestimmen",
"Status": 200,
"Lang": "de",
"TitleLen": 49,
"Title": "Pflanzen bestimmen per Foto kostenlos | GreenLens",
"DescLen": 140,
"Description": "Pflanze fotografieren und kostenlos bestimmen: GreenLens liefert Artname, Pflegeplan, Gießerinnerung und Gesundheitscheck ohne Google-Umweg.",
"H1": "Pflanzen bestimmen per Foto",
"Canonical": "https://greenlenspro.com/pflanzen-bestimmen",
"Words": 892,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/blumen-scanner",
"Status": 200,
"Lang": "de",
"TitleLen": 62,
"Title": "Blumen Scanner kostenlos: Blumen per Foto erkennen | GreenLens",
"DescLen": 132,
"Description": "Scanne Blumen per Foto und erhalte sofort Name, Pflegeplan und Hinweise bei Krankheiten. Kostenlos starten mit GreenLens für iPhone.",
"H1": "Blumen Scanner kostenlos",
"Canonical": "https://greenlenspro.com/blumen-scanner",
"Words": 892,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/zimmerpflanzen-bestimmen",
"Status": 200,
"Lang": "de",
"TitleLen": 55,
"Title": "Zimmerpflanzen bestimmen per Foto kostenlos | GreenLens",
"DescLen": 136,
"Description": "Bestimme Zimmerpflanzen per Foto: Monstera, Efeutute, Ficus, Orchideen und Sukkulenten erkennen, Pflegeplan erhalten und richtig gießen.",
"H1": "Zimmerpflanzen bestimmen",
"Canonical": "https://greenlenspro.com/zimmerpflanzen-bestimmen",
"Words": 825,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/pflanzen-pflege-app",
"Status": 200,
"Lang": "de",
"TitleLen": 50,
"Title": "Pflanzen Pflege App mit Gießerinnerung | GreenLens",
"DescLen": 136,
"Description": "Pflegeplan, Gießerinnerung pro Pflanze und Diagnose bei gelben Blättern. GreenLens hilft dir, Pflanzen richtig zu gießen und zu pflegen.",
"H1": "Pflanzen Pflege App",
"Canonical": "https://greenlenspro.com/pflanzen-pflege-app",
"Words": 835,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/pflanzen-krankheiten-erkennen",
"Status": 200,
"Lang": "de",
"TitleLen": 67,
"Title": "Pflanzenkrankheiten erkennen \u0026 diagnostizieren per Foto | GreenLens",
"DescLen": 159,
"Description": "Pflanzenkrankheit erkennen: gelbe Blätter, braune Flecken, Schädlinge oder Wurzelfäule per Foto analysieren und sofort den nächsten richtigen Schritt erhalten.",
"H1": "Pflanzenkrankheiten erkennen",
"Canonical": "https://greenlenspro.com/pflanzen-krankheiten-erkennen",
"Words": 829,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/es",
"Status": 200,
"Lang": "de",
"TitleLen": 51,
"Title": "GreenLens en español - Identificar y cuidar plantas",
"DescLen": 122,
"Description": "GreenLens en español: identifica plantas por foto, organiza cuidados, recibe recordatorios y diagnostica sintomas comunes.",
"H1": "Identifica, cuida y rescata tus plantas con mas claridad.",
"Canonical": "https://greenlenspro.com/es",
"Words": 313,
"JsonLd": 2
},
{
"Url": "https://greenlenspro.com/es/identificador-de-plantas",
"Status": 200,
"Lang": "de",
"TitleLen": 45,
"Title": "Identificador de plantas por foto | GreenLens",
"DescLen": 122,
"Description": "Identifica plantas por foto con GreenLens y recibe nombre, cuidados, recordatorios y diagnostico de salud en una sola app.",
"H1": "Identificador de plantas",
"Canonical": "https://greenlenspro.com/es/identificador-de-plantas",
"Words": 532,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/es/app-para-cuidar-plantas",
"Status": 200,
"Lang": "de",
"TitleLen": 35,
"Title": "App para cuidar plantas | GreenLens",
"DescLen": 123,
"Description": "GreenLens te ayuda a cuidar plantas con identificacion, planes de riego, recordatorios y diagnostico de problemas visibles.",
"H1": "App para cuidar plantas",
"Canonical": "https://greenlenspro.com/es/app-para-cuidar-plantas",
"Words": 476,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/es/diagnosticar-enfermedades-plantas",
"Status": 200,
"Lang": "de",
"TitleLen": 48,
"Title": "Diagnosticar enfermedades de plantas | GreenLens",
"DescLen": 113,
"Description": "Analiza hojas amarillas, manchas, tallos blandos y otros sintomas con GreenLens para recibir una accion concreta.",
"H1": "Diagnosticar enfermedades de plantas",
"Canonical": "https://greenlenspro.com/es/diagnosticar-enfermedades-plantas",
"Words": 511,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/es/escaner-de-plantas",
"Status": 200,
"Lang": "de",
"TitleLen": 30,
"Title": "Escaner de plantas | GreenLens",
"DescLen": 90,
"Description": "Escanea plantas con tu movil y recibe nombre, perfil, cuidado y diagnostico con GreenLens.",
"H1": "Escaner de plantas",
"Canonical": "https://greenlenspro.com/es/escaner-de-plantas",
"Words": 458,
"JsonLd": 8
},
{
"Url": "https://greenlenspro.com/es/comparar/google-lens",
"Status": 200,
"Lang": "de",
"TitleLen": 51,
"Title": "GreenLens vs Google Lens para plantas | Comparacion",
"DescLen": 132,
"Description": "Google Lens identifica el nombre. GreenLens tambien ofrece cuidado, diagnostico y recordatorios. Compara cual conviene para plantas.",
"H1": "GreenLens vs Google Lens para plantas",
"Canonical": "https://greenlenspro.com/es/comparar/google-lens",
"Words": 500,
"JsonLd": 8
}
]

View File

@@ -1,20 +0,0 @@
# Google Search Console SEO Report
Property: https://greenlenspro.com/
Current period: 2026-04-10 to 2026-05-07
Previous period: 2026-03-13 to 2026-04-09
## High-Impression Low-CTR Opportunities
_No rows matched this rule._
## Striking-Distance Queries
_No rows matched this rule._
## Declining Queries
_No rows matched this rule._

View File

@@ -1,20 +0,0 @@
# Google Search Console SEO Report
Property: https://greenlenspro.com/
Current period: 2026-02-07 to 2026-05-07
Previous period: 2025-11-09 to 2026-02-06
## High-Impression Low-CTR Opportunities
_No rows matched this rule._
## Striking-Distance Queries
_No rows matched this rule._
## Declining Queries
_No rows matched this rule._

View File

@@ -1,22 +0,0 @@
# Google Search Console SEO Report
Property: https://greenlenspro.com/
Current period: 2026-04-27 to 2026-05-24
Previous period: 2026-03-30 to 2026-04-26
## High-Impression Low-CTR Opportunities
_No rows matched this rule._
## Striking-Distance Queries
| Item | Clicks | Impressions | CTR | Position | Click Delta | Impression Delta |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| blumen scanner | 0 | 77 | 0.00% | 7.5 | 0 | 76 |
## Declining Queries
_No rows matched this rule._

View File

@@ -1,600 +0,0 @@
{
"name": "gsc-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"googleapis": "^171.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gaxios": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
"integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/google-auth-library": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/googleapis": {
"version": "171.4.0",
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz",
"integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.2.0",
"googleapis-common": "^8.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz",
"integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"gaxios": "^7.0.0-rc.4",
"google-auth-library": "^10.1.0",
"qs": "^6.7.0",
"url-template": "^2.0.8"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/url-template": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
"license": "BSD"
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
}
}
}

View File

@@ -1,5 +0,0 @@
{
"dependencies": {
"googleapis": "^171.4.0"
}
}

View File

@@ -1,8 +0,0 @@
{
"access_token": "ya29.a0AQvPyINmej164coiATatx7qWBbW51SvMtLZldyig_-6jetTbC743AYJ-yACoU57-NMusVd8B2zs60sy7_idw0YpoS2fph5ApYd9c8epd5ilY6ouG7GyowZmYaw48TNnigOdSBV3Y4iw7HvDN4612zukwvMaitw7BGFZ_-N8Jw7b2sgNQ8dXFzu0JvbTAQMSz4BWKSA4aCgYKAcsSARYSFQHGX2Mi_MimrSXm8CNiUT96FF3jwA0206",
"refresh_token": "1//03yyl4WqvoJJoCgYIARAAGAMSNwF-L9Irp5UITAEjpKQUTloqoKrQtIjyIWFbKY_JCBBQtwJAfDYx0VR-eUkH6edR94thgicPwTY",
"scope": "https://www.googleapis.com/auth/webmasters.readonly",
"token_type": "Bearer",
"refresh_token_expires_in": 604799,
"expiry_date": 1778447285480
}

27769
package-lock.json generated

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,26 +2,26 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { import {
BackendApiError, BackendApiError,
BillingProvider, BillingProvider,
BillingSummary, BillingSummary,
HealthCheckRequest, HealthCheckRequest,
HealthCheckResponse, HealthCheckResponse,
PlanId, PlanId,
PurchaseProductId, PurchaseProductId,
RevenueCatCustomerInfo, RevenueCatCustomerInfo,
RevenueCatEntitlementInfo, RevenueCatEntitlementInfo,
RevenueCatNonSubscriptionTransaction, RevenueCatNonSubscriptionTransaction,
RevenueCatSyncSource, RevenueCatSyncSource,
ScanPlantRequest, ScanPlantRequest,
ScanPlantResponse, ScanPlantResponse,
SemanticSearchRequest, SemanticSearchRequest,
SemanticSearchResponse, SemanticSearchResponse,
SimulatePurchaseRequest, SimulatePurchaseRequest,
SimulatePurchaseResponse, SimulatePurchaseResponse,
SimulateWebhookRequest, SimulateWebhookRequest,
SimulateWebhookResponse, SimulateWebhookResponse,
SyncRevenueCatStateResponse, SyncRevenueCatStateResponse,
isBackendApiError, isBackendApiError,
} from './contracts'; } from './contracts';
import { getMockPlantByImage, searchMockCatalog } from './mockCatalog'; import { getMockPlantByImage, searchMockCatalog } from './mockCatalog';
import { openAiScanService } from './openAiScanService'; import { openAiScanService } from './openAiScanService';
import { IdentificationResult, PlantHealthCheck } from '../../types'; 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_ACCOUNT_STORE_KEY = 'greenlens_mock_backend_accounts_v1';
const MOCK_IDEMPOTENCY_STORE_KEY = 'greenlens_mock_backend_idempotency_v1'; const MOCK_IDEMPOTENCY_STORE_KEY = 'greenlens_mock_backend_idempotency_v1';
const FREE_MONTHLY_CREDITS = 0; const FREE_MONTHLY_CREDITS = 0;
const GUEST_TRIAL_CREDITS = 0; const GUEST_TRIAL_CREDITS = 0;
const TRIAL_MONTHLY_CREDITS = 30; const TRIAL_MONTHLY_CREDITS = 30;
const PRO_MONTHLY_CREDITS = 100; const PRO_MONTHLY_CREDITS = 100;
const SCAN_PRIMARY_COST = 1; const SCAN_PRIMARY_COST = 1;
const SCAN_REVIEW_COST = 0; const SCAN_REVIEW_COST = 0;
const SEMANTIC_SEARCH_COST = 2; const SEMANTIC_SEARCH_COST = 2;
const HEALTH_CHECK_COST = 2; const HEALTH_CHECK_COST = 2;
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
const FREE_SIMULATED_DELAY_MS = 1100; const FREE_SIMULATED_DELAY_MS = 1100;
const PRO_SIMULATED_DELAY_MS = 280; const PRO_SIMULATED_DELAY_MS = 280;
const TOPUP_DEFAULT_CREDITS = 100; const TOPUP_DEFAULT_CREDITS = 100;
const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = { const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
monthly_pro: 0, monthly_pro: 0,
yearly_pro: 0, yearly_pro: 0,
topup_small: 30, topup_small: 30,
topup_medium: 100, topup_medium: 100,
topup_large: 250, topup_large: 250,
}; };
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro'; const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set<PurchaseProductId>(['monthly_pro', 'yearly_pro']); const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set<PurchaseProductId>(['monthly_pro', 'yearly_pro']);
interface MockAccountRecord { interface MockAccountRecord {
userId: string; userId: string;
@@ -101,27 +101,27 @@ const getCycleBounds = (now: Date) => {
return { cycleStartedAt, cycleEndsAt }; return { cycleStartedAt, cycleEndsAt };
}; };
const getMonthlyAllowanceForPlan = (plan: PlanId, userId?: string): number => { const getMonthlyAllowanceForPlan = (plan: PlanId, userId?: string): number => {
if (userId === 'guest') return GUEST_TRIAL_CREDITS; if (userId === 'guest') return GUEST_TRIAL_CREDITS;
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS; return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
}; };
const getRevenueCatPeriodType = (source?: RevenueCatEntitlementInfo | null): string => { const getRevenueCatPeriodType = (source?: RevenueCatEntitlementInfo | null): string => {
return String(source?.periodType || source?.period_type || '').trim().toLowerCase(); return String(source?.periodType || source?.period_type || '').trim().toLowerCase();
}; };
const isRevenueCatTrial = (source?: RevenueCatEntitlementInfo | null): boolean => { const isRevenueCatTrial = (source?: RevenueCatEntitlementInfo | null): boolean => {
return getRevenueCatPeriodType(source) === 'trial'; return getRevenueCatPeriodType(source) === 'trial';
}; };
const isAllowedMonthlyAllowance = (account: MockAccountRecord): boolean => { const isAllowedMonthlyAllowance = (account: MockAccountRecord): boolean => {
if (account.userId === 'guest') return account.monthlyAllowance === GUEST_TRIAL_CREDITS; if (account.userId === 'guest') return account.monthlyAllowance === GUEST_TRIAL_CREDITS;
if (account.plan === 'pro') { if (account.plan === 'pro') {
return account.monthlyAllowance === PRO_MONTHLY_CREDITS return account.monthlyAllowance === PRO_MONTHLY_CREDITS
|| account.monthlyAllowance === TRIAL_MONTHLY_CREDITS; || account.monthlyAllowance === TRIAL_MONTHLY_CREDITS;
} }
return account.monthlyAllowance === FREE_MONTHLY_CREDITS; return account.monthlyAllowance === FREE_MONTHLY_CREDITS;
}; };
const getSimulatedDelay = (plan: PlanId): number => { const getSimulatedDelay = (plan: PlanId): number => {
return plan === 'pro' ? PRO_SIMULATED_DELAY_MS : FREE_SIMULATED_DELAY_MS; 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 alignAccountToCurrentCycle = (account: MockAccountRecord, now: Date): MockAccountRecord => {
const next = { ...account }; const next = { ...account };
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan, next.userId); const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan, next.userId);
if (!isAllowedMonthlyAllowance(next)) { if (!isAllowedMonthlyAllowance(next)) {
next.monthlyAllowance = expectedMonthlyAllowance; next.monthlyAllowance = expectedMonthlyAllowance;
} }
if (!next.renewsAt && next.plan === 'pro' && next.provider === 'mock') { if (!next.renewsAt && next.plan === 'pro' && next.provider === 'mock') {
next.renewsAt = addDays(now, 30).toISOString(); next.renewsAt = addDays(now, 30).toISOString();
@@ -233,20 +233,20 @@ const getOrCreateAccount = (stores: { accounts: AccountStore }, userId: string):
return aligned; return aligned;
}; };
const getAvailableCredits = (account: MockAccountRecord): number => { const getAvailableCredits = (account: MockAccountRecord): number => {
if (account.plan !== 'pro') return 0; if (account.plan !== 'pro') return 0;
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle); const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
return monthlyRemaining + Math.max(0, account.topupBalance); return monthlyRemaining + Math.max(0, account.topupBalance);
}; };
const buildBillingSummary = (account: MockAccountRecord): BillingSummary => { const buildBillingSummary = (account: MockAccountRecord): BillingSummary => {
return { return {
entitlement: { entitlement: {
plan: account.plan, plan: account.plan,
provider: account.provider, provider: account.provider,
status: account.plan === 'pro' ? 'active' : 'inactive', status: account.plan === 'pro' ? 'active' : 'inactive',
renewsAt: account.renewsAt, renewsAt: account.renewsAt,
}, },
credits: { credits: {
monthlyAllowance: account.monthlyAllowance, monthlyAllowance: account.monthlyAllowance,
usedThisCycle: account.usedThisCycle, usedThisCycle: account.usedThisCycle,
@@ -256,51 +256,57 @@ const buildBillingSummary = (account: MockAccountRecord): BillingSummary => {
cycleEndsAt: account.cycleEndsAt, cycleEndsAt: account.cycleEndsAt,
}, },
availableProducts: ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'], availableProducts: ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'],
}; };
}; };
const normalizeRevenueCatTransactions = ( const normalizeRevenueCatTransactions = (
customerInfo: RevenueCatCustomerInfo, customerInfo: RevenueCatCustomerInfo,
): RevenueCatNonSubscriptionTransaction[] => { ): RevenueCatNonSubscriptionTransaction[] => {
const nonSubscriptions = customerInfo?.nonSubscriptions || {}; // react-native-purchases sends a flat `nonSubscriptionTransactions` array;
return Object.values(nonSubscriptions).flatMap((entries) => Array.isArray(entries) ? entries : []); // the RevenueCat REST API uses a `nonSubscriptions` record keyed by product.
}; const flat = Array.isArray(customerInfo?.nonSubscriptionTransactions)
? customerInfo.nonSubscriptionTransactions
const summarizeRevenueCatCustomerInfo = (customerInfo: RevenueCatCustomerInfo) => { : [];
const activeEntitlements = customerInfo?.entitlements?.active || {}; const nonSubscriptions = customerInfo?.nonSubscriptions || {};
return { const grouped = Object.values(nonSubscriptions).flatMap((entries) => Array.isArray(entries) ? entries : []);
appUserId: customerInfo?.appUserId ?? null, return [...flat, ...grouped];
originalAppUserId: customerInfo?.originalAppUserId ?? null, };
activeEntitlements: Object.entries(activeEntitlements).map(([id, entitlement]) => ({
id, const summarizeRevenueCatCustomerInfo = (customerInfo: RevenueCatCustomerInfo) => {
productIdentifier: entitlement?.productIdentifier ?? null, const activeEntitlements = customerInfo?.entitlements?.active || {};
expirationDate: entitlement?.expirationDate || entitlement?.expiresDate || null, return {
})), appUserId: customerInfo?.appUserId ?? null,
allPurchasedProductIdentifiers: customerInfo?.allPurchasedProductIdentifiers ?? [], originalAppUserId: customerInfo?.originalAppUserId ?? null,
nonSubscriptionTransactions: normalizeRevenueCatTransactions(customerInfo).map((transaction) => ({ activeEntitlements: Object.entries(activeEntitlements).map(([id, entitlement]) => ({
productIdentifier: transaction?.productIdentifier ?? null, id,
transactionIdentifier: transaction?.transactionIdentifier || transaction?.transactionId || null, productIdentifier: entitlement?.productIdentifier ?? null,
})), expirationDate: entitlement?.expirationDate || entitlement?.expiresDate || null,
}; })),
}; allPurchasedProductIdentifiers: customerInfo?.allPurchasedProductIdentifiers ?? [],
nonSubscriptionTransactions: normalizeRevenueCatTransactions(customerInfo).map((transaction) => ({
const getValidProEntitlement = (customerInfo: RevenueCatCustomerInfo): RevenueCatEntitlementInfo | null => { productIdentifier: transaction?.productIdentifier ?? null,
const activeEntitlements = customerInfo?.entitlements?.active || {}; transactionIdentifier: transaction?.transactionIdentifier || transaction?.transactionId || null,
const proEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID]; })),
if (!proEntitlement) { };
return null; };
}
const getValidProEntitlement = (customerInfo: RevenueCatCustomerInfo): RevenueCatEntitlementInfo | null => {
if ( const activeEntitlements = customerInfo?.entitlements?.active || {};
proEntitlement.productIdentifier const proEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID];
&& SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS.has(proEntitlement.productIdentifier as PurchaseProductId) if (!proEntitlement) {
) { return null;
return proEntitlement; }
}
if (
console.warn('[Billing][Mock] Ignoring unsupported RevenueCat pro entitlement', summarizeRevenueCatCustomerInfo(customerInfo)); proEntitlement.productIdentifier
return null; && 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 readIdempotentResponse = <T,>(store: IdempotencyStore, key: string): T | null => {
const record = store[key]; const record = store[key];
@@ -315,18 +321,18 @@ const writeIdempotentResponse = <T,>(store: IdempotencyStore, key: string, value
}; };
}; };
const consumeCredits = (account: MockAccountRecord, cost: number): number => { const consumeCredits = (account: MockAccountRecord, cost: number): number => {
if (cost <= 0) return 0; if (cost <= 0) return 0;
if (account.plan !== 'pro') { if (account.plan !== 'pro') {
throw new BackendApiError( throw new BackendApiError(
'INSUFFICIENT_CREDITS', 'INSUFFICIENT_CREDITS',
`Insufficient credits. Required ${cost}, available 0.`, `Insufficient credits. Required ${cost}, available 0.`,
402, 402,
{ required: cost, available: 0 }, { required: cost, available: 0 },
); );
} }
const available = getAvailableCredits(account); const available = getAvailableCredits(account);
if (available < cost) { if (available < cost) {
throw new BackendApiError( throw new BackendApiError(
'INSUFFICIENT_CREDITS', 'INSUFFICIENT_CREDITS',
@@ -350,18 +356,18 @@ const consumeCredits = (account: MockAccountRecord, cost: number): number => {
remaining -= topupUsage; remaining -= topupUsage;
} }
return cost; return cost;
}; };
const ensureActiveProEntitlement = (account: MockAccountRecord, requiredCredits: number): void => { const ensureActiveProEntitlement = (account: MockAccountRecord, requiredCredits: number): void => {
if (account.plan === 'pro') return; if (account.plan === 'pro') return;
throw new BackendApiError( throw new BackendApiError(
'INSUFFICIENT_CREDITS', 'INSUFFICIENT_CREDITS',
`Insufficient credits. Required ${requiredCredits}, available 0.`, `Insufficient credits. Required ${requiredCredits}, available 0.`,
402, 402,
{ required: requiredCredits, available: 0 }, { required: requiredCredits, available: 0 },
); );
}; };
const consumeCreditsWithIdempotency = ( const consumeCreditsWithIdempotency = (
account: MockAccountRecord, account: MockAccountRecord,
@@ -505,18 +511,18 @@ const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: numbe
'Tag 7: Vergleichsfoto erstellen.', 'Tag 7: Vergleichsfoto erstellen.',
]; ];
return { return {
generatedAt: nowIso(), generatedAt: nowIso(),
overallHealthScore: score, overallHealthScore: score,
status, status,
analysisSummary: status === 'critical' 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.' ? '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' : 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 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.', : '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, likelyIssues,
actionsNow, actionsNow,
plan7Days, plan7Days,
creditsCharged, creditsCharged,
imageUri: request.imageUri, imageUri: request.imageUri,
}; };
@@ -609,18 +615,18 @@ const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: numbe
'Dia 7: Tomar foto de comparacion.', 'Dia 7: Tomar foto de comparacion.',
]; ];
return { return {
generatedAt: nowIso(), generatedAt: nowIso(),
overallHealthScore: score, overallHealthScore: score,
status, status,
analysisSummary: status === 'critical' 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.' ? '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' : 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 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.', : '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, likelyIssues,
actionsNow, actionsNow,
plan7Days, plan7Days,
creditsCharged, creditsCharged,
imageUri: request.imageUri, imageUri: request.imageUri,
}; };
@@ -712,102 +718,102 @@ const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: numbe
'Day 7: Take a comparison photo.', 'Day 7: Take a comparison photo.',
]; ];
return { return {
generatedAt: nowIso(), generatedAt: nowIso(),
overallHealthScore: score, overallHealthScore: score,
status, status,
analysisSummary: status === 'critical' 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.' ? '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' : 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 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.', : '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, likelyIssues,
actionsNow, actionsNow,
plan7Days, plan7Days,
creditsCharged, creditsCharged,
imageUri: request.imageUri, imageUri: request.imageUri,
}; };
}; };
export const mockBackendService = { export const mockBackendService = {
getBillingSummary: async (userId: string): Promise<BillingSummary> => { getBillingSummary: async (userId: string): Promise<BillingSummary> => {
return withUserLock(userId, async () => { return withUserLock(userId, async () => {
const stores = await loadStores(); const stores = await loadStores();
const account = getOrCreateAccount(stores, userId); const account = getOrCreateAccount(stores, userId);
account.updatedAt = nowIso(); account.updatedAt = nowIso();
await persistStores(stores); await persistStores(stores);
return buildBillingSummary(account); return buildBillingSummary(account);
}); });
}, },
syncRevenueCatState: async (request: { syncRevenueCatState: async (request: {
userId: string; userId: string;
customerInfo: RevenueCatCustomerInfo; customerInfo: RevenueCatCustomerInfo;
source?: RevenueCatSyncSource; source?: RevenueCatSyncSource;
}): Promise<SyncRevenueCatStateResponse> => { }): Promise<SyncRevenueCatStateResponse> => {
return withUserLock(request.userId, async () => { return withUserLock(request.userId, async () => {
const stores = await loadStores(); const stores = await loadStores();
const account = getOrCreateAccount(stores, request.userId); const account = getOrCreateAccount(stores, request.userId);
const proEntitlement = getValidProEntitlement(request.customerInfo); const proEntitlement = getValidProEntitlement(request.customerInfo);
const source = request.source || 'app_init'; const source = request.source || 'app_init';
console.log('[Billing][Mock] Syncing RevenueCat customer info', { console.log('[Billing][Mock] Syncing RevenueCat customer info', {
source, source,
customerInfo: summarizeRevenueCatCustomerInfo(request.customerInfo), customerInfo: summarizeRevenueCatCustomerInfo(request.customerInfo),
}); });
if (source !== 'topup_purchase') { if (source !== 'topup_purchase') {
const now = new Date(); const now = new Date();
const previousPlan = account.plan; const previousPlan = account.plan;
const previousMonthlyAllowance = account.monthlyAllowance; const previousMonthlyAllowance = account.monthlyAllowance;
const nextPlan = proEntitlement ? 'pro' : 'free'; const nextPlan = proEntitlement ? 'pro' : 'free';
const nextMonthlyAllowance = proEntitlement && isRevenueCatTrial(proEntitlement) const nextMonthlyAllowance = proEntitlement && isRevenueCatTrial(proEntitlement)
? TRIAL_MONTHLY_CREDITS ? TRIAL_MONTHLY_CREDITS
: getMonthlyAllowanceForPlan(nextPlan, account.userId); : getMonthlyAllowanceForPlan(nextPlan, account.userId);
const planChanged = previousPlan !== nextPlan; const planChanged = previousPlan !== nextPlan;
const trialConvertedToPaid = previousPlan === 'pro' const trialConvertedToPaid = previousPlan === 'pro'
&& previousMonthlyAllowance === TRIAL_MONTHLY_CREDITS && previousMonthlyAllowance === TRIAL_MONTHLY_CREDITS
&& nextMonthlyAllowance === PRO_MONTHLY_CREDITS; && nextMonthlyAllowance === PRO_MONTHLY_CREDITS;
account.plan = nextPlan; account.plan = nextPlan;
account.provider = 'revenuecat'; account.provider = 'revenuecat';
account.monthlyAllowance = nextMonthlyAllowance; account.monthlyAllowance = nextMonthlyAllowance;
account.renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null; account.renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null;
if (planChanged || trialConvertedToPaid) { if (planChanged || trialConvertedToPaid) {
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
account.cycleStartedAt = cycleStartedAt.toISOString(); account.cycleStartedAt = cycleStartedAt.toISOString();
account.cycleEndsAt = cycleEndsAt.toISOString(); account.cycleEndsAt = cycleEndsAt.toISOString();
account.usedThisCycle = 0; account.usedThisCycle = 0;
} }
} }
for (const transaction of normalizeRevenueCatTransactions(request.customerInfo)) { for (const transaction of normalizeRevenueCatTransactions(request.customerInfo)) {
const productId = transaction.productIdentifier as PurchaseProductId | undefined; const productId = transaction.productIdentifier as PurchaseProductId | undefined;
const transactionId = transaction.transactionIdentifier || transaction.transactionId; const transactionId = transaction.transactionIdentifier || transaction.transactionId;
if (!productId || !transactionId || !productId.startsWith('topup_')) { if (!productId || !transactionId || !productId.startsWith('topup_')) {
continue; continue;
} }
const idempotencyKey = `revenuecat-topup:${transactionId}`; const idempotencyKey = `revenuecat-topup:${transactionId}`;
if (stores.idempotency[idempotencyKey]) { if (stores.idempotency[idempotencyKey]) {
continue; continue;
} }
account.topupBalance += TOPUP_CREDITS_BY_PRODUCT[productId] || 0; account.topupBalance += TOPUP_CREDITS_BY_PRODUCT[productId] || 0;
writeIdempotentResponse(stores.idempotency, idempotencyKey, { transactionId, productId }); writeIdempotentResponse(stores.idempotency, idempotencyKey, { transactionId, productId });
} }
account.updatedAt = nowIso(); account.updatedAt = nowIso();
await persistStores(stores); await persistStores(stores);
return { return {
billing: buildBillingSummary(account), billing: buildBillingSummary(account),
syncedAt: nowIso(), syncedAt: nowIso(),
}; };
}); });
}, },
scanPlant: async (request: ScanPlantRequest): Promise<ScanPlantResponse> => { scanPlant: async (request: ScanPlantRequest): Promise<ScanPlantResponse> => {
const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => { const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => {
const stores = await loadStores(); const stores = await loadStores();
const account = getOrCreateAccount(stores, request.userId); const account = getOrCreateAccount(stores, request.userId);
@@ -987,14 +993,14 @@ export const mockBackendService = {
} }
const normalizedImageUri = request.imageUri.trim(); const normalizedImageUri = request.imageUri.trim();
if (!normalizedImageUri) { if (!normalizedImageUri) {
throw new BackendApiError('BAD_REQUEST', 'Health check requires an image URI.', 400); throw new BackendApiError('BAD_REQUEST', 'Health check requires an image URI.', 400);
} }
ensureActiveProEntitlement(account, HEALTH_CHECK_COST); ensureActiveProEntitlement(account, HEALTH_CHECK_COST);
if (!openAiScanService.isConfigured()) { if (!openAiScanService.isConfigured()) {
throw new BackendApiError( throw new BackendApiError(
'PROVIDER_ERROR', 'PROVIDER_ERROR',
'OpenAI health check is unavailable. Please configure EXPO_PUBLIC_OPENAI_API_KEY.', 'OpenAI health check is unavailable. Please configure EXPO_PUBLIC_OPENAI_API_KEY.',
502, 502,
@@ -1021,13 +1027,13 @@ export const mockBackendService = {
HEALTH_CHECK_COST, HEALTH_CHECK_COST,
); );
const healthCheck: PlantHealthCheck = { const healthCheck: PlantHealthCheck = {
generatedAt: nowIso(), generatedAt: nowIso(),
overallHealthScore: aiAnalysis.overallHealthScore, overallHealthScore: aiAnalysis.overallHealthScore,
status: aiAnalysis.status, status: aiAnalysis.status,
analysisSummary: aiAnalysis.analysisSummary, analysisSummary: aiAnalysis.analysisSummary,
likelyIssues: aiAnalysis.likelyIssues, likelyIssues: aiAnalysis.likelyIssues,
actionsNow: aiAnalysis.actionsNow, actionsNow: aiAnalysis.actionsNow,
plan7Days: aiAnalysis.plan7Days, plan7Days: aiAnalysis.plan7Days,
creditsCharged, creditsCharged,
imageUri: normalizedImageUri, imageUri: normalizedImageUri,