Hard paywall

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

View File

@@ -68,77 +68,94 @@ describe('mockBackendService billing simulation', () => {
productId: 'topup_small', productId: 'topup_small',
}); });
expect(first.billing.credits.topupBalance).toBe(25); expect(first.billing.credits.topupBalance).toBe(30);
expect(second.billing.credits.topupBalance).toBe(25); 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: 'topup-order-1', idempotencyKey: 'sub-order-1',
productId: 'monthly_pro',
});
await mockBackendService.simulatePurchase({
userId,
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(25); expect(lastScan.billing.credits.topupBalance).toBe(30);
for (let i = 1; i <= 15; i += 1) { let scanIndex = 1;
lastScan = await runScan(userId, `scan-order-${i}`); while (
} lastScan.billing.credits.usedThisCycle < 100
&& lastScan.billing.credits.topupBalance === 30
expect(lastScan.billing.credits.usedThisCycle).toBe(15); && scanIndex < 150
expect(lastScan.billing.credits.topupBalance).toBe(24); ) {
}); lastScan = await runScan(userId, `scan-order-${scanIndex}`);
scanIndex += 1;
it('can deplete all available credits via webhook simulation', async () => { }
const userId = 'test-user-deplete-credits';
await mockBackendService.simulatePurchase({ if (lastScan.billing.credits.topupBalance === 30) {
userId, lastScan = await runScan(userId, `scan-order-${scanIndex}`);
idempotencyKey: 'topup-deplete-1', }
productId: 'topup_small',
}); expect(lastScan.billing.credits.usedThisCycle).toBe(100);
expect(lastScan.billing.credits.topupBalance).toBeLessThan(30);
const response = await mockBackendService.simulateWebhook({ expect(lastScan.billing.credits.topupBalance).toBeGreaterThanOrEqual(0);
userId, });
idempotencyKey: 'webhook-deplete-1',
event: 'credits_depleted', it('can deplete all available credits via webhook simulation', async () => {
}); const userId = 'test-user-deplete-credits';
await mockBackendService.simulatePurchase({
expect(response.billing.credits.available).toBe(0); userId,
expect(response.billing.credits.topupBalance).toBe(0); idempotencyKey: 'topup-deplete-1',
expect(response.billing.credits.usedThisCycle).toBe(response.billing.credits.monthlyAllowance); productId: 'topup_small',
}); });
const response = await mockBackendService.simulateWebhook({
userId,
idempotencyKey: 'webhook-deplete-1',
event: 'credits_depleted',
});
expect(response.billing.credits.available).toBe(0);
expect(response.billing.credits.topupBalance).toBe(0);
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({
userId,
idempotencyKey: 'sub-scan-idempotency',
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).toBe(1); expect(first.creditsCharged).toBeGreaterThan(0);
expect(second.creditsCharged).toBe(1); 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('enforces free monthly credit limit', 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;
for (let i = 0; i < 30; i += 1) { try {
try { await runScan(userId, 'scan-free-hard-paywall');
await runScan(userId, `scan-${i}`); successfulScans += 1;
successfulScans += 1; } catch (error) {
} catch (error) { errorCode = (error as { code?: string }).code || null;
errorCode = (error as { code?: string }).code || null; }
break;
}
}
expect(errorCode).toBe('INSUFFICIENT_CREDITS'); expect(errorCode).toBe('INSUFFICIENT_CREDITS');
expect(successfulScans).toBeGreaterThanOrEqual(7); expect(successfulScans).toBe(0);
expect(successfulScans).toBeLessThanOrEqual(15);
}); });
it('syncs pro entitlement from RevenueCat customer info', async () => { it('syncs pro entitlement from RevenueCat customer info', async () => {
@@ -162,6 +179,70 @@ describe('mockBackendService billing simulation', () => {
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 () => {
const response = await mockBackendService.syncRevenueCatState({
userId: 'test-user-rc-trial',
customerInfo: {
entitlements: {
active: {
pro: {
productIdentifier: 'monthly_pro',
expirationDate: '2026-04-30T00:00:00.000Z',
periodType: 'TRIAL',
},
},
},
nonSubscriptions: {},
},
});
expect(response.billing.entitlement.plan).toBe('pro');
expect(response.billing.credits.monthlyAllowance).toBe(30);
expect(response.billing.credits.available).toBe(30);
});
it('resets trial usage when RevenueCat trial converts to paid pro', async () => {
const userId = 'test-user-rc-trial-converts';
await mockBackendService.syncRevenueCatState({
userId,
customerInfo: {
entitlements: {
active: {
pro: {
productIdentifier: 'monthly_pro',
expirationDate: '2026-04-30T00:00:00.000Z',
periodType: 'TRIAL',
},
},
},
nonSubscriptions: {},
},
});
const trialScan = await runScan(userId, 'trial-conversion-scan');
expect(trialScan.billing.credits.usedThisCycle).toBeGreaterThan(0);
const paidResponse = await mockBackendService.syncRevenueCatState({
userId,
customerInfo: {
entitlements: {
active: {
pro: {
productIdentifier: 'monthly_pro',
expirationDate: '2026-05-30T00:00:00.000Z',
periodType: 'NORMAL',
},
},
},
nonSubscriptions: {},
},
});
expect(paidResponse.billing.credits.monthlyAllowance).toBe(100);
expect(paidResponse.billing.credits.usedThisCycle).toBe(0);
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({
@@ -194,7 +275,7 @@ describe('mockBackendService billing simulation', () => {
}, },
}); });
expect(second.billing.credits.topupBalance).toBe(25); expect(second.billing.credits.topupBalance).toBe(30);
}); });
it('ignores malformed pro entitlements coming from top-up customer info', async () => { it('ignores malformed pro entitlements coming from top-up customer info', async () => {
@@ -223,8 +304,8 @@ describe('mockBackendService billing simulation', () => {
expect(response.billing.entitlement.plan).toBe('free'); expect(response.billing.entitlement.plan).toBe('free');
expect(response.billing.entitlement.status).toBe('inactive'); expect(response.billing.entitlement.status).toBe('inactive');
expect(response.billing.credits.topupBalance).toBe(25); expect(response.billing.credits.topupBalance).toBe(30);
expect(response.billing.credits.available).toBe(40); expect(response.billing.credits.available).toBe(0);
}); });
it('does not downgrade an existing pro user during a top-up sync', async () => { it('does not downgrade an existing pro user during a top-up sync', async () => {
@@ -270,7 +351,7 @@ describe('mockBackendService billing simulation', () => {
}); });
expect(response.billing.entitlement.plan).toBe('pro'); expect(response.billing.entitlement.plan).toBe('pro');
expect(response.billing.credits.available).toBe(275); expect(response.billing.credits.available).toBe(130);
expect(response.billing.credits.topupBalance).toBe(25); expect(response.billing.credits.topupBalance).toBe(30);
}); });
}); });

View File

@@ -15,9 +15,10 @@
"assetBundlePatterns": [ "assetBundlePatterns": [
"**/*" "**/*"
], ],
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.greenlens.app", "usesAppleSignIn": true,
"bundleIdentifier": "com.greenlens.app",
"buildNumber": "37", "buildNumber": "37",
"infoPlist": { "infoPlist": {
"NSCameraUsageDescription": "GreenLens needs camera access to identify plants.", "NSCameraUsageDescription": "GreenLens needs camera access to identify plants.",
@@ -46,8 +47,9 @@
"plugins": [ "plugins": [
"expo-dev-client", "expo-dev-client",
"expo-router", "expo-router",
"expo-camera", "expo-camera",
"expo-image-picker", "expo-apple-authentication",
"expo-image-picker",
"expo-secure-store", "expo-secure-store",
"expo-asset", "expo-asset",
"expo-font", "expo-font",

View File

@@ -53,8 +53,18 @@ const ensureInstallConsistency = async (): Promise<boolean> => {
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen'; import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
function RootLayoutInner() { function RootLayoutInner() {
const { isDarkMode, colorPalette, signOut, session, isInitializing, isLoadingPlants, syncRevenueCatState } = useApp(); const {
isDarkMode,
colorPalette,
signOut,
session,
billingSummary,
isInitializing,
isLoadingPlants,
isLoadingBilling,
syncRevenueCatState,
} = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname(); const pathname = usePathname();
const [installCheckDone, setInstallCheckDone] = useState(false); const [installCheckDone, setInstallCheckDone] = useState(false);
@@ -144,17 +154,25 @@ function RootLayoutInner() {
})(); })();
}, [signOut]); }, [signOut]);
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants; const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active';
const isAllowedWithoutSession = pathname.includes('onboarding')
|| pathname.includes('auth/')
|| pathname.includes('scanner')
|| pathname.includes('profile/billing');
const isAllowedWithoutEntitlement = pathname.includes('auth/')
|| pathname.includes('scanner')
|| pathname.includes('profile/billing');
let content = null; let content = null;
if (isAppReady) { if (isAppReady) {
if (!session) { if (!session) {
// Only redirect if we are not already on an auth-related page or the scanner // Only redirect if we are not already on an auth-related page or the scanner
const isAuthPage = pathname.includes('onboarding') || pathname.includes('auth/') || pathname.includes('scanner') || pathname.includes('profile/billing'); if (!isAllowedWithoutSession) {
if (!isAuthPage) { content = <Redirect href="/onboarding" />;
content = <Redirect href="/onboarding" />; } else {
} else {
content = ( content = (
<Stack <Stack
screenOptions={{ screenOptions={{
@@ -178,9 +196,11 @@ function RootLayoutInner() {
options={{ presentation: 'card', animation: 'slide_from_right' }} options={{ presentation: 'card', animation: 'slide_from_right' }}
/> />
</Stack> </Stack>
); );
} }
} else { } else if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) {
content = <Redirect href="/profile/billing" />;
} else {
content = ( content = (
<> <>
<Stack <Stack
@@ -246,11 +266,10 @@ export default function RootLayout() {
initDatabase(); initDatabase();
return ( return (
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{ <PostHogProvider apiKey={POSTHOG_API_KEY} options={{
host: 'https://us.i.posthog.com', host: 'https://us.i.posthog.com',
enableSessionReplay: false, enableSessionReplay: false,
debug: __DEV__, }}>
}}>
<AppProvider> <AppProvider>
<CoachMarksProvider> <CoachMarksProvider>
<RootLayoutInner /> <RootLayoutInner />

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
View, View,
Text, Text,
@@ -16,17 +16,35 @@ import { router } from 'expo-router';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { AuthService } from '../../services/authService'; import { AuthService } from '../../services/authService';
import * as AppleAuthentication from 'expo-apple-authentication';
import { usePostHog } from 'posthog-react-native';
export default function LoginScreen() { export default function LoginScreen() {
const { isDarkMode, colorPalette, hydrateSession, t } = useApp(); const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog();
const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [email, setEmail] = useState('');
const [showPassword, setShowPassword] = useState(false); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null); const [appleAvailable, setAppleAvailable] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
AppleAuthentication.isAvailableAsync()
.then((available) => {
if (mounted) setAppleAvailable(available);
})
.catch(() => {
if (mounted) setAppleAvailable(false);
});
return () => {
mounted = false;
};
}, []);
const handleLogin = async () => { const handleLogin = async () => {
if (!email.trim() || !password) { if (!email.trim() || !password) {
@@ -36,9 +54,9 @@ export default function LoginScreen() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const session = await AuthService.login(email, password); const session = await AuthService.login(email, password);
await hydrateSession(session); await hydrateSession(session);
router.replace('/(tabs)'); router.replace('/profile/billing');
} catch (e: any) { } catch (e: any) {
if (e.message === 'USER_NOT_FOUND') { if (e.message === 'USER_NOT_FOUND') {
setError(t.errUserNotFound); setError(t.errUserNotFound);
@@ -53,8 +71,53 @@ export default function LoginScreen() {
} }
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleAppleSignIn = async () => {
setLoading(true);
setError(null);
posthog.capture('apple_login_started', { surface: 'login' });
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) {
throw new Error('APPLE_AUTH_INVALID');
}
const fullName = [
credential.fullName?.givenName,
credential.fullName?.familyName,
].filter(Boolean).join(' ');
const session = await AuthService.signInWithApple({
identityToken: credential.identityToken,
appleUser: credential.user,
email: credential.email,
name: fullName || undefined,
});
await hydrateSession(session);
posthog.capture('apple_login_succeeded', { surface: 'login' });
router.replace('/profile/billing');
} catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') {
return;
}
posthog.capture('apple_login_failed', {
surface: 'login',
error: e instanceof Error ? e.message : String(e),
});
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
: t.errAuthError);
} finally {
setLoading(false);
}
};
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
@@ -80,10 +143,30 @@ export default function LoginScreen() {
</Text> </Text>
</View> </View>
{/* Card */} {/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}> <View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
{/* Email */} {appleAvailable ? (
<View style={styles.fieldGroup}> <AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
style={styles.appleButton}
onPress={handleAppleSignIn}
/>
) : null}
{appleAvailable ? (
<View style={styles.dividerRowCompact}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
) : null}
{/* Email */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text> <Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}> <View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> <Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
@@ -150,8 +233,8 @@ export default function LoginScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Divider */} {/* Divider */}
<View style={styles.dividerRow}> <View style={styles.dividerRow}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> <View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text> <Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> <View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
@@ -201,7 +284,7 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
fontWeight: '400', fontWeight: '400',
}, },
card: { card: {
borderRadius: 20, borderRadius: 20,
borderWidth: 1, borderWidth: 1,
padding: 24, padding: 24,
@@ -210,7 +293,18 @@ const styles = StyleSheet.create({
shadowOpacity: 1, shadowOpacity: 1,
shadowRadius: 12, shadowRadius: 12,
elevation: 4, elevation: 4,
}, },
appleButton: {
width: '100%',
height: 50,
marginBottom: 2,
},
dividerRowCompact: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginVertical: 2,
},
fieldGroup: { fieldGroup: {
gap: 6, gap: 6,
}, },

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
View, View,
Text, Text,
@@ -16,22 +16,40 @@ import { router } from 'expo-router';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { AuthService } from '../../services/authService'; import { AuthService } from '../../services/authService';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import * as AppleAuthentication from 'expo-apple-authentication';
import { usePostHog } from 'posthog-react-native';
export default function SignupScreen() { export default function SignupScreen() {
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp(); const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const pendingPlant = getPendingPlant(); const posthog = usePostHog();
const pendingPlant = getPendingPlant();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState(''); const [passwordConfirm, setPasswordConfirm] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
const [loading, setLoading] = useState(false); const [appleAvailable, setAppleAvailable] = useState(false);
const [error, setError] = useState<string | null>(null); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
AppleAuthentication.isAvailableAsync()
.then((available) => {
if (mounted) setAppleAvailable(available);
})
.catch(() => {
if (mounted) setAppleAvailable(false);
});
return () => {
mounted = false;
};
}, []);
const validate = (): string | null => { const validate = (): string | null => {
if (!name.trim()) return t.errNameRequired; if (!name.trim()) return t.errNameRequired;
@@ -50,11 +68,11 @@ export default function SignupScreen() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const session = await AuthService.signUp(email, name, password); const session = await AuthService.signUp(email, name, password);
await hydrateSession(session); await hydrateSession(session);
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen // Flag setzen: Tour beim nächsten App-Öffnen anzeigen
await AsyncStorage.setItem('greenlens_show_tour', 'true'); await AsyncStorage.setItem('greenlens_show_tour', 'true');
router.replace('/onboarding/source'); router.replace('/profile/billing');
} catch (e: any) { } catch (e: any) {
if (e.message === 'EMAIL_TAKEN') { if (e.message === 'EMAIL_TAKEN') {
setError(t.errEmailTaken); setError(t.errEmailTaken);
@@ -71,8 +89,54 @@ export default function SignupScreen() {
} }
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleAppleSignIn = async () => {
setLoading(true);
setError(null);
posthog.capture('apple_login_started', { surface: 'signup' });
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) {
throw new Error('APPLE_AUTH_INVALID');
}
const fullName = [
credential.fullName?.givenName,
credential.fullName?.familyName,
].filter(Boolean).join(' ');
const session = await AuthService.signInWithApple({
identityToken: credential.identityToken,
appleUser: credential.user,
email: credential.email,
name: fullName || undefined,
});
await hydrateSession(session);
await AsyncStorage.setItem('greenlens_show_tour', 'true');
posthog.capture('apple_login_succeeded', { surface: 'signup' });
router.replace('/profile/billing');
} catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') {
return;
}
posthog.capture('apple_login_failed', {
surface: 'signup',
error: e instanceof Error ? e.message : String(e),
});
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
: t.errAuthError);
} finally {
setLoading(false);
}
};
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
@@ -114,10 +178,30 @@ export default function SignupScreen() {
</View> </View>
)} )}
{/* Card */} {/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}> <View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
{/* Name */} {appleAvailable ? (
<View style={styles.fieldGroup}> <AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
style={styles.appleButton}
onPress={handleAppleSignIn}
/>
) : null}
{appleAvailable ? (
<View style={styles.dividerRowCompact}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
) : null}
{/* Name */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text> <Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}> <View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> <Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
@@ -317,17 +401,36 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
fontWeight: '400', fontWeight: '400',
}, },
card: { card: {
borderRadius: 20, borderRadius: 20,
borderWidth: 1, borderWidth: 1,
padding: 24, padding: 24,
gap: 14, gap: 14,
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 1, shadowOpacity: 1,
shadowRadius: 12, shadowRadius: 12,
elevation: 4, elevation: 4,
}, },
fieldGroup: { appleButton: {
width: '100%',
height: 50,
marginBottom: 2,
},
dividerRowCompact: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginVertical: 2,
},
dividerLine: {
flex: 1,
height: 1,
},
dividerText: {
fontSize: 12,
fontWeight: '500',
},
fieldGroup: {
gap: 6, gap: 6,
}, },
label: { label: {

View File

@@ -19,9 +19,19 @@ import { Language } from '../../types';
import { PurchaseProductId } from '../../services/backend/contracts'; import { PurchaseProductId } from '../../services/backend/contracts';
type SubscriptionProductId = 'monthly_pro' | 'yearly_pro'; type SubscriptionProductId = 'monthly_pro' | 'yearly_pro';
type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>; type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>;
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>; type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>; type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
const TOPUP_CREDITS_BY_PRODUCT: Record<TopupProductId, number> = {
topup_small: 30,
topup_medium: 100,
topup_large: 250,
};
const isTopupProductId = (productId: PurchaseProductId): productId is TopupProductId => (
productId === 'topup_small' || productId === 'topup_medium' || productId === 'topup_large'
);
const isMatchingPackage = ( const isMatchingPackage = (
pkg: PurchasesPackage, pkg: PurchasesPackage,
@@ -79,7 +89,22 @@ const getBillingCopy = (language: Language) => {
manageSubscription: 'Abo verwalten', manageSubscription: 'Abo verwalten',
subscriptionTitle: 'Abos', subscriptionTitle: 'Abos',
subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.', subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.',
freePlanName: 'Free', paywallTitle: 'Vollstaendige Diagnose freischalten',
paywallHint: 'Starte Pro fuer echte GPT-5.4 Scans, deinen 7-Tage-Rettungsplan und 100 Credits fuer AI-Scans und Follow-ups.',
startTrial: '7 Tage kostenlos testen',
monthlyCta: 'Monatlich starten',
yearlyCta: 'Jaehrlich starten',
yearlyTrialBadge: '7 TAGE GRATIS',
monthlyBadge: 'FLEXIBEL',
yearlySubline: 'Danach 39,99 EUR/Jahr. Jederzeit kuendbar.',
monthlySubline: '4,99 EUR/Monat. Ohne Jahresbindung.',
saveLabel: 'Bester Wert',
expoGoPurchaseTitle: 'Kauf nur im Dev Build',
expoGoPurchaseMessage: 'Expo Go kann keine Apple- oder RevenueCat-Kaufmaske anzeigen. Im Development Build oder TestFlight erscheint hier der echte 7-Tage-Trial. Fuer lokale Tests kannst du Pro simulieren.',
expoGoSimulate: 'Pro simulieren',
perYear: '/ Jahr',
perMonth: '/ Monat',
freePlanName: 'Free',
freePlanPrice: '0 EUR / Monat', freePlanPrice: '0 EUR / Monat',
proPlanName: 'Pro', proPlanName: 'Pro',
proPlanPrice: '4,99 € / Monat', proPlanPrice: '4,99 € / Monat',
@@ -88,17 +113,20 @@ const getBillingCopy = (language: Language) => {
proYearlyPlanPrice: '39,99 € / Jahr', proYearlyPlanPrice: '39,99 € / Jahr',
proYearlyBadgeText: 'SPAREN', proYearlyBadgeText: 'SPAREN',
proBenefits: [ proBenefits: [
'250 Credits jeden Monat', '100 Credits für AI-Scans und Follow-ups jeden Monat',
'Pro-Scans mit GPT-5.4', 'Pro-Scans mit GPT-5.4',
'Unbegrenzte Historie & Galerie', 'Unbegrenzte Historie & Galerie',
'KI-Pflanzendoktor inklusive', 'KI-Pflanzendoktor inklusive',
'Priorisierter Support' 'Priorisierter Support'
], ],
topupTitle: 'Credits Aufladen', topupTitle: 'Credits Aufladen',
topupSmall: '25 Credits 1,99 €', topupHint: 'Für aktive Pro-Nutzer, wenn die Monatscredits nicht reichen.',
topupMedium: '120 Credits 6,99 €', topupSmall: '30 Credits 2,99 €',
topupLarge: '300 Credits 12,99 €', topupMedium: '100 Credits 6,99 €',
topupBestValue: 'BESTES ANGEBOT', topupLarge: '250 Credits 12,99 €',
topupBestValue: 'BESTES ANGEBOT',
topupRequiresProTitle: 'Pro erforderlich',
topupRequiresProMessage: 'Top-ups sind für aktive Pro-Nutzer gedacht. Starte Pro, um zusätzliche Credits zu kaufen.',
cancelTitle: 'Schade, dass du gehst', cancelTitle: 'Schade, dass du gehst',
cancelQuestion: 'Dürfen wir fragen, warum du kündigst?', cancelQuestion: 'Dürfen wir fragen, warum du kündigst?',
reasonTooExpensive: 'Es ist mir zu teuer', reasonTooExpensive: 'Es ist mir zu teuer',
@@ -124,7 +152,22 @@ const getBillingCopy = (language: Language) => {
manageSubscription: 'Administrar Suscripción', manageSubscription: 'Administrar Suscripción',
subscriptionTitle: 'Suscripciones', subscriptionTitle: 'Suscripciones',
subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.', subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.',
freePlanName: 'Gratis', paywallTitle: 'Desbloquear diagnostico completo',
paywallHint: 'Inicia Pro para escaneos reales con GPT-5.4, tu plan de rescate de 7 dias y 100 creditos para escaneos IA y seguimientos.',
startTrial: 'Probar 7 dias gratis',
monthlyCta: 'Empezar mensual',
yearlyCta: 'Empezar anual',
yearlyTrialBadge: '7 DIAS GRATIS',
monthlyBadge: 'FLEXIBLE',
yearlySubline: 'Despues 39.99 EUR/ano. Cancela cuando quieras.',
monthlySubline: '4.99 EUR/mes. Sin compromiso anual.',
saveLabel: 'Mejor valor',
expoGoPurchaseTitle: 'Compra solo en Dev Build',
expoGoPurchaseMessage: 'Expo Go no puede mostrar la compra nativa de Apple o RevenueCat. En Development Build o TestFlight aparecera el trial real de 7 dias. Para pruebas locales puedes simular Pro.',
expoGoSimulate: 'Simular Pro',
perYear: '/ ano',
perMonth: '/ mes',
freePlanName: 'Gratis',
freePlanPrice: '0 EUR / Mes', freePlanPrice: '0 EUR / Mes',
proPlanName: 'Pro', proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Mes', proPlanPrice: '4.99 EUR / Mes',
@@ -133,17 +176,20 @@ const getBillingCopy = (language: Language) => {
proYearlyPlanPrice: '39.99 EUR / Año', proYearlyPlanPrice: '39.99 EUR / Año',
proYearlyBadgeText: 'AHORRAR', proYearlyBadgeText: 'AHORRAR',
proBenefits: [ proBenefits: [
'250 créditos cada mes', '100 créditos para escaneos IA y seguimientos cada mes',
'Escaneos Pro con GPT-5.4', 'Escaneos Pro con GPT-5.4',
'Historial y galería ilimitados', 'Historial y galería ilimitados',
'Doctor de plantas de IA incluido', 'Doctor de plantas de IA incluido',
'Soporte prioritario' 'Soporte prioritario'
], ],
topupTitle: 'Recargar Créditos', topupTitle: 'Recargar Créditos',
topupSmall: '25 Créditos 1,99 €', topupHint: 'Para usuarios Pro activos cuando los créditos mensuales no alcanzan.',
topupMedium: '120 Créditos 6,99 €', topupSmall: '30 Créditos 2,99 €',
topupLarge: '300 Créditos 12,99 €', topupMedium: '100 Créditos 6,99 €',
topupBestValue: 'MEJOR OFERTA', topupLarge: '250 Créditos 12,99 €',
topupBestValue: 'MEJOR OFERTA',
topupRequiresProTitle: 'Pro requerido',
topupRequiresProMessage: 'Los top-ups son para usuarios Pro activos. Inicia Pro para comprar créditos adicionales.',
cancelTitle: 'Lamentamos verte ir', cancelTitle: 'Lamentamos verte ir',
cancelQuestion: '¿Podemos saber por qué cancelas?', cancelQuestion: '¿Podemos saber por qué cancelas?',
reasonTooExpensive: 'Es muy caro', reasonTooExpensive: 'Es muy caro',
@@ -169,7 +215,22 @@ const getBillingCopy = (language: Language) => {
manageSubscription: 'Manage Subscription', manageSubscription: 'Manage Subscription',
subscriptionTitle: 'Subscriptions', subscriptionTitle: 'Subscriptions',
subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.', subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.',
freePlanName: 'Free', paywallTitle: 'Unlock the full diagnosis',
paywallHint: 'Start Pro for real GPT-5.4 scans, your 7-day rescue plan, and 100 credits for AI scans and follow-ups.',
startTrial: 'Start 7-day free trial',
monthlyCta: 'Start monthly',
yearlyCta: 'Start yearly',
yearlyTrialBadge: '7 DAYS FREE',
monthlyBadge: 'FLEXIBLE',
yearlySubline: 'Then EUR 39.99/year. Cancel anytime.',
monthlySubline: 'EUR 4.99/month. No annual commitment.',
saveLabel: 'Best value',
expoGoPurchaseTitle: 'Purchase requires a dev build',
expoGoPurchaseMessage: 'Expo Go cannot show the native Apple or RevenueCat purchase sheet. In a Development Build or TestFlight this opens the real 7-day trial. For local testing you can simulate Pro.',
expoGoSimulate: 'Simulate Pro',
perYear: '/ year',
perMonth: '/ month',
freePlanName: 'Free',
freePlanPrice: '0 EUR / Month', freePlanPrice: '0 EUR / Month',
proPlanName: 'Pro', proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Month', proPlanPrice: '4.99 EUR / Month',
@@ -178,17 +239,20 @@ const getBillingCopy = (language: Language) => {
proYearlyPlanPrice: '39.99 EUR / Year', proYearlyPlanPrice: '39.99 EUR / Year',
proYearlyBadgeText: 'SAVE', proYearlyBadgeText: 'SAVE',
proBenefits: [ proBenefits: [
'250 credits every month', '100 credits for AI scans and follow-ups every month',
'Pro scans with GPT-5.4', 'Pro scans with GPT-5.4',
'Unlimited history & gallery', 'Unlimited history & gallery',
'AI Plant Doctor included', 'AI Plant Doctor included',
'Priority support' 'Priority support'
], ],
topupTitle: 'Topup Credits', topupTitle: 'Topup Credits',
topupSmall: '25 Credits €1.99', topupHint: 'For active Pro users when monthly credits are not enough.',
topupMedium: '120 Credits 6.99', topupSmall: '30 Credits 2.99',
topupLarge: '300 Credits 12.99', topupMedium: '100 Credits 6.99',
topupBestValue: 'BEST VALUE', topupLarge: '250 Credits €12.99',
topupBestValue: 'BEST VALUE',
topupRequiresProTitle: 'Pro required',
topupRequiresProMessage: 'Top-ups are for active Pro users. Start Pro to buy extra credits.',
cancelTitle: 'Sorry to see you go', cancelTitle: 'Sorry to see you go',
cancelQuestion: 'May we ask why you are cancelling?', cancelQuestion: 'May we ask why you are cancelling?',
reasonTooExpensive: 'It is too expensive', reasonTooExpensive: 'It is too expensive',
@@ -225,8 +289,9 @@ export default function BillingScreen() {
// Cancel Flow State // Cancel Flow State
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
const planId = billingSummary?.entitlement?.plan || 'free'; const planId = billingSummary?.entitlement?.plan || 'free';
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--'); const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--');
const showPaywallPlans = !session || planId !== 'pro';
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -274,9 +339,15 @@ export default function BillingScreen() {
}; };
}, [isExpoGo]); }, [isExpoGo]);
useEffect(() => { useEffect(() => {
posthog.capture('paywall_viewed', { plan_id: planId }); posthog.capture('paywall_viewed', { plan_id: planId });
}, [posthog, planId]); if (showPaywallPlans) {
posthog.capture('hard_paywall_viewed', {
plan_id: planId,
authenticated: Boolean(session),
});
}
}, [posthog, planId, session?.serverUserId, showPaywallPlans]);
const monthlyPackage = subscriptionPackages.monthly_pro; const monthlyPackage = subscriptionPackages.monthly_pro;
const yearlyPackage = subscriptionPackages.yearly_pro; const yearlyPackage = subscriptionPackages.yearly_pro;
@@ -285,22 +356,60 @@ export default function BillingScreen() {
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice;
const topupLabels = useMemo(() => ({ const topupLabels = useMemo(() => ({
topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall, topup_small: topupProducts.topup_small ? `${TOPUP_CREDITS_BY_PRODUCT.topup_small} Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium, topup_medium: topupProducts.topup_medium ? `${TOPUP_CREDITS_BY_PRODUCT.topup_medium} Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium,
topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge, topup_large: topupProducts.topup_large ? `${TOPUP_CREDITS_BY_PRODUCT.topup_large} Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge,
}), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]); }), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]);
const openAppleSubscriptions = async () => { const openAppleSubscriptions = async () => {
await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions'); await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
}; };
const handlePurchase = async (productId: PurchaseProductId) => { const handleBack = () => {
setIsUpdating(true); if (showPaywallPlans) {
posthog.capture('purchase_initiated', { product_id: productId }); router.replace(session ? '/scanner' : '/onboarding');
return;
}
router.back();
};
const completeExpoGoSimulation = async (productId: PurchaseProductId) => {
setIsUpdating(true);
try {
await simulatePurchase(productId);
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
posthog.capture('subscription_started', { product_id: productId, simulated: true });
posthog.capture('trial_started', { product_id: productId, simulated: true });
} else {
posthog.capture('topup_purchased', { product_id: productId, simulated: true });
}
} finally {
setIsUpdating(false);
}
};
const handlePurchase = async (productId: PurchaseProductId) => {
if (isTopupProductId(productId) && planId !== 'pro') {
Alert.alert(copy.topupRequiresProTitle, copy.topupRequiresProMessage, [
{ text: copy.manageSubscription, onPress: () => setSubModalVisible(true) },
]);
return;
}
setIsUpdating(true);
posthog.capture('purchase_initiated', { product_id: productId });
try { try {
if (isExpoGo) { if (isExpoGo) {
// ExpoGo has no native RevenueCat — use simulation for development only // ExpoGo has no native RevenueCat — use simulation for development only
await simulatePurchase(productId); setIsUpdating(false);
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [
{ text: 'OK', style: 'cancel' },
]);
return;
}
await completeExpoGoSimulation(productId);
return;
} else { } else {
if (productId === 'monthly_pro' || productId === 'yearly_pro') { if (productId === 'monthly_pro' || productId === 'yearly_pro') {
if (planId === 'pro') { if (planId === 'pro') {
@@ -323,8 +432,7 @@ export default function BillingScreen() {
// Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3) // Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3)
const customerInfo = await Purchases.getCustomerInfo(); const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any, 'subscription_purchase'); await syncRevenueCatState(customerInfo as any, 'subscription_purchase');
posthog.capture('subscription_started', { product_id: productId }); } else {
} else {
const selectedProduct = topupProducts[productId]; const selectedProduct = topupProducts[productId];
if (!selectedProduct) { if (!selectedProduct) {
throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.'); throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.');
@@ -332,18 +440,24 @@ export default function BillingScreen() {
await Purchases.purchaseStoreProduct(selectedProduct); await Purchases.purchaseStoreProduct(selectedProduct);
const customerInfo = await Purchases.getCustomerInfo(); const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any, 'topup_purchase'); await syncRevenueCatState(customerInfo as any, 'topup_purchase');
posthog.capture('topup_purchased', { product_id: productId }); }
} }
} if (productId === 'monthly_pro' || productId === 'yearly_pro') {
setSubModalVisible(false); posthog.capture('subscription_started', { product_id: productId });
posthog.capture('trial_started', { product_id: productId });
} else {
posthog.capture('topup_purchased', { product_id: productId });
}
setSubModalVisible(false);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled); const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled);
if (userCancelled) { if (userCancelled) {
posthog.capture('purchase_cancelled', { product_id: productId }); posthog.capture('purchase_cancelled', { product_id: productId });
return; posthog.capture('paywall_purchase_cancelled', { product_id: productId });
} return;
}
// RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already // RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already
// owns this subscription on a different GreenLens account. Silently dismiss; // owns this subscription on a different GreenLens account. Silently dismiss;
@@ -405,7 +519,7 @@ export default function BillingScreen() {
<ThemeBackdrop colors={colors} /> <ThemeBackdrop colors={colors} />
<SafeAreaView style={styles.safeArea} edges={['top']}> <SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}> <TouchableOpacity onPress={handleBack} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.text} /> <Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text> <Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
@@ -417,7 +531,7 @@ export default function BillingScreen() {
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} /> <ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
) : ( ) : (
<> <>
{session && ( {session && planId === 'pro' && (
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}> <View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.planLabel}</Text> <Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.planLabel}</Text>
<View style={[styles.row, { marginBottom: 16 }]}> <View style={[styles.row, { marginBottom: 16 }]}>
@@ -436,66 +550,73 @@ export default function BillingScreen() {
<Text style={[styles.creditsValue, { color: colors.text }]}>{credits}</Text> <Text style={[styles.creditsValue, { color: colors.text }]}>{credits}</Text>
</View> </View>
)} )}
{!session && ( {showPaywallPlans && (
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}> <View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Subscription Plans</Text> <Text style={[styles.paywallTitle, { color: colors.text }]}>{copy.paywallTitle}</Text>
<Text style={[styles.modalHint, { color: colors.text + '80', marginBottom: 16 }]}> <Text style={[styles.modalHint, { color: colors.text + '80', marginBottom: 16 }]}>
Choose a plan to unlock AI plant scans and care features. {copy.paywallHint}
</Text> </Text>
{/* Monthly */} <View style={styles.paywallValueRows}>
<View style={[styles.guestPlanCard, { borderColor: colors.primary, backgroundColor: colors.primary + '10' }]}> {copy.proBenefits.slice(0, 3).map((benefit, index) => (
<View style={styles.guestPlanHeader}> <View key={benefit} style={[styles.paywallValueRow, { backgroundColor: colors.surfaceMuted }]}>
<Text style={[styles.guestPlanName, { color: colors.text }]}>GreenLens Pro</Text> <Ionicons
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}> name={index === 0 ? 'scan-outline' : index === 1 ? 'medkit-outline' : 'calendar-outline'}
<Text style={styles.proBadgeText}>MONTHLY</Text> size={17}
</View> color={colors.primary}
</View> />
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{monthlyPrice}</Text> <Text style={[styles.paywallValueText, { color: colors.text }]}>{benefit}</Text>
<Text style={[styles.guestPlanRenew, { color: colors.textMuted }]}>{copy.autoRenewMonthly}</Text> </View>
<View style={{ gap: 4, marginTop: 8 }}> ))}
{copy.proBenefits.map((b, i) => ( </View>
<View key={i} style={styles.benefitRow}>
<Ionicons name="checkmark" size={14} color={colors.primary} /> <TouchableOpacity
<Text style={[styles.benefitText, { color: colors.textSecondary }]}>{b}</Text> style={[styles.paywallPlanCardPrimary, { borderColor: colors.primary, backgroundColor: colors.primary + '12' }]}
</View> onPress={() => handlePurchase('yearly_pro')}
))} disabled={isUpdating || !storeReady}
</View> activeOpacity={0.9}
<TouchableOpacity >
style={[styles.guestSubscribeBtn, { backgroundColor: colors.primary }]} <View style={styles.planTopRow}>
onPress={() => handlePurchase('monthly_pro')} <View>
disabled={isUpdating || !storeReady} <Text style={[styles.guestPlanName, { color: colors.text }]}>GreenLens Pro</Text>
> <Text style={[styles.planSubline, { color: colors.textMuted }]}>{copy.saveLabel}</Text>
<Text style={styles.manageBtnText}>Subscribe Monthly</Text> </View>
</TouchableOpacity> <View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
</View> <Text style={styles.proBadgeText}>{copy.yearlyTrialBadge}</Text>
</View>
{/* Yearly */} </View>
<View style={[styles.guestPlanCard, { borderColor: colors.border, marginTop: 12 }]}> <View style={styles.priceRow}>
<View style={styles.guestPlanHeader}> <Text style={[styles.guestPlanPrice, { color: colors.text }]}>{yearlyPrice}</Text>
<Text style={[styles.guestPlanName, { color: colors.text }]}>GreenLens Pro</Text> <Text style={[styles.planTerm, { color: colors.textMuted }]}>{copy.perYear}</Text>
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}> </View>
<Text style={styles.proBadgeText}>YEARLY</Text> <Text style={[styles.guestPlanRenew, { color: colors.textMuted }]}>{copy.yearlySubline}</Text>
</View> <View style={[styles.trialCallout, { backgroundColor: colors.primarySoft }]}>
</View> <Ionicons name="sparkles-outline" size={16} color={colors.primary} />
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{yearlyPrice}</Text> <Text style={[styles.trialCalloutText, { color: colors.text }]}>{copy.startTrial}</Text>
<Text style={[styles.guestPlanRenew, { color: colors.textMuted }]}>{copy.autoRenewYearly}</Text> </View>
<View style={{ gap: 4, marginTop: 8 }}> </TouchableOpacity>
{copy.proBenefits.map((b, i) => (
<View key={i} style={styles.benefitRow}> <TouchableOpacity
<Ionicons name="checkmark" size={14} color={colors.primary} /> style={[styles.paywallPlanCardSecondary, { borderColor: colors.border }]}
<Text style={[styles.benefitText, { color: colors.textSecondary }]}>{b}</Text> onPress={() => handlePurchase('monthly_pro')}
</View> disabled={isUpdating || !storeReady}
))} activeOpacity={0.9}
</View> >
<TouchableOpacity <View style={styles.planTopRow}>
style={[styles.guestSubscribeBtn, { backgroundColor: colors.primary }]} <View>
onPress={() => handlePurchase('yearly_pro')} <Text style={[styles.guestPlanName, { color: colors.text }]}>Monatlich</Text>
disabled={isUpdating || !storeReady} <Text style={[styles.planSubline, { color: colors.textMuted }]}>{copy.monthlySubline}</Text>
> </View>
<Text style={styles.manageBtnText}>Subscribe Yearly</Text> <View style={[styles.secondaryBadge, { borderColor: colors.borderStrong }]}>
</TouchableOpacity> <Text style={[styles.secondaryBadgeText, { color: colors.textSecondary }]}>{copy.monthlyBadge}</Text>
</View> </View>
</View>
<View style={styles.priceRow}>
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{monthlyPrice}</Text>
<Text style={[styles.planTerm, { color: colors.textMuted }]}>{copy.perMonth}</Text>
</View>
<Text style={[styles.monthlyCtaText, { color: colors.primary }]}>{copy.monthlyCta}</Text>
</TouchableOpacity>
<View style={[styles.legalLinksRow, { marginTop: 16 }]}> <View style={[styles.legalLinksRow, { marginTop: 16 }]}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}> <TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
@@ -512,9 +633,11 @@ export default function BillingScreen() {
</View> </View>
)} )}
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}> {session && planId === 'pro' && !isExpoGo ? (
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.topupTitle}</Text> <View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<View style={{ gap: 10, marginTop: 8 }}> <Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.topupTitle}</Text>
<Text style={[styles.modalHint, { color: colors.text + '80', marginBottom: 8 }]}>{copy.topupHint}</Text>
<View style={{ gap: 10, marginTop: 8 }}>
{([ {([
{ id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small }, { id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small },
{ id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue }, { id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue },
@@ -560,10 +683,11 @@ export default function BillingScreen() {
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text> <Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<TouchableOpacity style={styles.restoreBtn} onPress={handleRestore} disabled={isUpdating}> <TouchableOpacity style={styles.restoreBtn} onPress={handleRestore} disabled={isUpdating}>
<Text style={[styles.legalLink, { color: colors.textMuted }]}>{copy.restorePurchases}</Text> <Text style={[styles.legalLink, { color: colors.textMuted }]}>{copy.restorePurchases}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : null}
</> </>
)} )}
</ScrollView> </ScrollView>
@@ -779,11 +903,36 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
}, },
creditsValue: { creditsValue: {
fontSize: 32, fontSize: 32,
fontWeight: '700', fontWeight: '700',
}, },
topupBtn: { paywallTitle: {
fontSize: 24,
fontWeight: '800',
lineHeight: 30,
marginBottom: 8,
},
paywallValueRows: {
gap: 8,
marginBottom: 14,
},
paywallValueRow: {
minHeight: 42,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 9,
},
paywallValueText: {
flex: 1,
fontSize: 13,
fontWeight: '700',
lineHeight: 18,
},
topupBtn: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -940,29 +1089,93 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
fontWeight: '500', fontWeight: '500',
}, },
guestPlanCard: { guestPlanCard: {
borderWidth: 2, borderWidth: 2,
borderRadius: 12, borderRadius: 12,
padding: 16, padding: 16,
}, },
guestPlanHeader: { paywallPlanCardPrimary: {
flexDirection: 'row', borderWidth: 2,
alignItems: 'center', borderRadius: 14,
gap: 8, padding: 16,
marginBottom: 4, marginTop: 2,
}, },
guestPlanName: { paywallPlanCardSecondary: {
fontSize: 18, borderWidth: 1,
fontWeight: '700', borderRadius: 14,
}, padding: 14,
guestPlanPrice: { marginTop: 10,
fontSize: 22, },
fontWeight: '700', guestPlanHeader: {
marginBottom: 2, flexDirection: 'row',
}, alignItems: 'center',
guestPlanRenew: { gap: 8,
fontSize: 12, marginBottom: 4,
}, },
planTopRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 10,
marginBottom: 10,
},
guestPlanName: {
fontSize: 18,
fontWeight: '700',
},
planSubline: {
fontSize: 12,
fontWeight: '600',
marginTop: 2,
},
priceRow: {
flexDirection: 'row',
alignItems: 'baseline',
gap: 6,
},
guestPlanPrice: {
fontSize: 22,
fontWeight: '700',
marginBottom: 2,
},
planTerm: {
fontSize: 13,
fontWeight: '600',
},
guestPlanRenew: {
fontSize: 12,
lineHeight: 17,
},
trialCallout: {
minHeight: 46,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
marginTop: 14,
},
trialCalloutText: {
fontSize: 15,
fontWeight: '800',
},
secondaryBadge: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 3,
},
secondaryBadgeText: {
fontSize: 10,
fontWeight: '800',
},
monthlyCtaText: {
fontSize: 13,
fontWeight: '800',
marginTop: 8,
},
guestSubscribeBtn: { guestSubscribeBtn: {
marginTop: 14, marginTop: 14,
paddingVertical: 12, paddingVertical: 12,

View File

@@ -6,18 +6,20 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera'; import { CameraView, useCameraPermissions } from 'expo-camera';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import * as ImageManipulator from 'expo-image-manipulator'; import * as ImageManipulator from 'expo-image-manipulator';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { usePostHog } from 'posthog-react-native'; import * as AppleAuthentication from 'expo-apple-authentication';
import { usePostHog } from 'posthog-react-native';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { useColors } from '../constants/Colors'; import { useColors } from '../constants/Colors';
import { PlantRecognitionService } from '../services/plantRecognitionService'; import { PlantRecognitionService } from '../services/plantRecognitionService';
import { IdentificationResult } from '../types'; import { IdentificationResult } from '../types';
import { ResultCard } from '../components/ResultCard'; import { ResultCard } from '../components/ResultCard';
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient'; import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
import { isBackendApiError } from '../services/backend/contracts'; import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency'; import { createIdempotencyKey } from '../utils/idempotency';
import { AuthService } from '../services/authService';
const HEALTH_CHECK_CREDIT_COST = 2; const HEALTH_CHECK_CREDIT_COST = 2;
@@ -41,11 +43,16 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
notAPlantMessage: 'Das Bild zeigt keine erkennbare Pflanze. Bitte fotografiere eine Pflanze und versuche es erneut.', notAPlantMessage: 'Das Bild zeigt keine erkennbare Pflanze. Bitte fotografiere eine Pflanze und versuche es erneut.',
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.', providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.',
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.', healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
healthTitle: 'Health Check', healthTitle: 'Health Check',
healthDoneTitle: 'Health Check abgeschlossen', healthDoneTitle: 'Health Check abgeschlossen',
healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.', healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.',
signupLabel: 'Registrieren', signupLabel: 'Registrieren',
}; demoTitle: 'Rettungsplan bereit',
demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.',
appleCta: 'Mit Apple fortfahren',
emailCta: 'Mit E-Mail fortfahren',
unlockCta: 'Vollständige Diagnose freischalten',
};
} }
if (language === 'es') { if (language === 'es') {
@@ -67,11 +74,16 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
notAPlantMessage: 'La imagen no muestra una planta reconocible. Por favor fotografía una planta e inténtalo de nuevo.', notAPlantMessage: 'La imagen no muestra una planta reconocible. Por favor fotografía una planta e inténtalo de nuevo.',
providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.', providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.',
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.', healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
healthTitle: 'Health Check', healthTitle: 'Health Check',
healthDoneTitle: 'Health-check completado', healthDoneTitle: 'Health-check completado',
healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.', healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.',
signupLabel: 'Registrarse', signupLabel: 'Registrarse',
}; demoTitle: 'Plan de rescate listo',
demoMessage: 'Detectamos posibles causas. Desbloquea el diagnóstico completo con IA y tu plan de rescate de 7 días.',
appleCta: 'Continuar con Apple',
emailCta: 'Continuar con email',
unlockCta: 'Desbloquear diagnóstico completo',
};
} }
return { return {
@@ -92,12 +104,17 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
notAPlantMessage: 'The image does not show a recognizable plant. Please photograph a plant and try again.', notAPlantMessage: 'The image does not show a recognizable plant. Please photograph a plant and try again.',
providerErrorMessage: 'AI scan is currently unavailable. Please try again.', providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.', healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
healthTitle: 'Health Check', healthTitle: 'Health Check',
healthDoneTitle: 'Health Check Complete', healthDoneTitle: 'Health Check Complete',
healthDoneMessage: 'The new photo was analyzed and added to gallery.', healthDoneMessage: 'The new photo was analyzed and added to gallery.',
signupLabel: 'Sign Up', signupLabel: 'Sign Up',
}; demoTitle: 'Rescue plan ready',
}; demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.',
appleCta: 'Continue with Apple',
emailCta: 'Continue with email',
unlockCta: 'Unlock full diagnosis',
};
};
export default function ScannerScreen() { export default function ScannerScreen() {
const params = useLocalSearchParams<{ mode?: string; plantId?: string }>(); const params = useLocalSearchParams<{ mode?: string; plantId?: string }>();
@@ -112,36 +129,53 @@ export default function ScannerScreen() {
updatePlant, updatePlant,
billingSummary, billingSummary,
refreshBillingSummary, refreshBillingSummary,
isLoadingBilling, isLoadingBilling,
session, session,
setPendingPlant, hydrateSession,
guestScanCount, setPendingPlant,
incrementGuestScanCount, } = useApp();
} = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const billingCopy = getBillingCopy(language); const billingCopy = getBillingCopy(language);
const isHealthMode = params.mode === 'health'; const isHealthMode = params.mode === 'health';
const healthPlantId = Array.isArray(params.plantId) ? params.plantId[0] : params.plantId; const healthPlantId = Array.isArray(params.plantId) ? params.plantId[0] : params.plantId;
const healthPlant = isHealthMode && healthPlantId const healthPlant = isHealthMode && healthPlantId
? plants.find((item) => item.id === healthPlantId) ? plants.find((item) => item.id === healthPlantId)
: null; : null;
const availableCredits = session const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
? (billingSummary?.credits.available ?? 0) && billingSummary?.entitlement?.status === 'active';
: Math.max(0, 5 - guestScanCount); const isDemoMode = !hasActiveEntitlement;
const availableCredits = hasActiveEntitlement ? (billingSummary?.credits.available ?? 0) : 0;
const [permission, requestPermission] = useCameraPermissions();
const [selectedImage, setSelectedImage] = useState<string | null>(null); const [permission, requestPermission] = useCameraPermissions();
const [isAnalyzing, setIsAnalyzing] = useState(false); const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [analysisProgress, setAnalysisProgress] = useState(0); const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisResult, setAnalysisResult] = useState<IdentificationResult | null>(null); const [isAuthLoading, setIsAuthLoading] = useState(false);
const cameraRef = useRef<CameraView>(null); const [appleAvailable, setAppleAvailable] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
const [analysisResult, setAnalysisResult] = useState<IdentificationResult | null>(null);
const [demoResultVisible, setDemoResultVisible] = useState(false);
const cameraRef = useRef<CameraView>(null);
const scanLineProgress = useRef(new Animated.Value(0)).current; const scanLineProgress = useRef(new Animated.Value(0)).current;
const scanPulse = useRef(new Animated.Value(0)).current; const scanPulse = useRef(new Animated.Value(0)).current;
useEffect(() => { useEffect(() => {
if (!isAnalyzing) { let mounted = true;
AppleAuthentication.isAvailableAsync()
.then((available) => {
if (mounted) setAppleAvailable(available);
})
.catch(() => {
if (mounted) setAppleAvailable(false);
});
return () => {
mounted = false;
};
}, []);
useEffect(() => {
if (!isAnalyzing) {
scanLineProgress.stopAnimation(); scanLineProgress.stopAnimation();
scanLineProgress.setValue(0); scanLineProgress.setValue(0);
scanPulse.stopAnimation(); scanPulse.stopAnimation();
@@ -199,29 +233,25 @@ export default function ScannerScreen() {
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => { const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
if (isAnalyzing) return; if (isAnalyzing) return;
if (availableCredits <= 0) { if (!isDemoMode && availableCredits <= 0) {
if (!session) {
// Guest: show paywall directly — no registration required to purchase
router.push('/profile/billing');
return;
}
Alert.alert( Alert.alert(
billingCopy.noCreditsTitle, billingCopy.noCreditsTitle,
isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage, isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage,
[ [
{ text: billingCopy.dismiss, style: 'cancel' }, { text: billingCopy.dismiss, style: 'cancel' },
{ {
text: billingCopy.managePlan, text: billingCopy.managePlan,
onPress: () => router.replace('/(tabs)/profile'), onPress: () => router.replace('/profile/billing'),
}, },
], ],
); );
return; return;
} }
setIsAnalyzing(true); setIsAnalyzing(true);
setAnalysisProgress(0); setAnalysisProgress(0);
setAnalysisResult(null); setAnalysisResult(null);
setDemoResultVisible(false);
const startTime = Date.now(); const startTime = Date.now();
@@ -234,10 +264,32 @@ export default function ScannerScreen() {
}); });
}, 150); }, 150);
try { try {
if (isHealthMode) { if (isDemoMode) {
if (!healthPlant) { posthog.capture('demo_scan_started', {
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); authenticated: Boolean(session),
scan_type: isHealthMode ? 'health_check' : 'identification',
});
await new Promise(resolve => setTimeout(resolve, 2100));
setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await new Promise(resolve => setTimeout(resolve, 350));
setDemoResultVisible(true);
posthog.capture('demo_scan_completed', {
authenticated: Boolean(session),
latency_ms: Date.now() - startTime,
});
return;
}
posthog.capture('paid_scan_started', {
scan_type: isHealthMode ? 'health_check' : 'identification',
credits_available: availableCredits,
});
if (isHealthMode) {
if (!healthPlant) {
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
setSelectedImage(null); setSelectedImage(null);
setIsAnalyzing(false); setIsAnalyzing(false);
return; return;
@@ -261,11 +313,7 @@ export default function ScannerScreen() {
latency_ms: Date.now() - startTime, latency_ms: Date.now() - startTime,
}); });
if (!session) { const currentGallery = healthPlant.gallery || [];
incrementGuestScanCount();
}
const currentGallery = healthPlant.gallery || [];
const existingChecks = healthPlant.healthChecks || []; const existingChecks = healthPlant.healthChecks || [];
const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6); const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6);
const updatedPlant = { const updatedPlant = {
@@ -285,16 +333,16 @@ export default function ScannerScreen() {
latency_ms: Date.now() - startTime, latency_ms: Date.now() - startTime,
}); });
if (!session) { setAnalysisResult(result);
incrementGuestScanCount(); }
} setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setAnalysisResult(result); posthog.capture('paid_scan_completed', {
} scan_type: isHealthMode ? 'health_check' : 'identification',
setAnalysisProgress(100); latency_ms: Date.now() - startTime,
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); });
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 500));
setIsAnalyzing(false); setIsAnalyzing(false);
if (isHealthMode && healthPlant) { if (isHealthMode && healthPlant) {
Alert.alert(billingCopy.healthDoneTitle, billingCopy.healthDoneMessage, [ Alert.alert(billingCopy.healthDoneTitle, billingCopy.healthDoneMessage, [
{ text: billingCopy.dismiss, onPress: () => router.replace(`/plant/${healthPlant.id}`) }, { text: billingCopy.dismiss, onPress: () => router.replace(`/plant/${healthPlant.id}`) },
@@ -315,13 +363,13 @@ export default function ScannerScreen() {
billingCopy.noCreditsTitle, billingCopy.noCreditsTitle,
isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage, isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage,
[ [
{ text: billingCopy.dismiss, style: 'cancel' }, { text: billingCopy.dismiss, style: 'cancel' },
{ {
text: billingCopy.managePlan, text: billingCopy.managePlan,
onPress: () => router.replace('/(tabs)/profile'), onPress: () => router.replace('/profile/billing'),
}, },
], ],
); );
} else if (isTimeoutError(error)) { } else if (isTimeoutError(error)) {
Alert.alert( Alert.alert(
billingCopy.timeoutTitle, billingCopy.timeoutTitle,
@@ -360,20 +408,24 @@ export default function ScannerScreen() {
} }
setSelectedImage(null); setSelectedImage(null);
setIsAnalyzing(false); setIsAnalyzing(false);
} finally { } finally {
clearInterval(progressInterval); clearInterval(progressInterval);
await refreshBillingSummary(); setIsAnalyzing(false);
} if (!isDemoMode) {
}; await refreshBillingSummary();
}
}
};
const takePicture = async () => { const takePicture = async () => {
if (!cameraRef.current || isAnalyzing) return; if (!cameraRef.current || isAnalyzing) return;
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 }); const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 });
if (photo) { if (photo) {
const analysisUri = await resizeForAnalysis(photo.uri); const analysisUri = await resizeForAnalysis(photo.uri);
setSelectedImage(analysisUri); setDemoResultVisible(false);
analyzeImage(analysisUri, photo.uri); setSelectedImage(analysisUri);
analyzeImage(analysisUri, photo.uri);
} }
}; };
@@ -385,15 +437,16 @@ export default function ScannerScreen() {
quality: 1, quality: 1,
base64: false, base64: false,
}); });
if (!result.canceled && result.assets[0]) { if (!result.canceled && result.assets[0]) {
const asset = result.assets[0]; const asset = result.assets[0];
const analysisUri = await resizeForAnalysis(asset.uri); const analysisUri = await resizeForAnalysis(asset.uri);
setSelectedImage(asset.uri); setDemoResultVisible(false);
setSelectedImage(asset.uri);
analyzeImage(analysisUri, asset.uri); analyzeImage(analysisUri, asset.uri);
} }
}; };
const handleSave = async () => { const handleSave = async () => {
if (analysisResult && selectedImage) { if (analysisResult && selectedImage) {
if (!session) { if (!session) {
// Guest mode: store result and go to signup // Guest mode: store result and go to signup
@@ -409,10 +462,72 @@ export default function ScannerScreen() {
console.error('Saving identified plant failed', error); console.error('Saving identified plant failed', error);
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
} }
} }
}; };
const handleClose = () => { const routeToHardPaywall = () => {
posthog.capture('auth_prompt_shown', {
authenticated: Boolean(session),
surface: 'demo_scan_result',
});
if (session) {
router.replace('/profile/billing');
return;
}
router.replace('/auth/signup');
};
const handleDemoAppleSignIn = async () => {
if (!appleAvailable) {
routeToHardPaywall();
return;
}
setIsAuthLoading(true);
posthog.capture('apple_login_started', { surface: 'scanner_demo' });
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) {
throw new Error('APPLE_AUTH_INVALID');
}
const fullName = [
credential.fullName?.givenName,
credential.fullName?.familyName,
].filter(Boolean).join(' ');
const nextSession = await AuthService.signInWithApple({
identityToken: credential.identityToken,
appleUser: credential.user,
email: credential.email,
name: fullName || undefined,
});
await hydrateSession(nextSession);
posthog.capture('apple_login_succeeded', { surface: 'scanner_demo' });
router.replace('/profile/billing');
} catch (error: any) {
if (error?.code === 'ERR_REQUEST_CANCELED') {
return;
}
posthog.capture('apple_login_failed', {
surface: 'scanner_demo',
error: error instanceof Error ? error.message : String(error),
});
Alert.alert(
billingCopy.genericErrorTitle,
error instanceof Error && error.message === 'APPLE_BACKEND_UNAVAILABLE'
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
: billingCopy.genericErrorMessage,
);
} finally {
setIsAuthLoading(false);
}
};
const handleClose = () => {
router.back(); router.back();
}; };
@@ -470,14 +585,14 @@ export default function ScannerScreen() {
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.headerTitle, { color: colors.iconOnImage }]}> <Text style={[styles.headerTitle, { color: colors.iconOnImage }]}>
{isHealthMode ? billingCopy.healthTitle : t.scanner} {isHealthMode ? billingCopy.healthTitle : t.scanner}
</Text> </Text>
<View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}> <View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}>
<Ionicons name="wallet-outline" size={12} color={colors.text} /> <Ionicons name={isDemoMode ? 'sparkles-outline' : 'wallet-outline'} size={12} color={colors.text} />
<Text style={[styles.creditBadgeText, { color: colors.text }]}> <Text style={[styles.creditBadgeText, { color: colors.text }]}>
{billingCopy.creditsLabel}: {availableCredits} {isDemoMode ? 'Demo' : `${billingCopy.creditsLabel}: ${availableCredits}`}
</Text> </Text>
</View> </View>
</View> </View>
{/* Camera */} {/* Camera */}
<View style={styles.cameraContainer}> <View style={styles.cameraContainer}>
@@ -560,10 +675,65 @@ export default function ScannerScreen() {
</Text> </Text>
</View> </View>
</View> </View>
)} )}
{/* Bottom Controls */} {demoResultVisible && !isAnalyzing ? (
<View <View
style={[
styles.demoSheet,
{
backgroundColor: colors.background,
borderColor: colors.border,
bottom: analysisBottomOffset,
},
]}
>
<View style={[styles.demoIconWrap, { backgroundColor: colors.primarySoft }]}>
<Ionicons name="sparkles" size={22} color={colors.primary} />
</View>
<Text style={[styles.demoTitle, { color: colors.text }]}>{billingCopy.demoTitle}</Text>
<Text style={[styles.demoMessage, { color: colors.textSecondary }]}>{billingCopy.demoMessage}</Text>
{!session && appleAvailable ? (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
style={styles.demoAppleButton}
onPress={handleDemoAppleSignIn}
/>
) : (
<TouchableOpacity
style={[styles.demoPrimaryBtn, { backgroundColor: colors.primary }]}
onPress={session ? routeToHardPaywall : handleDemoAppleSignIn}
disabled={isAuthLoading}
activeOpacity={0.85}
>
<Text style={[styles.demoPrimaryText, { color: colors.onPrimary }]}>
{isAuthLoading ? '...' : session ? billingCopy.unlockCta : billingCopy.appleCta}
</Text>
</TouchableOpacity>
)}
{!session ? (
<TouchableOpacity
style={[styles.demoSecondaryBtn, { borderColor: colors.borderStrong }]}
onPress={() => {
posthog.capture('auth_prompt_shown', { surface: 'demo_scan_result', method: 'email' });
router.replace('/auth/signup');
}}
activeOpacity={0.85}
>
<Text style={[styles.demoSecondaryText, { color: colors.text }]}>{billingCopy.emailCta}</Text>
</TouchableOpacity>
) : null}
</View>
) : null}
{/* Bottom Controls */}
<View
style={[ style={[
styles.controls, styles.controls,
{ {
@@ -686,7 +856,7 @@ const styles = StyleSheet.create({
}, },
shutterInner: { width: 64, height: 64, borderRadius: 32 }, shutterInner: { width: 64, height: 64, borderRadius: 32 },
shutterBtnDisabled: { opacity: 0.6 }, shutterBtnDisabled: { opacity: 0.6 },
analysisSheet: { analysisSheet: {
position: 'absolute', position: 'absolute',
left: 16, left: 16,
right: 16, right: 16,
@@ -699,9 +869,68 @@ const styles = StyleSheet.create({
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.28, shadowOpacity: 0.28,
shadowRadius: 14, shadowRadius: 14,
elevation: 14, elevation: 14,
}, },
analysisHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }, demoSheet: {
position: 'absolute',
left: 16,
right: 16,
borderRadius: 22,
borderWidth: 1,
padding: 18,
zIndex: 25,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.24,
shadowRadius: 12,
elevation: 12,
},
demoIconWrap: {
width: 42,
height: 42,
borderRadius: 21,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 10,
},
demoTitle: {
fontSize: 20,
fontWeight: '800',
marginBottom: 6,
},
demoMessage: {
fontSize: 14,
lineHeight: 20,
marginBottom: 14,
},
demoAppleButton: {
width: '100%',
height: 50,
marginBottom: 10,
},
demoPrimaryBtn: {
height: 50,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 10,
},
demoPrimaryText: {
fontSize: 15,
fontWeight: '800',
},
demoSecondaryBtn: {
height: 48,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
demoSecondaryText: {
fontSize: 14,
fontWeight: '700',
},
analysisHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
analysisBadge: { analysisBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

27123
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,79 +1,80 @@
{ {
"name": "greenlens", "name": "greenlens",
"version": "2.2.3", "version": "2.2.3",
"main": "expo-router/entry", "main": "expo-router/entry",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "expo start --offline", "start": "expo start --offline",
"android": "expo start --android --offline", "android": "expo start --android --offline",
"ios": "expo start --ios --offline", "ios": "expo start --ios --offline",
"web": "expo start --web --offline", "web": "expo start --web --offline",
"build:dev": "eas build --profile development --platform android", "build:dev": "eas build --profile development --platform android",
"build:preview": "eas build --profile preview --platform android", "build:preview": "eas build --profile preview --platform android",
"build:prod": "eas build --profile production --platform android", "build:prod": "eas build --profile production --platform android",
"test": "jest", "test": "jest",
"audit:semantic": "node scripts/generate_semantic_audit.js" "audit:semantic": "node scripts/generate_semantic_audit.js"
}, },
"jest": { "jest": {
"preset": "jest-expo", "preset": "jest-expo",
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)" "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)"
], ],
"setupFiles": [ "setupFiles": [
"./jest.setup.js" "./jest.setup.js"
] ]
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@google/genai": "^1.38.0", "@google/genai": "^1.38.0",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"expo": "^54.0.33", "expo": "^54.0.33",
"expo-application": "~7.0.8", "expo-apple-authentication": "~8.0.8",
"expo-asset": "~12.0.12", "expo-application": "~7.0.8",
"expo-av": "^16.0.8", "expo-asset": "~12.0.12",
"expo-blur": "~15.0.8", "expo-av": "^16.0.8",
"expo-build-properties": "^55.0.9", "expo-blur": "~15.0.8",
"expo-camera": "~17.0.10", "expo-build-properties": "^55.0.9",
"expo-constants": "~18.0.13", "expo-camera": "~17.0.10",
"expo-dev-client": "~6.0.20", "expo-constants": "~18.0.13",
"expo-device": "~8.0.10", "expo-dev-client": "~6.0.20",
"expo-file-system": "~19.0.21", "expo-device": "~8.0.10",
"expo-font": "~14.0.11", "expo-file-system": "~19.0.21",
"expo-haptics": "~15.0.8", "expo-font": "~14.0.11",
"expo-image-manipulator": "~14.0.8", "expo-haptics": "~15.0.8",
"expo-image-picker": "~17.0.10", "expo-image-manipulator": "~14.0.8",
"expo-linking": "~8.0.11", "expo-image-picker": "~17.0.10",
"expo-localization": "~17.0.8", "expo-linking": "~8.0.11",
"expo-notifications": "~0.32.16", "expo-localization": "~17.0.8",
"expo-router": "~6.0.23", "expo-notifications": "~0.32.16",
"expo-secure-store": "~15.0.8", "expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13", "expo-secure-store": "~15.0.8",
"expo-sqlite": "~16.0.10", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9", "expo-sqlite": "~16.0.10",
"expo-updates": "~29.0.16", "expo-status-bar": "~3.0.9",
"expo-video": "~3.0.16", "expo-updates": "~29.0.16",
"posthog-react-native": "^4.37.1", "expo-video": "~3.0.16",
"react": "19.1.0", "posthog-react-native": "^4.37.1",
"react-dom": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5", "react-dom": "19.1.0",
"react-native-gesture-handler": "~2.28.0", "react-native": "0.81.5",
"react-native-purchases": "^9.10.5", "react-native-gesture-handler": "~2.28.0",
"react-native-purchases-ui": "^9.10.5", "react-native-purchases": "^9.10.5",
"react-native-reanimated": "~4.1.1", "react-native-purchases-ui": "^9.10.5",
"react-native-safe-area-context": "~5.6.0", "react-native-reanimated": "~4.1.1",
"react-native-screens": "~4.16.0", "react-native-safe-area-context": "~5.6.0",
"react-native-svg": "^15.12.1", "react-native-screens": "~4.16.0",
"react-native-web": "^0.21.2", "react-native-svg": "^15.12.1",
"react-native-worklets": "0.5.1" "react-native-web": "^0.21.2",
}, "react-native-worklets": "0.5.1"
"devDependencies": { },
"@babel/core": "^7.25.0", "devDependencies": {
"@testing-library/jest-native": "^5.4.3", "@babel/core": "^7.25.0",
"@testing-library/react-native": "^13.3.3", "@testing-library/jest-native": "^5.4.3",
"@types/jest": "^29.5.14", "@testing-library/react-native": "^13.3.3",
"@types/react": "~19.1.0", "@types/jest": "^29.5.14",
"jest": "^29.7.0", "@types/react": "~19.1.0",
"jest-expo": "^54.0.17", "jest": "^29.7.0",
"typescript": "^5.3.0" "jest-expo": "^54.0.17",
} "typescript": "^5.3.0"
} }
}

View File

@@ -26,7 +26,14 @@ loadEnvFiles([
]); ]);
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres'); const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
const { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth'); const {
ensureAuthSchema,
signUp: authSignUp,
login: authLogin,
signInWithApple: authSignInWithApple,
issueToken,
verifyJwt,
} = require('./lib/auth');
const { const {
PlantImportValidationError, PlantImportValidationError,
ensurePlantSchema, ensurePlantSchema,
@@ -168,13 +175,27 @@ const resolveUserId = (request) => {
return ''; return '';
}; };
const resolveIdempotencyKey = (request) => { const resolveIdempotencyKey = (request) => {
const header = request.header('idempotency-key'); const header = request.header('idempotency-key');
if (typeof header === 'string' && header.trim()) return header.trim(); if (typeof header === 'string' && header.trim()) return header.trim();
return ''; return '';
}; };
const toPlantResult = (entry, confidence) => { const createHardPaywallError = (requiredCredits) => {
const error = new Error('Active Pro or trial entitlement required.');
error.code = 'INSUFFICIENT_CREDITS';
error.status = 402;
error.metadata = { required: requiredCredits, available: 0 };
return error;
};
const ensureActiveProEntitlement = (accountSnapshot, requiredCredits) => {
if (!accountSnapshot || accountSnapshot.plan !== 'pro') {
throw createHardPaywallError(requiredCredits);
}
};
const toPlantResult = (entry, confidence) => {
return { return {
name: entry.name, name: entry.name,
botanicalName: entry.botanicalName, botanicalName: entry.botanicalName,
@@ -235,23 +256,37 @@ const toApiErrorPayload = (error) => {
}; };
} }
if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') { if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') {
return { return {
status: 401, status: 401,
body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' }, body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' },
}; };
} }
if (isInsufficientCreditsError(error)) { if (isInsufficientCreditsError(error)) {
return { return {
status: 402, status: 402,
body: { body: {
code: 'INSUFFICIENT_CREDITS', code: 'INSUFFICIENT_CREDITS',
message: error.message || 'Insufficient credits.', message: error.message || 'Insufficient credits.',
details: error.metadata || undefined, details: error.metadata || undefined,
}, },
}; };
} }
if (
error
&& typeof error === 'object'
&& Number.isInteger(error.status)
&& error.status >= 400
&& error.status < 500
&& typeof error.code === 'string'
) {
return {
status: error.status,
body: { code: error.code, message: error.message || 'Request failed.' },
};
}
if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') { if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') {
return { return {
@@ -487,8 +522,9 @@ app.get('/', (_request, response) => {
'GET /health', 'GET /health',
'GET /api/plants', 'GET /api/plants',
'POST /api/plants/rebuild', 'POST /api/plants/rebuild',
'POST /auth/signup', 'POST /auth/signup',
'POST /auth/login', 'POST /auth/login',
'POST /auth/apple',
'GET /v1/billing/summary', 'GET /v1/billing/summary',
'POST /v1/billing/sync-revenuecat', 'POST /v1/billing/sync-revenuecat',
'POST /v1/scan', 'POST /v1/scan',
@@ -642,14 +678,17 @@ app.post('/v1/scan', async (request, response) => {
let modelUsed = null; let modelUsed = null;
let modelFallbackCount = 0; let modelFallbackCount = 0;
const [creditResult, accountSnapshot, catalogEntries] = await Promise.all([ const [accountSnapshot, catalogEntries] = await Promise.all([
isGuest(userId) getAccountSnapshot(db, userId),
? Promise.resolve(0) getCachedCatalogEntries(db),
: consumeCreditsWithIdempotency(db, userId, chargeKey('scan-primary', userId, idempotencyKey), SCAN_PRIMARY_COST), ]);
getAccountSnapshot(db, userId), ensureActiveProEntitlement(accountSnapshot, SCAN_PRIMARY_COST);
getCachedCatalogEntries(db), creditsCharged += await consumeCreditsWithIdempotency(
]); db,
creditsCharged += creditResult; userId,
chargeKey('scan-primary', userId, idempotencyKey),
SCAN_PRIMARY_COST,
);
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free'; const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true }); let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
@@ -785,21 +824,24 @@ app.post('/v1/search/semantic', async (request, response) => {
return; return;
} }
if (!query) { if (!query) {
const payload = { const payload = {
status: 'no_results', status: 'no_results',
results: [], results: [],
creditsCharged: 0, creditsCharged: 0,
billing: await getBillingSummary(db, userId), billing: await getBillingSummary(db, userId),
}; };
await storeEndpointResponse(db, endpointId, payload); await storeEndpointResponse(db, endpointId, payload);
response.status(200).json(payload); response.status(200).json(payload);
return; return;
} }
const creditsCharged = await consumeCreditsWithIdempotency( const accountSnapshot = await getAccountSnapshot(db, userId);
db, ensureActiveProEntitlement(accountSnapshot, SEMANTIC_SEARCH_COST);
userId,
const creditsCharged = await consumeCreditsWithIdempotency(
db,
userId,
chargeKey('semantic-search', userId, idempotencyKey), chargeKey('semantic-search', userId, idempotencyKey),
SEMANTIC_SEARCH_COST, SEMANTIC_SEARCH_COST,
); );
@@ -831,11 +873,14 @@ app.post('/v1/health-check', async (request, response) => {
const cached = await getEndpointResponse(db, endpointId); const cached = await getEndpointResponse(db, endpointId);
if (cached) { if (cached) {
response.status(200).json(cached); response.status(200).json(cached);
return; return;
} }
if (!isOpenAiConfigured()) { const accountSnapshot = await getAccountSnapshot(db, userId);
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.'); ensureActiveProEntitlement(accountSnapshot, HEALTH_CHECK_COST);
if (!isOpenAiConfigured()) {
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
error.code = 'PROVIDER_ERROR'; error.code = 'PROVIDER_ERROR';
throw error; throw error;
} }
@@ -998,9 +1043,9 @@ app.post('/auth/signup', async (request, response) => {
} }
}); });
app.post('/auth/login', async (request, response) => { app.post('/auth/login', async (request, response) => {
try { try {
const { email, password } = request.body || {}; const { email, password } = request.body || {};
if (!email || !password) { if (!email || !password) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'email and password are required.' }); return response.status(400).json({ code: 'BAD_REQUEST', message: 'email and password are required.' });
} }
@@ -1010,8 +1055,23 @@ app.post('/auth/login', async (request, response) => {
} catch (error) { } catch (error) {
const status = error.status || 500; const status = error.status || 500;
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
} }
}); });
app.post('/auth/apple', async (request, response) => {
try {
const { identityToken, appleUser, email, name } = request.body || {};
if (!identityToken) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'identityToken is required.' });
}
const user = await authSignInWithApple(db, identityToken, { appleUser, email, name });
const token = issueToken(user.id, user.email, user.name);
response.status(200).json({ userId: user.id, email: user.email, name: user.name, token });
} catch (error) {
const status = error.status || 500;
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
}
});
// ─── Startup ─────────────────────────────────────────────────────────────── // ─── Startup ───────────────────────────────────────────────────────────────

View File

@@ -1,8 +1,18 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { get, run } = require('./postgres'); const { get, run } = require('./postgres');
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod'; const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys';
const APPLE_ISSUER = 'https://appleid.apple.com';
const APPLE_AUDIENCE = (
process.env.APPLE_CLIENT_ID
|| process.env.APPLE_BUNDLE_ID
|| process.env.EXPO_PUBLIC_APPLE_CLIENT_ID
|| process.env.IOS_BUNDLE_ID
|| 'com.greenlens.app'
).trim();
let appleJwksCache = { keys: [], expiresAt: 0 };
// ─── Minimal JWT (HS256, no external deps) ───────────────────────────────── // ─── Minimal JWT (HS256, no external deps) ─────────────────────────────────
@@ -47,8 +57,100 @@ const issueToken = (userId, email, name) =>
// ─── Password hashing ────────────────────────────────────────────────────── // ─── Password hashing ──────────────────────────────────────────────────────
const hashPassword = (password) => const hashPassword = (password) =>
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex'); crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
const parseJwtPart = (value) => JSON.parse(b64urlDecode(value));
const getAppleJwks = async () => {
const now = Date.now();
if (appleJwksCache.keys.length > 0 && appleJwksCache.expiresAt > now) {
return appleJwksCache.keys;
}
const response = await fetch(APPLE_JWKS_URL);
if (!response.ok) {
const error = new Error('Could not load Apple public keys.');
error.code = 'APPLE_AUTH_UNAVAILABLE';
error.status = 503;
throw error;
}
const payload = await response.json();
appleJwksCache = {
keys: Array.isArray(payload.keys) ? payload.keys : [],
expiresAt: now + 6 * 60 * 60 * 1000,
};
return appleJwksCache.keys;
};
const verifyAppleIdentityToken = async (identityToken) => {
if (!identityToken || typeof identityToken !== 'string') {
const error = new Error('Apple identityToken is required.');
error.code = 'BAD_REQUEST';
error.status = 400;
throw error;
}
const parts = identityToken.split('.');
if (parts.length !== 3) {
const error = new Error('Apple identityToken is malformed.');
error.code = 'APPLE_AUTH_INVALID';
error.status = 401;
throw error;
}
const [encodedHeader, encodedPayload, encodedSignature] = parts;
let header;
let claims;
try {
header = parseJwtPart(encodedHeader);
claims = parseJwtPart(encodedPayload);
} catch {
const error = new Error('Apple identityToken is malformed.');
error.code = 'APPLE_AUTH_INVALID';
error.status = 401;
throw error;
}
if (header.alg !== 'RS256' || !header.kid) {
const error = new Error('Apple identityToken has an unsupported signature.');
error.code = 'APPLE_AUTH_INVALID';
error.status = 401;
throw error;
}
const keys = await getAppleJwks();
const jwk = keys.find((key) => key.kid === header.kid);
if (!jwk) {
const error = new Error('Apple public key not found.');
error.code = 'APPLE_AUTH_INVALID';
error.status = 401;
throw error;
}
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(`${encodedHeader}.${encodedPayload}`);
verifier.end();
const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });
const validSignature = verifier.verify(publicKey, Buffer.from(encodedSignature, 'base64url'));
const nowSeconds = Math.floor(Date.now() / 1000);
const expectedAudiences = new Set([APPLE_AUDIENCE, 'com.greenlens.app'].filter(Boolean));
if (
!validSignature
|| claims.iss !== APPLE_ISSUER
|| !expectedAudiences.has(claims.aud)
|| !claims.sub
|| (claims.exp && nowSeconds > Number(claims.exp))
) {
const error = new Error('Apple identityToken could not be verified.');
error.code = 'APPLE_AUTH_INVALID';
error.status = 401;
throw error;
}
return claims;
};
// ─── Schema ──────────────────────────────────────────────────────────────── // ─── Schema ────────────────────────────────────────────────────────────────
@@ -59,10 +161,22 @@ const ensureAuthSchema = async (db) => {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL, password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'email',
apple_subject TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`, )`,
); );
await run(db, "ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS auth_provider TEXT NOT NULL DEFAULT 'email'");
await run(db, 'ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS apple_subject TEXT');
await run(db, 'ALTER TABLE auth_users ALTER COLUMN password_hash DROP NOT NULL');
await run(
db,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_users_apple_subject
ON auth_users (apple_subject)
WHERE apple_subject IS NOT NULL`,
);
}; };
// ─── Operations ─────────────────────────────────────────────────────────── // ─── Operations ───────────────────────────────────────────────────────────
@@ -98,14 +212,68 @@ const login = async (db, email, password) => {
err.code = 'USER_NOT_FOUND'; err.code = 'USER_NOT_FOUND';
err.status = 401; err.status = 401;
throw err; throw err;
} }
if (user.password_hash !== hashPassword(password)) { if (!user.password_hash) {
const err = new Error('This account uses Apple Sign-In.');
err.code = 'USE_APPLE_LOGIN';
err.status = 401;
throw err;
}
if (user.password_hash !== hashPassword(password)) {
const err = new Error('Wrong password.'); const err = new Error('Wrong password.');
err.code = 'WRONG_PASSWORD'; err.code = 'WRONG_PASSWORD';
err.status = 401; err.status = 401;
throw err; throw err;
} }
return { id: user.id, email: user.email, name: user.name }; return { id: user.id, email: user.email, name: user.name };
}; };
module.exports = { ensureAuthSchema, signUp, login, issueToken, verifyJwt }; const signInWithApple = async (db, identityToken, profile = {}) => {
const claims = await verifyAppleIdentityToken(identityToken);
const appleSubject = String(claims.sub);
const emailFromToken = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : '';
const emailFromProfile = typeof profile.email === 'string' ? profile.email.trim().toLowerCase() : '';
const normalizedEmail = emailFromToken || emailFromProfile;
const profileName = typeof profile.name === 'string' ? profile.name.trim() : '';
const existingByApple = await get(
db,
'SELECT id, email, name FROM auth_users WHERE apple_subject = $1',
[appleSubject],
);
if (existingByApple) return existingByApple;
if (!normalizedEmail) {
const err = new Error('Apple did not return an email for this account.');
err.code = 'APPLE_EMAIL_MISSING';
err.status = 400;
throw err;
}
const existingByEmail = await get(
db,
'SELECT id, email, name FROM auth_users WHERE LOWER(email) = LOWER($1)',
[normalizedEmail],
);
if (existingByEmail) {
const nextName = existingByEmail.name || profileName || normalizedEmail.split('@')[0] || 'GreenLens User';
await run(
db,
'UPDATE auth_users SET apple_subject = $1, auth_provider = $2, name = $3 WHERE id = $4',
[appleSubject, 'apple', nextName, existingByEmail.id],
);
return { ...existingByEmail, name: nextName };
}
const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
const name = profileName || normalizedEmail.split('@')[0] || 'GreenLens User';
await run(
db,
`INSERT INTO auth_users (id, email, name, password_hash, auth_provider, apple_subject)
VALUES ($1, $2, $3, NULL, $4, $5)`,
[id, normalizedEmail, name, 'apple', appleSubject],
);
return { id, email: normalizedEmail, name };
};
module.exports = { ensureAuthSchema, signUp, login, signInWithApple, issueToken, verifyJwt, verifyAppleIdentityToken };

View File

@@ -1,17 +1,18 @@
const { get, run } = require('./postgres'); const { get, run } = require('./postgres');
const FREE_MONTHLY_CREDITS = 15; const FREE_MONTHLY_CREDITS = 0;
const PRO_MONTHLY_CREDITS = 250; const TRIAL_MONTHLY_CREDITS = 30;
const TOPUP_DEFAULT_CREDITS = 60; const PRO_MONTHLY_CREDITS = 100;
const TOPUP_DEFAULT_CREDITS = 100;
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro'; const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
const SUPPORTED_SUBSCRIPTION_PRODUCTS = new Set(['monthly_pro', 'yearly_pro']); const SUPPORTED_SUBSCRIPTION_PRODUCTS = new Set(['monthly_pro', 'yearly_pro']);
const TOPUP_CREDITS_BY_PRODUCT = { const TOPUP_CREDITS_BY_PRODUCT = {
monthly_pro: 0, monthly_pro: 0,
yearly_pro: 0, yearly_pro: 0,
topup_small: 25, topup_small: 30,
topup_medium: 120, topup_medium: 100,
topup_large: 300, topup_large: 250,
}; };
const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large']; const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'];
@@ -64,6 +65,22 @@ const getMonthlyAllowanceForPlan = (plan) => {
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS; return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
}; };
const getRevenueCatPeriodType = (source) => {
return String(source?.periodType || source?.period_type || '').trim().toLowerCase();
};
const isRevenueCatTrial = (source) => {
return getRevenueCatPeriodType(source) === 'trial';
};
const isAllowedMonthlyAllowance = (account) => {
if (account.plan === 'pro') {
return account.monthlyAllowance === PRO_MONTHLY_CREDITS
|| account.monthlyAllowance === TRIAL_MONTHLY_CREDITS;
}
return account.monthlyAllowance === FREE_MONTHLY_CREDITS;
};
const createInsufficientCreditsError = (required, available) => { const createInsufficientCreditsError = (required, available) => {
const error = new Error(`Insufficient credits. Required ${required}, available ${available}.`); const error = new Error(`Insufficient credits. Required ${required}, available ${available}.`);
error.code = 'INSUFFICIENT_CREDITS'; error.code = 'INSUFFICIENT_CREDITS';
@@ -130,7 +147,7 @@ const buildDefaultAccount = (userId, now) => {
const alignAccountToCurrentCycle = (account, now) => { const alignAccountToCurrentCycle = (account, now) => {
const next = { ...account }; const next = { ...account };
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan); const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan);
if (next.monthlyAllowance !== expectedMonthlyAllowance) { if (!isAllowedMonthlyAllowance(next)) {
next.monthlyAllowance = expectedMonthlyAllowance; next.monthlyAllowance = expectedMonthlyAllowance;
} }
@@ -238,6 +255,7 @@ const getOrCreateAccount = async (db, userId) => {
}; };
const getAvailableCredits = (account) => { const getAvailableCredits = (account) => {
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);
}; };
@@ -326,14 +344,22 @@ const getValidProEntitlement = (customerInfo) => {
const applyRevenueCatEntitlementState = (account, options) => { const applyRevenueCatEntitlementState = (account, options) => {
const now = new Date(); const now = new Date();
const previousPlan = account.plan;
const previousMonthlyAllowance = account.monthlyAllowance;
const nextPlan = options.active ? 'pro' : 'free'; const nextPlan = options.active ? 'pro' : 'free';
const planChanged = account.plan !== nextPlan; const nextMonthlyAllowance = options.active && options.isTrial
? TRIAL_MONTHLY_CREDITS
: getMonthlyAllowanceForPlan(nextPlan);
const planChanged = previousPlan !== nextPlan;
const trialConvertedToPaid = previousPlan === 'pro'
&& previousMonthlyAllowance === TRIAL_MONTHLY_CREDITS
&& nextMonthlyAllowance === PRO_MONTHLY_CREDITS;
account.plan = nextPlan; account.plan = nextPlan;
account.provider = 'revenuecat'; account.provider = 'revenuecat';
account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan); account.monthlyAllowance = nextMonthlyAllowance;
account.renewsAt = options.active ? options.renewsAt || account.renewsAt || addDays(now, 30).toISOString() : null; account.renewsAt = options.active ? options.renewsAt || account.renewsAt || addDays(now, 30).toISOString() : null;
if (planChanged) { 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();
@@ -401,6 +427,7 @@ const syncRevenueCatCustomerInfo = async (db, userId, customerInfo, options = {}
if (source !== 'topup_purchase') { if (source !== 'topup_purchase') {
applyRevenueCatEntitlementState(account, { applyRevenueCatEntitlementState(account, {
active: Boolean(proEntitlement), active: Boolean(proEntitlement),
isTrial: isRevenueCatTrial(proEntitlement),
renewsAt: asIsoDate(proEntitlement?.expirationDate || proEntitlement?.expiresDate), renewsAt: asIsoDate(proEntitlement?.expirationDate || proEntitlement?.expiresDate),
}); });
} }
@@ -490,6 +517,7 @@ const syncRevenueCatWebhookEvent = async (db, eventPayload) => {
if (affectsProEntitlement && shouldGrantRevenueCatSubscription(eventType)) { if (affectsProEntitlement && shouldGrantRevenueCatSubscription(eventType)) {
applyRevenueCatEntitlementState(account, { applyRevenueCatEntitlementState(account, {
active: true, active: true,
isTrial: isRevenueCatTrial(eventPayload),
renewsAt: asIsoDate(eventPayload?.expiration_at_ms || eventPayload?.expiration_at), renewsAt: asIsoDate(eventPayload?.expiration_at_ms || eventPayload?.expiration_at),
}); });
} else if (affectsProEntitlement && shouldRevokeRevenueCatSubscription(eventType)) { } else if (affectsProEntitlement && shouldRevokeRevenueCatSubscription(eventType)) {
@@ -519,6 +547,9 @@ const syncRevenueCatWebhookEvent = async (db, eventPayload) => {
const consumeCredits = (account, cost) => { const consumeCredits = (account, cost) => {
if (cost <= 0) return 0; if (cost <= 0) return 0;
if (account.plan !== 'pro') {
throw createInsufficientCreditsError(cost, 0);
}
const available = getAvailableCredits(account); const available = getAvailableCredits(account);
if (available < cost) { if (available < cost) {
@@ -567,12 +598,12 @@ const consumeCreditsWithIdempotency = async (db, userId, key, cost) => {
const getBillingSummary = async (db, userId) => { const getBillingSummary = async (db, userId) => {
if (userId === 'guest') { if (userId === 'guest') {
return { return {
entitlement: { plan: 'free', provider: 'mock', status: 'active', renewsAt: null }, entitlement: { plan: 'free', provider: 'mock', status: 'inactive', renewsAt: null },
credits: { credits: {
monthlyAllowance: 5, monthlyAllowance: 0,
usedThisCycle: 0, usedThisCycle: 0,
topupBalance: 0, topupBalance: 0,
available: 5, available: 0,
cycleStartedAt: nowIso(), cycleStartedAt: nowIso(),
cycleEndsAt: nowIso(), cycleEndsAt: nowIso(),
}, },
@@ -595,7 +626,7 @@ const getAccountSnapshot = async (db, userId) => {
provider: 'mock', provider: 'mock',
cycleStartedAt: nowIso(), cycleStartedAt: nowIso(),
cycleEndsAt: nowIso(), cycleEndsAt: nowIso(),
monthlyAllowance: 5, monthlyAllowance: 0,
usedThisCycle: 0, usedThisCycle: 0,
topupBalance: 0, topupBalance: 0,
renewsAt: null, renewsAt: null,

View File

@@ -36,11 +36,14 @@ const authPost = async (path: string, body: object): Promise<{ userId: string; e
} }
throw new Error('NETWORK_ERROR'); throw new Error('NETWORK_ERROR');
} }
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
const code = (data as any).code || 'AUTH_ERROR'; if (response.status === 404 && path === '/auth/apple') {
const msg = (data as any).message || ''; throw new Error('APPLE_BACKEND_UNAVAILABLE');
console.warn(`[Auth] ${path} failed:`, response.status, code, msg); }
const code = (data as any).code || 'AUTH_ERROR';
const msg = (data as any).message || '';
console.warn(`[Auth] ${path} failed:`, response.status, code, msg);
throw new Error(code); throw new Error(code);
} }
return data as any; return data as any;
@@ -84,14 +87,26 @@ export const AuthService = {
return session; return session;
}, },
async login(email: string, password: string): Promise<AuthSession> { async login(email: string, password: string): Promise<AuthSession> {
const data = await authPost('/auth/login', { email, password }); const data = await authPost('/auth/login', { email, password });
const session = buildSession(data); const session = buildSession(data);
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session)); await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
return session; return session;
}, },
async logout(): Promise<void> { async signInWithApple(params: {
identityToken: string;
appleUser?: string | null;
email?: string | null;
name?: string | null;
}): Promise<AuthSession> {
const data = await authPost('/auth/apple', params);
const session = buildSession(data);
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
return session;
},
async logout(): Promise<void> {
await clearStoredSession(); await clearStoredSession();
}, },

View File

@@ -37,11 +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;
period_type?: string | null;
}
export interface RevenueCatNonSubscriptionTransaction { export interface RevenueCatNonSubscriptionTransaction {
productIdentifier?: string; productIdentifier?: string;

View File

@@ -29,9 +29,10 @@ 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 = 15; const FREE_MONTHLY_CREDITS = 0;
const GUEST_TRIAL_CREDITS = 5; const GUEST_TRIAL_CREDITS = 0;
const PRO_MONTHLY_CREDITS = 250; const TRIAL_MONTHLY_CREDITS = 30;
const PRO_MONTHLY_CREDITS = 100;
const SCAN_PRIMARY_COST = 1; const SCAN_PRIMARY_COST = 1;
const SCAN_REVIEW_COST = 1; const SCAN_REVIEW_COST = 1;
@@ -42,14 +43,14 @@ const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
const FREE_SIMULATED_DELAY_MS = 1100; const FREE_SIMULATED_DELAY_MS = 1100;
const PRO_SIMULATED_DELAY_MS = 280; const PRO_SIMULATED_DELAY_MS = 280;
const TOPUP_DEFAULT_CREDITS = 60; 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: 25, topup_small: 30,
topup_medium: 120, topup_medium: 100,
topup_large: 300, 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';
@@ -100,10 +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 => {
return String(source?.periodType || source?.period_type || '').trim().toLowerCase();
};
const isRevenueCatTrial = (source?: RevenueCatEntitlementInfo | null): boolean => {
return getRevenueCatPeriodType(source) === 'trial';
};
const isAllowedMonthlyAllowance = (account: MockAccountRecord): boolean => {
if (account.userId === 'guest') return account.monthlyAllowance === GUEST_TRIAL_CREDITS;
if (account.plan === 'pro') {
return account.monthlyAllowance === PRO_MONTHLY_CREDITS
|| account.monthlyAllowance === TRIAL_MONTHLY_CREDITS;
}
return account.monthlyAllowance === FREE_MONTHLY_CREDITS;
};
const getSimulatedDelay = (plan: PlanId): number => { 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;
@@ -185,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 (next.monthlyAllowance !== expectedMonthlyAllowance) { 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();
@@ -215,10 +233,11 @@ const getOrCreateAccount = (stores: { accounts: AccountStore }, userId: string):
return aligned; return aligned;
}; };
const getAvailableCredits = (account: MockAccountRecord): number => { const getAvailableCredits = (account: MockAccountRecord): number => {
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle); if (account.plan !== 'pro') return 0;
return monthlyRemaining + Math.max(0, account.topupBalance); const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
}; return monthlyRemaining + Math.max(0, account.topupBalance);
};
const buildBillingSummary = (account: MockAccountRecord): BillingSummary => { const buildBillingSummary = (account: MockAccountRecord): BillingSummary => {
return { return {
@@ -296,10 +315,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') {
const available = getAvailableCredits(account); throw new BackendApiError(
'INSUFFICIENT_CREDITS',
`Insufficient credits. Required ${cost}, available 0.`,
402,
{ required: cost, available: 0 },
);
}
const available = getAvailableCredits(account);
if (available < cost) { if (available < cost) {
throw new BackendApiError( throw new BackendApiError(
'INSUFFICIENT_CREDITS', 'INSUFFICIENT_CREDITS',
@@ -323,8 +350,18 @@ const consumeCredits = (account: MockAccountRecord, cost: number): number => {
remaining -= topupUsage; remaining -= topupUsage;
} }
return cost; return cost;
}; };
const ensureActiveProEntitlement = (account: MockAccountRecord, requiredCredits: number): void => {
if (account.plan === 'pro') return;
throw new BackendApiError(
'INSUFFICIENT_CREDITS',
`Insufficient credits. Required ${requiredCredits}, available 0.`,
402,
{ required: requiredCredits, available: 0 },
);
};
const consumeCreditsWithIdempotency = ( const consumeCreditsWithIdempotency = (
account: MockAccountRecord, account: MockAccountRecord,
@@ -705,10 +742,29 @@ export const mockBackendService = {
}); });
if (source !== 'topup_purchase') { if (source !== 'topup_purchase') {
account.plan = proEntitlement ? 'pro' : 'free'; const now = new Date();
const previousPlan = account.plan;
const previousMonthlyAllowance = account.monthlyAllowance;
const nextPlan = proEntitlement ? 'pro' : 'free';
const nextMonthlyAllowance = proEntitlement && isRevenueCatTrial(proEntitlement)
? TRIAL_MONTHLY_CREDITS
: getMonthlyAllowanceForPlan(nextPlan, account.userId);
const planChanged = previousPlan !== nextPlan;
const trialConvertedToPaid = previousPlan === 'pro'
&& previousMonthlyAllowance === TRIAL_MONTHLY_CREDITS
&& nextMonthlyAllowance === PRO_MONTHLY_CREDITS;
account.plan = nextPlan;
account.provider = 'revenuecat'; account.provider = 'revenuecat';
account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan, account.userId); account.monthlyAllowance = nextMonthlyAllowance;
account.renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null; account.renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null;
if (planChanged || trialConvertedToPaid) {
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
account.cycleStartedAt = cycleStartedAt.toISOString();
account.cycleEndsAt = cycleEndsAt.toISOString();
account.usedThisCycle = 0;
}
} }
for (const transaction of normalizeRevenueCatTransactions(request.customerInfo)) { for (const transaction of normalizeRevenueCatTransactions(request.customerInfo)) {
@@ -916,12 +972,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);
} }
if (!openAiScanService.isConfigured()) { ensureActiveProEntitlement(account, HEALTH_CHECK_COST);
throw new BackendApiError(
if (!openAiScanService.isConfigured()) {
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,