diff --git a/docs/payments/receipt-validation-flow.md b/docs/payments/receipt-validation-flow.md new file mode 100644 index 0000000..a4b9b91 --- /dev/null +++ b/docs/payments/receipt-validation-flow.md @@ -0,0 +1,113 @@ +# Receipt Validation Flow + +All in-app purchase receipts are validated by the TeachLink backend before local subscription state is updated. Client-side validation is not performed at any point. + +## Why server-side validation is required + +The platform SDKs (StoreKit on iOS, Google Play Billing on Android) return a receipt or purchase token to the client. Without server-side verification, an attacker can: + +- **Replay a valid receipt** from a real purchase across multiple accounts +- **Tamper with the purchase response** (via a proxy or patched binary) to inject a successful status +- **Use tools like Freedom (Android) or iAP Cracker (iOS)** to return fake successful purchases that the app cannot distinguish from real ones at the JS layer + +Server validation is the only authoritative check. Apple's `/verifyReceipt` endpoint and Google's Play Developer API both have access to the full purchase history and can detect replay, revocation, and fraud. + +## Flow diagram + +``` +User taps "Subscribe" + │ + ▼ +IAP.requestSubscription() ←── opens native payment sheet + │ + ▼ (purchase approved by platform) +purchaseUpdatedListener fires with { transactionReceipt } + │ + ├─ receiptValidationPending === true? → skip (duplicate guard) + │ + ▼ +setReceiptValidationPending(true) + │ + ▼ +POST /api/payments/validate-receipt + { receipt, platform, productId } + │ + ┌────┴────────────────────────────────────┐ + │ network error? │ + │ retry with exponential back-off │ + │ attempt 1 → 2 → 3 → 4 (1s, 2s, 4s) │ + └────┬────────────────────────────────────┘ + │ + ┌────┴──────────────────────────┐ + │ server responds │ + ├─ valid: true ────────────────┤ + │ finishTransaction() │ + │ setSubscriptionTier(tier) │ + │ AsyncStorage.setItem(tier) │ + ├─ valid: false ────────────────┤ + │ log rejection error │ + │ do NOT finishTransaction │ + ├─ network error after 4 tries ─┤ + │ log network error │ + │ do NOT finishTransaction │ + └───────────────────────────────┘ + │ + ▼ +setReceiptValidationPending(false) +``` + +## Key guarantees + +| Property | Implementation | +|---|---| +| No client-only acceptance | `validateReceipt` throws on any error; the fallback mock has been removed | +| No double-processing | `receiptValidationPending` flag checked at listener entry | +| Network resilience | Up to 3 retries with exponential back-off (1 s, 2 s, 4 s); non-network errors (4xx / 5xx) are not retried | +| Idempotent server side | The server identifies receipts by `transactionId`; re-submission of the same receipt returns the same result | +| State only updated on confirmation | `setSubscriptionTier` and `finishTransaction` are called only inside the `result.valid === true` branch | + +## API contract + +### Request + +``` +POST /api/payments/validate-receipt +Content-Type: application/json +Authorization: Bearer + +{ + "receipt": "", + "platform": "ios" | "android", + "productId": "com.teachlink.subscription.pro.monthly" // optional +} +``` + +### Success response + +```json +{ + "valid": true, + "tier": "pro", + "expiry": "2027-01-15T00:00:00.000Z", + "productId": "com.teachlink.subscription.pro.monthly" +} +``` + +### Rejected receipt response + +```json +{ + "valid": false, + "error": "Receipt has already been redeemed" +} +``` + +The server must return HTTP 200 in both cases. A non-200 response is treated as a server error and triggers the retry path (if network-level) or immediate failure (if 4xx / 5xx). + +## Restore Purchases + +`restorePurchases()` iterates available purchases from the platform and calls `validateReceipt` for each. Receipts where `valid: false` are silently skipped. Receipts that fail with a network error after all retries cause the whole restore to throw — the user should be shown an error and prompted to retry. + +## Subscription state after validation + +`subscriptionTier` in `useAppStore` is the source of truth for UI gating. It is set by `_setTier()` only after the server returns `valid: true`. On logout, `subscriptionTier` resets to `'free'`. The tier is also mirrored to `AsyncStorage` for cold-start reads before the store hydrates. diff --git a/src/__tests__/services/mobilePayments.test.ts b/src/__tests__/services/mobilePayments.test.ts index da8623d..61dd1b4 100644 --- a/src/__tests__/services/mobilePayments.test.ts +++ b/src/__tests__/services/mobilePayments.test.ts @@ -1,28 +1,51 @@ /** - * Tests for #615: restorePurchases validates server-side before updating state. + * Tests for: + * #615 — restorePurchases validates server-side before updating state + * #XXX — server-side receipt validation in purchaseUpdatedListener */ import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Platform } from 'react-native'; import * as IAP from 'react-native-iap'; import { apiService } from '../../services/api'; import { mobilePaymentsService, PRODUCT_IDS } from '../../services/mobilePayments'; +jest.mock('react-native', () => ({ Platform: { OS: 'ios' } })); jest.mock('@react-native-async-storage/async-storage'); jest.mock('react-native-iap'); jest.mock('../../services/api', () => ({ apiService: { post: jest.fn() }, })); jest.mock('../../utils/logger', () => ({ - appLogger: { errorSync: jest.fn(), infoSync: jest.fn(), debugSync: jest.fn() }, + appLogger: { errorSync: jest.fn(), warnSync: jest.fn(), infoSync: jest.fn(), debugSync: jest.fn() }, })); jest.mock('../../store/deviceStore', () => ({ useDeviceStore: { getState: () => ({ isDeviceCompromised: false }) }, })); +const mockStoreState = { + receiptValidationPending: false, + setReceiptValidationPending: jest.fn(), + setSubscriptionTier: jest.fn(), +}; +jest.mock('../../store', () => ({ + useAppStore: { getState: jest.fn(() => mockStoreState) }, +})); + +// ─── Typed helpers ──────────────────────────────────────────────────────────── + const mockIAP = IAP as jest.Mocked; const mockApi = apiService as jest.Mocked; const mockStorage = AsyncStorage as jest.Mocked; +const MOCK_RECEIPT = 'base64-encoded-receipt-data'; +const MOCK_PRODUCT_ID = PRODUCT_IDS.PRO_MONTHLY; + +function makeNetworkError(): Error { + // An error with no .response property is a network-level failure + return new Error('Network Error') as Error; +} + const makePurchase = (productId: string, receipt: string) => ({ productId, transactionId: `txn_${productId}`, @@ -32,15 +55,18 @@ const makePurchase = (productId: string, receipt: string) => ({ priceCurrencyCode: 'USD', }); +// ─── restorePurchases (#615) ────────────────────────────────────────────────── + describe('mobilePaymentsService.restorePurchases (#615)', () => { beforeEach(() => { jest.clearAllMocks(); + mockStoreState.receiptValidationPending = false; mockStorage.getItem.mockResolvedValue(null); mockStorage.setItem.mockResolvedValue(undefined); mockIAP.finishTransaction = jest.fn().mockResolvedValue(undefined); }); - it('only returns purchases with validated: true from server', async () => { + it('only returns purchases with valid: true from server', async () => { const validPurchase = makePurchase(PRODUCT_IDS.PRO_MONTHLY, 'valid-receipt'); const invalidPurchase = makePurchase(PRODUCT_IDS.PREMIUM_MONTHLY, 'invalid-receipt'); @@ -80,26 +106,182 @@ describe('mobilePaymentsService.restorePurchases (#615)', () => { }); it('returns empty array when all receipts are invalid', async () => { - const p1 = makePurchase(PRODUCT_IDS.PRO_MONTHLY, 'bad-1'); - const p2 = makePurchase(PRODUCT_IDS.PRO_ANNUAL, 'bad-2'); - - mockIAP.getAvailablePurchases = jest.fn().mockResolvedValue([p1, p2]); + mockIAP.getAvailablePurchases = jest.fn().mockResolvedValue([ + makePurchase(PRODUCT_IDS.PRO_MONTHLY, 'bad-1'), + makePurchase(PRODUCT_IDS.PRO_ANNUAL, 'bad-2'), + ]); mockApi.post.mockResolvedValue({ data: { valid: false } }); const result = await mobilePaymentsService.restorePurchases(); - // Falls back to local history (empty), so final result is [] expect(result).toHaveLength(0); expect(mockIAP.finishTransaction).not.toHaveBeenCalled(); }); it('updates subscription tier for the valid restored subscription', async () => { - const validPurchase = makePurchase(PRODUCT_IDS.PRO_MONTHLY, 'valid-receipt'); - mockIAP.getAvailablePurchases = jest.fn().mockResolvedValue([validPurchase]); + mockIAP.getAvailablePurchases = jest + .fn() + .mockResolvedValue([makePurchase(PRODUCT_IDS.PRO_MONTHLY, 'valid-receipt')]); mockApi.post.mockResolvedValue({ data: { valid: true, tier: 'pro' } }); await mobilePaymentsService.restorePurchases(); expect(mockStorage.setItem).toHaveBeenCalledWith('@teachlink:subscription_tier', 'pro'); + expect(mockStoreState.setSubscriptionTier).toHaveBeenCalledWith('pro'); + }); +}); + +// ─── validateReceipt – server validation + retry logic ─────────────────────── + +describe('mobilePaymentsService.validateReceipt', () => { + beforeEach(() => jest.clearAllMocks()); + + it('POSTs to /api/payments/validate-receipt and returns server result on success', async () => { + const serverResult = { valid: true, tier: 'pro', expiry: '2027-01-01T00:00:00.000Z' }; + mockApi.post.mockResolvedValueOnce({ data: serverResult }); + + const result = await mobilePaymentsService.validateReceipt(MOCK_RECEIPT, 'ios', MOCK_PRODUCT_ID); + + expect(mockApi.post).toHaveBeenCalledWith('/api/payments/validate-receipt', { + receipt: MOCK_RECEIPT, + platform: 'ios', + productId: MOCK_PRODUCT_ID, + }); + expect(result).toEqual(serverResult); + }); + + it('returns valid: false when server rejects the receipt', async () => { + mockApi.post.mockResolvedValueOnce({ data: { valid: false, error: 'Receipt already redeemed' } }); + + const result = await mobilePaymentsService.validateReceipt(MOCK_RECEIPT, 'android'); + + expect(result.valid).toBe(false); + expect(result.error).toBe('Receipt already redeemed'); + expect(mockApi.post).toHaveBeenCalledTimes(1); + }); + + it('retries on network error and succeeds on a later attempt', async () => { + const serverResult = { valid: true, tier: 'pro' }; + mockApi.post + .mockRejectedValueOnce(makeNetworkError()) + .mockRejectedValueOnce(makeNetworkError()) + .mockResolvedValueOnce({ data: serverResult }); + + jest.useFakeTimers(); + const promise = mobilePaymentsService.validateReceipt(MOCK_RECEIPT, 'ios'); + await jest.runAllTimersAsync(); + const result = await promise; + jest.useRealTimers(); + + expect(mockApi.post).toHaveBeenCalledTimes(3); + expect(result).toEqual(serverResult); + }); + + it('throws after all 4 attempts on persistent network error', async () => { + mockApi.post.mockRejectedValue(makeNetworkError()); + + jest.useFakeTimers(); + const promise = mobilePaymentsService.validateReceipt(MOCK_RECEIPT, 'ios'); + await jest.runAllTimersAsync(); + jest.useRealTimers(); + + await expect(promise).rejects.toThrow('Network Error'); + expect(mockApi.post).toHaveBeenCalledTimes(4); + }); + + it('throws immediately on a server-returned error without retrying', async () => { + const serverError = Object.assign(new Error('Forbidden'), { + response: { status: 403, data: { message: 'Forbidden' } }, + }); + mockApi.post.mockRejectedValueOnce(serverError); + + await expect(mobilePaymentsService.validateReceipt(MOCK_RECEIPT, 'ios')).rejects.toThrow( + 'Forbidden' + ); + expect(mockApi.post).toHaveBeenCalledTimes(1); + }); +}); + +// ─── purchaseUpdatedListener ────────────────────────────────────────────────── + +describe('purchaseUpdatedListener (via initialize)', () => { + let capturedListener: ((purchase: unknown) => Promise) | null = null; + + beforeEach(async () => { + jest.clearAllMocks(); + capturedListener = null; + mockStoreState.receiptValidationPending = false; + mockIAP.initConnection = jest.fn().mockResolvedValue(true); + mockIAP.purchaseUpdatedListener = jest.fn().mockImplementation(cb => { + capturedListener = cb; + return { remove: jest.fn() }; + }); + mockIAP.purchaseErrorListener = jest.fn().mockReturnValue({ remove: jest.fn() }); + mockIAP.finishTransaction = jest.fn().mockResolvedValue(undefined); + + // Reset internal initialized flag so initialize() runs fresh + (mobilePaymentsService as unknown as { isInitialized: boolean }).isInitialized = false; + await mobilePaymentsService.initialize(); + }); + + const MOCK_IAP_PURCHASE = { transactionReceipt: MOCK_RECEIPT, productId: MOCK_PRODUCT_ID }; + + it('sets receiptValidationPending true then false around a successful validation', async () => { + mockApi.post.mockResolvedValueOnce({ data: { valid: true, tier: 'pro' } }); + + await capturedListener!(MOCK_IAP_PURCHASE); + + expect(mockStoreState.setReceiptValidationPending).toHaveBeenNthCalledWith(1, true); + expect(mockStoreState.setReceiptValidationPending).toHaveBeenNthCalledWith(2, false); + }); + + it('calls finishTransaction and updates store tier when server validates receipt', async () => { + mockApi.post.mockResolvedValueOnce({ data: { valid: true, tier: 'pro' } }); + + await capturedListener!(MOCK_IAP_PURCHASE); + + expect(mockIAP.finishTransaction).toHaveBeenCalledWith({ + purchase: MOCK_IAP_PURCHASE, + isConsumable: false, + }); + expect(mockStoreState.setSubscriptionTier).toHaveBeenCalledWith('pro'); + }); + + it('does NOT call finishTransaction when server returns valid: false', async () => { + mockApi.post.mockResolvedValueOnce({ data: { valid: false, error: 'Receipt already redeemed' } }); + + await capturedListener!(MOCK_IAP_PURCHASE); + + expect(mockIAP.finishTransaction).not.toHaveBeenCalled(); + expect(mockStoreState.setSubscriptionTier).not.toHaveBeenCalled(); + }); + + it('does NOT call finishTransaction after network errors exhaust retries', async () => { + mockApi.post.mockRejectedValue(makeNetworkError()); + + jest.useFakeTimers(); + const listenerPromise = capturedListener!(MOCK_IAP_PURCHASE); + await jest.runAllTimersAsync(); + await listenerPromise; + jest.useRealTimers(); + + expect(mockIAP.finishTransaction).not.toHaveBeenCalled(); + expect(mockStoreState.setReceiptValidationPending).toHaveBeenLastCalledWith(false); + }); + + it('skips validation entirely when receiptValidationPending is already true', async () => { + mockStoreState.receiptValidationPending = true; + + await capturedListener!(MOCK_IAP_PURCHASE); + + expect(mockApi.post).not.toHaveBeenCalled(); + expect(mockIAP.finishTransaction).not.toHaveBeenCalled(); + }); + + it('ignores purchases without a transactionReceipt', async () => { + await capturedListener!({ productId: MOCK_PRODUCT_ID, transactionReceipt: undefined }); + + expect(mockApi.post).not.toHaveBeenCalled(); + expect(mockStoreState.setReceiptValidationPending).not.toHaveBeenCalled(); }); }); diff --git a/src/services/mobilePayments.ts b/src/services/mobilePayments.ts index b94c9c7..22dccbf 100644 --- a/src/services/mobilePayments.ts +++ b/src/services/mobilePayments.ts @@ -16,6 +16,7 @@ import { Platform } from 'react-native'; import * as IAP from 'react-native-iap'; import { apiService } from './api'; +import { useAppStore } from '../store'; import { useDeviceStore } from '../store/deviceStore'; import { appLogger } from '../utils/logger'; @@ -157,6 +158,11 @@ const STORAGE_KEYS = { SUBSCRIPTION_TIER: '@teachlink:subscription_tier', } as const; +// ─── Validation constants ───────────────────────────────────────────────────── + +const MAX_VALIDATION_ATTEMPTS = 4; // up to 3 retries on network error +const VALIDATION_BASE_DELAY_MS = 1_000; + // ─── Service ────────────────────────────────────────────────────────────────── class MobilePaymentsService { @@ -173,11 +179,42 @@ class MobilePaymentsService { IAP.purchaseUpdatedListener(async purchase => { const receipt = purchase.transactionReceipt; - if (receipt) { - const result = await this.validateReceipt(receipt, Platform.OS as 'ios' | 'android'); + if (!receipt) return; + + const store = useAppStore.getState(); + if (store.receiptValidationPending) { + appLogger.warnSync('[Payments] Receipt validation already in progress — skipping duplicate'); + return; + } + + store.setReceiptValidationPending(true); + try { + const result = await this.validateReceipt( + receipt, + Platform.OS as 'ios' | 'android', + purchase.productId + ); + if (result.valid) { await IAP.finishTransaction({ purchase, isConsumable: false }); + if (result.tier) { + await this._setTier(result.tier); + } + } else { + appLogger.errorSync( + '[Payments] Receipt rejected by server', + new Error(result.error ?? 'Receipt validation failed'), + { productId: purchase.productId } + ); } + } catch (error) { + appLogger.errorSync( + '[Payments] Receipt validation failed after retries — purchase not completed', + error instanceof Error ? error : new Error(String(error)), + { productId: purchase.productId } + ); + } finally { + useAppStore.getState().setReceiptValidationPending(false); } }); @@ -396,7 +433,11 @@ class MobilePaymentsService { /** * Validates a purchase receipt against the server. - * The server should verify with Apple / Google using their validation APIs. + * The server verifies with Apple (/verifyReceipt) or Google (Play Developer API). + * + * Retries up to 3 times (4 total attempts) on network-level failures. + * Throws immediately on any server-returned error (4xx / 5xx) so the + * caller can distinguish a rejected receipt from a connectivity problem. * * Apple server validation: https://buy.itunes.apple.com/verifyReceipt * Google server validation: https://www.googleapis.com/androidpublisher/v3/... @@ -406,22 +447,32 @@ class MobilePaymentsService { platform: 'ios' | 'android', productId?: string ): Promise { - try { - const response = await apiService.post('/payments/validate', { - receipt: receiptData, - platform, - productId, - }); - return response.data as ReceiptValidationResult; - } catch { - // Fallback mock for development — remove in production - return { - valid: true, - expiry: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - productId: productId ?? PRODUCT_IDS.PRO_MONTHLY, - tier: 'pro', - }; + let lastNetworkError: unknown; + + for (let attempt = 0; attempt < MAX_VALIDATION_ATTEMPTS; attempt++) { + try { + const response = await apiService.post('/api/payments/validate-receipt', { + receipt: receiptData, + platform, + productId, + }); + return response.data as ReceiptValidationResult; + } catch (error) { + // Only retry on network-level failures (no response from server). + // 4xx / 5xx responses carry a body and must not be retried blindly. + const hasResponse = !!(error as { response?: unknown }).response; + if (hasResponse) throw error; + + lastNetworkError = error; + if (attempt < MAX_VALIDATION_ATTEMPTS - 1) { + await new Promise(resolve => + setTimeout(resolve, VALIDATION_BASE_DELAY_MS * Math.pow(2, attempt)) + ); + } + } } + + throw lastNetworkError; } // ─── Storage helpers ──────────────────────────────────────────────────────── @@ -472,6 +523,7 @@ class MobilePaymentsService { private async _setTier(tier: SubscriptionTier): Promise { await AsyncStorage.setItem(STORAGE_KEYS.SUBSCRIPTION_TIER, tier); + useAppStore.getState().setSubscriptionTier(tier); } } diff --git a/src/store/index.ts b/src/store/index.ts index 25187e7..5cb2ba5 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'; -import { createHydrationErrorRecovery, secureStorageJSONStorage, toUnixMs } from './persistence'; +import { createHydrationErrorRecovery, secureStorageJSONStorage, toUnixMs } from './persistence'; import { clearFormCache, getFormCacheStorageKey } from '../services/formCache'; import { sentryContextService } from '../services/sentryContext'; @@ -25,7 +25,10 @@ interface AppState { isLoading: boolean; error: string | null; theme: 'light' | 'dark'; - // ── Client-side auth lockout ───────────────────────────────────────────── + // ── Subscription ────────────────────────────────────────────────────────── + subscriptionTier: 'free' | 'pro' | 'premium'; + receiptValidationPending: boolean; + // ── Client-side auth lockout ────────────────────────────────────────────── authFailureCount: number; authLockedUntil: number | null; refreshFailureCount: number; @@ -38,6 +41,8 @@ interface AppState { logout: () => void; setLoading: (isLoading: boolean) => void; setError: (error: string | null) => void; + setSubscriptionTier: (tier: 'free' | 'pro' | 'premium') => void; + setReceiptValidationPending: (pending: boolean) => void; incrementAuthFailure: () => void; resetAuthFailures: () => void; incrementRefreshFailure: () => void; @@ -63,96 +68,109 @@ let resetAppStoreAfterHydrationError = () => {}; export const useAppStore = create()( devtools( persist( - subscribeWithSelector(set => { + subscribeWithSelector((set, get) => { resetAppStoreAfterHydrationError = () => set(INITIAL_APP_STATE, false, 'hydrationErrorReset'); return { ...INITIAL_APP_STATE, - setUser: user => { - set({ user, isAuthenticated: !!user }, false, 'setUser'); - // Sync Sentry scope with the signed-in user so every subsequent - // error report is automatically tagged with user identity. - if (user) { - sentryContextService.setUser({ - id: user.id, - email: user.email, - username: user.name, - role: user.role, - }); - } else { + subscriptionTier: 'free' as const, + receiptValidationPending: false, + authFailureCount: 0, + authLockedUntil: null, + refreshFailureCount: 0, + setUser: (user: User | null) => { + set({ user, isAuthenticated: !!user }, false, 'setUser'); + // Sync Sentry scope with the signed-in user so every subsequent + // error report is automatically tagged with user identity. + if (user) { + sentryContextService.setUser({ + id: user.id, + email: user.email, + username: user.name, + role: user.role, + }); + } else { + sentryContextService.clearUser(); + } + }, + setTheme: (theme: 'light' | 'dark') => set({ theme }, false, 'setTheme'), + setTokens: (accessToken: string, refreshToken: string, sessionExpiresAt: number | Date) => + set( + { + accessToken, + refreshToken, + sessionExpiresAt: toUnixMs(sessionExpiresAt), + }, + false, + 'setTokens' + ), + setSessionExpiringSoon: (sessionExpiringSoon: boolean) => + set({ sessionExpiringSoon }, false, 'setSessionExpiringSoon'), + setAuthLoading: (isAuthLoading: boolean) => + set({ isAuthLoading }, false, 'setAuthLoading'), + setAuthError: (authError: string | null) => + set({ authError }, false, 'setAuthError'), + logout: () => { + const userId = get().user?.id; + set( + { + user: null, + isAuthenticated: false, + isAuthLoading: false, + authError: null, + accessToken: null, + refreshToken: null, + sessionExpiresAt: null, + sessionExpiringSoon: false, + subscriptionTier: 'free', + receiptValidationPending: false, + authFailureCount: 0, + authLockedUntil: null, + refreshFailureCount: 0, + }, + false, + 'logout' + ); sentryContextService.clearUser(); - } - }, - setTheme: theme => set({ theme }, false, 'setTheme'), - setTokens: (accessToken, refreshToken, sessionExpiresAt) => - set( - { - accessToken, - refreshToken, - sessionExpiresAt: toUnixMs(sessionExpiresAt), - }, - false, - 'setTokens' - ), - setSessionExpiringSoon: sessionExpiringSoon => - set({ sessionExpiringSoon }, false, 'setSessionExpiringSoon'), - setAuthLoading: isAuthLoading => set({ isAuthLoading }, false, 'setAuthLoading'), - setAuthError: authError => set({ authError }, false, 'setAuthError'), - logout: () => { - const userId = get().user?.id; - set( - { - user: null, - isAuthenticated: false, - isAuthLoading: false, - authError: null, - accessToken: null, - refreshToken: null, - sessionExpiresAt: null, - sessionExpiringSoon: false, - authFailureCount: 0, - authLockedUntil: null, - refreshFailureCount: 0, - }, - false, - 'logout' - ); - // Clear Sentry user scope and reset breadcrumb trail on logout - sentryContextService.clearUser(); - sentryContextService.resetSession(); - if (userId) { - clearFormCache(getFormCacheStorageKey(userId)).catch(() => {}); - } - }, - setLoading: isLoading => set({ isLoading }, false, 'setLoading'), - setError: error => set({ error }, false, 'setError'), - incrementAuthFailure: () => - set( - state => { - const next = state.authFailureCount + 1; - if (next >= 5) { - return { authFailureCount: 0, authLockedUntil: Date.now() + 30_000 }; - } - return { authFailureCount: next }; - }, - false, - 'incrementAuthFailure' - ), - resetAuthFailures: () => - set({ authFailureCount: 0, authLockedUntil: null }, false, 'resetAuthFailures'), - incrementRefreshFailure: () => { - const next = get().refreshFailureCount + 1; - if (next >= 3) { - get().logout(); - set({ refreshFailureCount: 0 }, false, 'forceLogoutOnRefreshFailure'); - } else { - set({ refreshFailureCount: next }, false, 'incrementRefreshFailure'); - } - }, - resetRefreshFailures: () => - set({ refreshFailureCount: 0 }, false, 'resetRefreshFailures'), - })), + sentryContextService.resetSession(); + if (userId) { + clearFormCache(getFormCacheStorageKey(userId)).catch(() => {}); + } + }, + setLoading: (isLoading: boolean) => set({ isLoading }, false, 'setLoading'), + setError: (error: string | null) => set({ error }, false, 'setError'), + setSubscriptionTier: (tier: 'free' | 'pro' | 'premium') => + set({ subscriptionTier: tier }, false, 'setSubscriptionTier'), + setReceiptValidationPending: (pending: boolean) => + set({ receiptValidationPending: pending }, false, 'setReceiptValidationPending'), + incrementAuthFailure: () => + set( + state => { + const next = state.authFailureCount + 1; + if (next >= 5) { + return { authFailureCount: 0, authLockedUntil: Date.now() + 30_000 }; + } + return { authFailureCount: next }; + }, + false, + 'incrementAuthFailure' + ), + resetAuthFailures: () => + set({ authFailureCount: 0, authLockedUntil: null }, false, 'resetAuthFailures'), + incrementRefreshFailure: () => { + const next = get().refreshFailureCount + 1; + if (next >= 3) { + get().logout(); + set({ refreshFailureCount: 0 }, false, 'forceLogoutOnRefreshFailure'); + } else { + set({ refreshFailureCount: next }, false, 'incrementRefreshFailure'); + } + }, + resetRefreshFailures: () => + set({ refreshFailureCount: 0 }, false, 'resetRefreshFailures'), + }; + }), { name: 'app-auth-storage', storage: secureStorageJSONStorage,