Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions docs/payments/receipt-validation-flow.md
Original file line number Diff line number Diff line change
@@ -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 <access-token>

{
"receipt": "<base64 receipt (iOS) or purchase token (Android)>",
"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.
202 changes: 192 additions & 10 deletions src/__tests__/services/mobilePayments.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof IAP>;
const mockApi = apiService as jest.Mocked<typeof apiService>;
const mockStorage = AsyncStorage as jest.Mocked<typeof AsyncStorage>;

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}`,
Expand All @@ -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');

Expand Down Expand Up @@ -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<void>) | 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();
});
});
Loading
Loading