Hard paywall
This commit is contained in:
@@ -36,11 +36,14 @@ const authPost = async (path: string, body: object): Promise<{ userId: string; e
|
||||
}
|
||||
throw new Error('NETWORK_ERROR');
|
||||
}
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const code = (data as any).code || 'AUTH_ERROR';
|
||||
const msg = (data as any).message || '';
|
||||
console.warn(`[Auth] ${path} failed:`, response.status, code, msg);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
if (response.status === 404 && path === '/auth/apple') {
|
||||
throw new Error('APPLE_BACKEND_UNAVAILABLE');
|
||||
}
|
||||
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);
|
||||
}
|
||||
return data as any;
|
||||
@@ -84,14 +87,26 @@ export const AuthService = {
|
||||
return session;
|
||||
},
|
||||
|
||||
async login(email: string, password: string): Promise<AuthSession> {
|
||||
const data = await authPost('/auth/login', { email, password });
|
||||
const session = buildSession(data);
|
||||
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
|
||||
return session;
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
async login(email: string, password: string): Promise<AuthSession> {
|
||||
const data = await authPost('/auth/login', { email, password });
|
||||
const session = buildSession(data);
|
||||
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
|
||||
return session;
|
||||
},
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
|
||||
@@ -37,11 +37,13 @@ export interface BillingSummary {
|
||||
availableProducts: PurchaseProductId[];
|
||||
}
|
||||
|
||||
export interface RevenueCatEntitlementInfo {
|
||||
productIdentifier?: string;
|
||||
expirationDate?: string | null;
|
||||
expiresDate?: string | null;
|
||||
}
|
||||
export interface RevenueCatEntitlementInfo {
|
||||
productIdentifier?: string;
|
||||
expirationDate?: string | null;
|
||||
expiresDate?: string | null;
|
||||
periodType?: string | null;
|
||||
period_type?: string | null;
|
||||
}
|
||||
|
||||
export interface RevenueCatNonSubscriptionTransaction {
|
||||
productIdentifier?: string;
|
||||
|
||||
@@ -29,9 +29,10 @@ import { IdentificationResult, PlantHealthCheck } from '../../types';
|
||||
const MOCK_ACCOUNT_STORE_KEY = 'greenlens_mock_backend_accounts_v1';
|
||||
const MOCK_IDEMPOTENCY_STORE_KEY = 'greenlens_mock_backend_idempotency_v1';
|
||||
|
||||
const FREE_MONTHLY_CREDITS = 15;
|
||||
const GUEST_TRIAL_CREDITS = 5;
|
||||
const PRO_MONTHLY_CREDITS = 250;
|
||||
const FREE_MONTHLY_CREDITS = 0;
|
||||
const GUEST_TRIAL_CREDITS = 0;
|
||||
const TRIAL_MONTHLY_CREDITS = 30;
|
||||
const PRO_MONTHLY_CREDITS = 100;
|
||||
|
||||
const SCAN_PRIMARY_COST = 1;
|
||||
const SCAN_REVIEW_COST = 1;
|
||||
@@ -42,14 +43,14 @@ const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
|
||||
const FREE_SIMULATED_DELAY_MS = 1100;
|
||||
const PRO_SIMULATED_DELAY_MS = 280;
|
||||
|
||||
const TOPUP_DEFAULT_CREDITS = 60;
|
||||
const TOPUP_DEFAULT_CREDITS = 100;
|
||||
|
||||
const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
|
||||
monthly_pro: 0,
|
||||
yearly_pro: 0,
|
||||
topup_small: 25,
|
||||
topup_medium: 120,
|
||||
topup_large: 300,
|
||||
topup_small: 30,
|
||||
topup_medium: 100,
|
||||
topup_large: 250,
|
||||
};
|
||||
|
||||
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
|
||||
@@ -100,10 +101,27 @@ const getCycleBounds = (now: Date) => {
|
||||
return { cycleStartedAt, cycleEndsAt };
|
||||
};
|
||||
|
||||
const getMonthlyAllowanceForPlan = (plan: PlanId, userId?: string): number => {
|
||||
if (userId === 'guest') return GUEST_TRIAL_CREDITS;
|
||||
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
||||
};
|
||||
const getMonthlyAllowanceForPlan = (plan: PlanId, userId?: string): number => {
|
||||
if (userId === 'guest') return GUEST_TRIAL_CREDITS;
|
||||
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
||||
};
|
||||
|
||||
const getRevenueCatPeriodType = (source?: RevenueCatEntitlementInfo | null): string => {
|
||||
return String(source?.periodType || source?.period_type || '').trim().toLowerCase();
|
||||
};
|
||||
|
||||
const isRevenueCatTrial = (source?: RevenueCatEntitlementInfo | null): boolean => {
|
||||
return getRevenueCatPeriodType(source) === 'trial';
|
||||
};
|
||||
|
||||
const isAllowedMonthlyAllowance = (account: MockAccountRecord): boolean => {
|
||||
if (account.userId === 'guest') return account.monthlyAllowance === GUEST_TRIAL_CREDITS;
|
||||
if (account.plan === 'pro') {
|
||||
return account.monthlyAllowance === PRO_MONTHLY_CREDITS
|
||||
|| account.monthlyAllowance === TRIAL_MONTHLY_CREDITS;
|
||||
}
|
||||
return account.monthlyAllowance === FREE_MONTHLY_CREDITS;
|
||||
};
|
||||
|
||||
const getSimulatedDelay = (plan: PlanId): number => {
|
||||
return plan === 'pro' ? PRO_SIMULATED_DELAY_MS : FREE_SIMULATED_DELAY_MS;
|
||||
@@ -185,11 +203,11 @@ const buildDefaultAccount = (userId: string, now: Date): MockAccountRecord => {
|
||||
};
|
||||
|
||||
const alignAccountToCurrentCycle = (account: MockAccountRecord, now: Date): MockAccountRecord => {
|
||||
const next = { ...account };
|
||||
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan, next.userId);
|
||||
if (next.monthlyAllowance !== expectedMonthlyAllowance) {
|
||||
next.monthlyAllowance = expectedMonthlyAllowance;
|
||||
}
|
||||
const next = { ...account };
|
||||
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan, next.userId);
|
||||
if (!isAllowedMonthlyAllowance(next)) {
|
||||
next.monthlyAllowance = expectedMonthlyAllowance;
|
||||
}
|
||||
|
||||
if (!next.renewsAt && next.plan === 'pro' && next.provider === 'mock') {
|
||||
next.renewsAt = addDays(now, 30).toISOString();
|
||||
@@ -215,10 +233,11 @@ const getOrCreateAccount = (stores: { accounts: AccountStore }, userId: string):
|
||||
return aligned;
|
||||
};
|
||||
|
||||
const getAvailableCredits = (account: MockAccountRecord): number => {
|
||||
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
||||
return monthlyRemaining + Math.max(0, account.topupBalance);
|
||||
};
|
||||
const getAvailableCredits = (account: MockAccountRecord): number => {
|
||||
if (account.plan !== 'pro') return 0;
|
||||
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
||||
return monthlyRemaining + Math.max(0, account.topupBalance);
|
||||
};
|
||||
|
||||
const buildBillingSummary = (account: MockAccountRecord): BillingSummary => {
|
||||
return {
|
||||
@@ -296,10 +315,18 @@ const writeIdempotentResponse = <T,>(store: IdempotencyStore, key: string, value
|
||||
};
|
||||
};
|
||||
|
||||
const consumeCredits = (account: MockAccountRecord, cost: number): number => {
|
||||
if (cost <= 0) return 0;
|
||||
|
||||
const available = getAvailableCredits(account);
|
||||
const consumeCredits = (account: MockAccountRecord, cost: number): number => {
|
||||
if (cost <= 0) return 0;
|
||||
if (account.plan !== 'pro') {
|
||||
throw new BackendApiError(
|
||||
'INSUFFICIENT_CREDITS',
|
||||
`Insufficient credits. Required ${cost}, available 0.`,
|
||||
402,
|
||||
{ required: cost, available: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
const available = getAvailableCredits(account);
|
||||
if (available < cost) {
|
||||
throw new BackendApiError(
|
||||
'INSUFFICIENT_CREDITS',
|
||||
@@ -323,8 +350,18 @@ const consumeCredits = (account: MockAccountRecord, cost: number): number => {
|
||||
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 = (
|
||||
account: MockAccountRecord,
|
||||
@@ -705,10 +742,29 @@ export const mockBackendService = {
|
||||
});
|
||||
|
||||
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.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan, account.userId);
|
||||
account.monthlyAllowance = nextMonthlyAllowance;
|
||||
account.renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null;
|
||||
|
||||
if (planChanged || trialConvertedToPaid) {
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
account.cycleStartedAt = cycleStartedAt.toISOString();
|
||||
account.cycleEndsAt = cycleEndsAt.toISOString();
|
||||
account.usedThisCycle = 0;
|
||||
}
|
||||
}
|
||||
|
||||
for (const transaction of normalizeRevenueCatTransactions(request.customerInfo)) {
|
||||
@@ -916,12 +972,14 @@ export const mockBackendService = {
|
||||
}
|
||||
|
||||
const normalizedImageUri = request.imageUri.trim();
|
||||
if (!normalizedImageUri) {
|
||||
throw new BackendApiError('BAD_REQUEST', 'Health check requires an image URI.', 400);
|
||||
}
|
||||
|
||||
if (!openAiScanService.isConfigured()) {
|
||||
throw new BackendApiError(
|
||||
if (!normalizedImageUri) {
|
||||
throw new BackendApiError('BAD_REQUEST', 'Health check requires an image URI.', 400);
|
||||
}
|
||||
|
||||
ensureActiveProEntitlement(account, HEALTH_CHECK_COST);
|
||||
|
||||
if (!openAiScanService.isConfigured()) {
|
||||
throw new BackendApiError(
|
||||
'PROVIDER_ERROR',
|
||||
'OpenAI health check is unavailable. Please configure EXPO_PUBLIC_OPENAI_API_KEY.',
|
||||
502,
|
||||
|
||||
Reference in New Issue
Block a user