diff --git a/.github/pr-previews/pr-313-kit-store-verification-ui.mp4 b/.github/pr-previews/pr-313-kit-store-verification-ui.mp4 new file mode 100644 index 000000000..8bd41c682 Binary files /dev/null and b/.github/pr-previews/pr-313-kit-store-verification-ui.mp4 differ diff --git a/.github/workflows/deploy-kit.yml b/.github/workflows/deploy-kit.yml index 6bfb13ff2..c45b78344 100644 --- a/.github/workflows/deploy-kit.yml +++ b/.github/workflows/deploy-kit.yml @@ -82,17 +82,20 @@ jobs: run: bun run test:coverage - name: Enforce server line coverage - run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90 + run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90 server/ - - name: Upload server coverage + - name: Enforce Convex line coverage + run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 48 convex/ + + - name: Upload Kit coverage uses: codecov/codecov-action@v7 with: use_oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} fail_ci_if_error: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} disable_search: true files: coverage/lcov.info - flags: iapkit-server - name: iapkit-server + flags: iapkit + name: iapkit network_prefix: packages/kit/ working-directory: packages/kit diff --git a/codecov.yml b/codecov.yml index 039e5c870..0cea4f369 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,7 @@ +parsers: + lcov: + partials_as_hits: true + coverage: precision: 2 round: down @@ -30,8 +34,14 @@ coverage: target: 90% threshold: 0% informational: false - flags: - - iapkit-server + paths: + - "packages/kit/server/**" + iapkit-convex: + target: 48% + threshold: 0% + informational: false + paths: + - "packages/kit/convex/**" patch: default: target: auto @@ -59,8 +69,14 @@ coverage: target: 90% threshold: 0% informational: false - flags: - - iapkit-server + paths: + - "packages/kit/server/**" + iapkit-convex: + target: 48% + threshold: 0% + informational: false + paths: + - "packages/kit/convex/**" flags: react-native-iap: @@ -75,9 +91,10 @@ flags: paths: - "libraries/flutter_inapp_purchase/lib/**" carryforward: true - iapkit-server: + iapkit: paths: - "packages/kit/server/**" + - "packages/kit/convex/**" carryforward: true component_management: @@ -105,7 +122,13 @@ component_management: paths: - "packages/kit/server/**" flag_regexes: - - "^iapkit-server$" + - "^iapkit$" + - component_id: iapkit-convex + name: IAPKit Convex + paths: + - "packages/kit/convex/**" + flag_regexes: + - "^iapkit$" comment: layout: "reach,diff,flags,components,files" @@ -118,3 +141,6 @@ ignore: - "libraries/react-native-iap/src/types.ts" - "libraries/expo-iap/src/types.ts" - "libraries/flutter_inapp_purchase/lib/types.dart" + - "packages/kit/convex/_generated/**" + - "packages/kit/convex/**/*.test.ts" + - "packages/kit/convex/test.setup.ts" diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index f30f0fdb9..9f537c7a8 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-08-10T16:17:58.157Z +> Last updated: 2026-08-11T08:49:51.526Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -4755,9 +4755,17 @@ Retention: - Events are retained for the bounded IAPKit operational window and pruned by a Convex cron job. They are not exposed as a public replay stream. -Meta Horizon has no inbound webhook. Its bounded polling reconciler may record -synthetic lifecycle events under the `MetaHorizonReconciler` source for the same -private state machine and retention policy. +Meta Horizon has no inbound webhook or background lifecycle lane in IAPKit. +`POST /v1/purchase/verify` performs a synchronous entitlement check only. The +`MetaHorizonReconciler` source remains schema-compatible for legacy retained +rows, but those synthetic events are excluded from current revenue rollups. + +Amazon RVS also has no inbound webhook receiver in IAPKit. A bounded purchase +reconciler schedules active Amazon receipt rows for revisits on a 48-hour due +cadence, but backlog, retries, and lease recovery mean it does not guarantee +that every row is checked within 72 hours. It updates state only from +authoritative RVS outcomes and preserves the last confirmed state across +transient or malformed responses. --- diff --git a/knowledge/external/webhook-mapping.md b/knowledge/external/webhook-mapping.md index 69229d659..443272b01 100644 --- a/knowledge/external/webhook-mapping.md +++ b/knowledge/external/webhook-mapping.md @@ -81,6 +81,14 @@ Retention: - Events are retained for the bounded IAPKit operational window and pruned by a Convex cron job. They are not exposed as a public replay stream. -Meta Horizon has no inbound webhook. Its bounded polling reconciler may record -synthetic lifecycle events under the `MetaHorizonReconciler` source for the same -private state machine and retention policy. +Meta Horizon has no inbound webhook or background lifecycle lane in IAPKit. +`POST /v1/purchase/verify` performs a synchronous entitlement check only. The +`MetaHorizonReconciler` source remains schema-compatible for legacy retained +rows, but those synthetic events are excluded from current revenue rollups. + +Amazon RVS also has no inbound webhook receiver in IAPKit. A bounded purchase +reconciler schedules active Amazon receipt rows for revisits on a 48-hour due +cadence, but backlog, retries, and lease recovery mean it does not guarantee +that every row is checked within 72 hours. It updates state only from +authoritative RVS outcomes and preserves the last confirmed state across +transient or malformed responses. diff --git a/libraries/expo-iap/example/.env.example b/libraries/expo-iap/example/.env.example index 02d0e27e8..12b637b8e 100644 --- a/libraries/expo-iap/example/.env.example +++ b/libraries/expo-iap/example/.env.example @@ -6,3 +6,6 @@ EXPO_PUBLIC_IAPKIT_API_KEY=openiap-kit_pk_your_publishable_key_here # Required when selecting Local (IAPKit). Use your Mac's LAN IP on a device. # Example: http://192.168.0.10:3100 EXPO_PUBLIC_IAPKIT_BASE_URL= +# Set true only for Amazon App Tester receipts after enabling the matching +# sandbox option in the IAPKit project settings. +EXPO_PUBLIC_AMAZON_RVS_SANDBOX=false diff --git a/libraries/expo-iap/example/README.md b/libraries/expo-iap/example/README.md index 677262b1b..2e52f3e4b 100644 --- a/libraries/expo-iap/example/README.md +++ b/libraries/expo-iap/example/README.md @@ -26,7 +26,7 @@ Create the ignored environment file before testing IAPKit: cp .env.example .env ``` -For hosted IAPKit, get an `openiap-kit_pk_` publishable key from the [IAPKit dashboard](https://kit.openiap.dev) and set it as `EXPO_PUBLIC_IAPKIT_API_KEY`. Expo embeds every `EXPO_PUBLIC_*` value in the app, so never use an `openiap-kit_sk_` secret admin key. For **Local (IAPKit)**, the publishable key and local server must target the same Convex deployment. Also set `EXPO_PUBLIC_IAPKIT_BASE_URL` to the device-reachable HTTP(S) origin only; do not append `/v1/purchase/verify`. A physical iPhone must use the Mac's LAN address. An Android device connected over USB can use `http://127.0.0.1:3100`: inspect `adb -s "$ANDROID_SERIAL" reverse --list`, reuse an exact `tcp:3100` mapping when present, or create it with `adb -s "$ANDROID_SERIAL" reverse --no-rebind tcp:3100 tcp:3100`. Record whether this run created the rule and remove only that rule during cleanup; if `--no-rebind` fails, use another port instead of overwriting an existing mapping. +For hosted IAPKit, get an `openiap-kit_pk_` publishable key from the [IAPKit dashboard](https://kit.openiap.dev) and set it as `EXPO_PUBLIC_IAPKIT_API_KEY`. Expo embeds every `EXPO_PUBLIC_*` value in the app, so never use an `openiap-kit_sk_` secret admin key. For **Local (IAPKit)**, the publishable key and local server must target the same Convex deployment. Also set `EXPO_PUBLIC_IAPKIT_BASE_URL` to the device-reachable HTTP(S) origin only; do not append `/v1/purchase/verify`. Set `EXPO_PUBLIC_AMAZON_RVS_SANDBOX=true` only for Amazon App Tester receipts after enabling the matching sandbox option in the IAPKit project settings. A physical iPhone must use the Mac's LAN address. An Android device connected over USB can use `http://127.0.0.1:3100`: inspect `adb -s "$ANDROID_SERIAL" reverse --list`, reuse an exact `tcp:3100` mapping when present, or create it with `adb -s "$ANDROID_SERIAL" reverse --no-rebind tcp:3100 tcp:3100`. Record whether this run created the rule and remove only that rule during cleanup; if `--no-rebind` fails, use another port instead of overwriting an existing mapping. The Vega build scripts load the same Expo environment-file chain as the normal Expo CLI: Debug uses `.env.development.local`, `.env.local`, diff --git a/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx b/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx index 990b5755a..ce36839ae 100644 --- a/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx +++ b/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx @@ -43,7 +43,7 @@ const mockUseIAP = { platform: 'ios', }, ], - availablePurchases: [], + availablePurchases: [] as Record[], fetchProducts: mockFetchProducts, finishTransaction: mockFinishTransaction, getAvailablePurchases: mockGetAvailablePurchases, @@ -74,10 +74,13 @@ describe('PurchaseFlow Component', () => { mockVerifyPurchaseWithProvider.mockResolvedValue({ iapkit: { isValid: true, - state: 'purchased', + productId: 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', store: 'apple', }, }); + mockUseIAP.connected = true; + mockUseIAP.availablePurchases = []; mockOnPurchaseSuccess = undefined; (getStorefront as jest.Mock).mockResolvedValue('US'); }); @@ -186,6 +189,70 @@ describe('PurchaseFlow Component', () => { ).toBeLessThan(mockFinishTransaction.mock.invocationCallOrder[0]!); }); + it('finishes a ready-to-consume Google consumable after verification', async () => { + mockVerifyPurchaseWithProvider.mockResolvedValue({ + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', + store: 'google', + }, + }); + const purchase = { + id: 'google-consumable-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'google-token-1', + store: 'google', + transactionDate: Date.now(), + purchaseState: 'purchased', + }; + + await render(); + await act(async () => { + await mockOnPurchaseSuccess?.(purchase); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledWith({ + provider: 'iapkit', + iapkit: { + apiKey: 'test-api-key', + baseUrl: 'http://192.168.0.10:3100', + google: {purchaseToken: 'google-token-1'}, + }, + }); + expect(mockFinishTransaction).toHaveBeenCalledWith({ + purchase, + isConsumable: true, + }); + }); + + it('does not refresh or re-enqueue after finishing persistently fails', async () => { + mockFinishTransaction.mockRejectedValue(new Error('finish failed')); + const purchase = { + id: 'finish-failure-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'finish-failure-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }; + + await render(); + await waitFor(() => { + expect(mockGetAvailablePurchases).toHaveBeenCalledTimes(1); + }); + mockGetAvailablePurchases.mockClear(); + + await act(async () => { + await mockOnPurchaseSuccess?.(purchase); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + expect(mockGetAvailablePurchases).not.toHaveBeenCalled(); + }); + it('keeps Local (Device) on direct Apple/Google verification', async () => { mockShowActionSheetWithOptions.mockImplementation( (_options: unknown, callback: (index?: number) => void) => callback(0), @@ -253,4 +320,394 @@ describe('PurchaseFlow Component', () => { }, }); }); + + it.each([ + { + label: 'an invalid result', + result: { + provider: 'iapkit', + iapkit: { + isValid: false, + productId: 'dev.hyo.martie.10bulbs', + state: 'consumed', + store: 'apple', + }, + }, + }, + { + label: 'a mismatched product', + result: { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.30bulbs', + state: 'ready-to-consume', + store: 'apple', + }, + }, + }, + ])('does not finish after $label', async ({result}) => { + mockVerifyPurchaseWithProvider.mockResolvedValue(result); + await render(); + + await act(async () => { + await mockOnPurchaseSuccess?.({ + id: 'transaction-rejected-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'rejected-apple-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + expect(mockFinishTransaction).not.toHaveBeenCalled(); + }); + + it('verifies a restored purchase and keeps a rejected one unfinished', async () => { + mockUseIAP.availablePurchases = [ + { + id: 'restored-transaction-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'restored-apple-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }, + ]; + mockVerifyPurchaseWithProvider.mockResolvedValue({ + provider: 'iapkit', + iapkit: { + isValid: false, + productId: 'dev.hyo.martie.10bulbs', + state: 'consumed', + store: 'apple', + }, + }); + + await render(); + + await waitFor(() => { + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + }); + expect(mockFinishTransaction).not.toHaveBeenCalled(); + }); + + it('verifies and finishes multiple restored purchases sequentially', async () => { + mockUseIAP.availablePurchases = [ + { + id: 'restored-transaction-10', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'restored-10-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }, + { + id: 'restored-transaction-30', + productId: 'dev.hyo.martie.30bulbs', + purchaseToken: 'restored-30-jws', + store: 'apple', + transactionDate: Date.now() + 1, + purchaseState: 'purchased', + }, + ]; + mockVerifyPurchaseWithProvider.mockImplementation((request) => { + const token = (request as {iapkit?: {apple?: {jws?: string}}}).iapkit + ?.apple?.jws; + return Promise.resolve({ + provider: 'iapkit', + iapkit: { + isValid: true, + productId: + token === 'restored-30-jws' + ? 'dev.hyo.martie.30bulbs' + : 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', + store: 'apple', + }, + }); + }); + + await render(); + + await waitFor(() => { + expect(mockFinishTransaction).toHaveBeenCalledTimes(2); + }); + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(2); + expect( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[0], + ).toBeLessThan(mockFinishTransaction.mock.invocationCallOrder[0]!); + expect(mockFinishTransaction.mock.invocationCallOrder[0]).toBeLessThan( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[1]!, + ); + expect( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[1], + ).toBeLessThan(mockFinishTransaction.mock.invocationCallOrder[1]!); + }); + + it('keeps a preclaimed restore queue intact across an available-purchases rerender', async () => { + const restoredPurchases = [ + { + id: 'rerender-restored-10', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'rerender-restored-10-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }, + { + id: 'rerender-restored-30', + productId: 'dev.hyo.martie.30bulbs', + purchaseToken: 'rerender-restored-30-jws', + store: 'apple', + transactionDate: Date.now() + 1, + purchaseState: 'purchased', + }, + ]; + const firstResult = { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', + store: 'apple', + }, + }; + let resolveFirst: ((value: typeof firstResult) => void) | undefined; + const firstVerification = new Promise((resolve) => { + resolveFirst = resolve; + }); + mockVerifyPurchaseWithProvider.mockImplementation((request) => { + const token = (request as {iapkit?: {apple?: {jws?: string}}}).iapkit + ?.apple?.jws; + if (token === 'rerender-restored-10-jws') return firstVerification; + return Promise.resolve({ + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.30bulbs', + state: 'ready-to-consume', + store: 'apple', + }, + }); + }); + mockUseIAP.availablePurchases = restoredPurchases; + + const {rerender} = await render(); + await waitFor(() => { + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + }); + + mockUseIAP.availablePurchases = restoredPurchases.map((purchase) => ({ + ...purchase, + })); + await rerender(); + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + + if (!resolveFirst) throw new Error('first verification was not pending'); + await act(async () => { + resolveFirst?.(firstResult); + }); + await waitFor(() => { + expect(mockFinishTransaction).toHaveBeenCalledTimes(2); + }); + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(2); + expect(mockFinishTransaction.mock.invocationCallOrder[0]).toBeLessThan( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[1]!, + ); + }); + + it('keeps an in-flight restored purchase deduped across reconnect', async () => { + const restoredPurchase = { + id: 'reconnect-restored-10', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'reconnect-restored-10-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }; + const result = { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', + store: 'apple', + }, + }; + let resolveVerification: ((value: typeof result) => void) | undefined; + mockVerifyPurchaseWithProvider.mockImplementation( + () => + new Promise((resolve) => { + resolveVerification = resolve; + }), + ); + mockUseIAP.availablePurchases = [restoredPurchase]; + + const {rerender} = await render(); + await waitFor(() => { + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + }); + + mockUseIAP.connected = false; + mockUseIAP.availablePurchases = []; + await rerender(); + mockUseIAP.connected = true; + mockUseIAP.availablePurchases = [{...restoredPurchase}]; + await rerender(); + + if (!resolveVerification) { + throw new Error('restored purchase verification was not pending'); + } + await act(async () => { + resolveVerification?.(result); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await waitFor(() => { + expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + }); + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + }); + + it('does not duplicate verification or finish across a remount while finish is pending', async () => { + const purchase = { + id: 'remount-pending-finish-10', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'remount-pending-finish-10-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }; + let resolveFinish: (() => void) | undefined; + mockFinishTransaction.mockImplementation( + () => + new Promise((resolve) => { + resolveFinish = resolve; + }), + ); + + const firstMount = await render(); + const firstPurchaseSuccessHandler = mockOnPurchaseSuccess; + if (!firstPurchaseSuccessHandler) { + throw new Error('purchase success handler was not registered'); + } + + let processingPromise: Promise | undefined; + await act(async () => { + processingPromise = Promise.resolve( + firstPurchaseSuccessHandler(purchase), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + await firstMount.unmount(); + + mockUseIAP.availablePurchases = [{...purchase}]; + const secondMount = await render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + + if (!resolveFinish || !processingPromise) { + throw new Error('pending finish was not initialized'); + } + await act(async () => { + resolveFinish?.(); + await processingPromise; + await Promise.resolve(); + }); + + const remountedPurchaseSuccessHandler = mockOnPurchaseSuccess; + if (!remountedPurchaseSuccessHandler) { + throw new Error('remounted purchase success handler was not registered'); + } + await act(async () => { + await remountedPurchaseSuccessHandler({...purchase}); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + await secondMount.unmount(); + }); + + it('serializes two overlapping live purchase callbacks', async () => { + const purchases = [ + { + id: 'live-purchase-10', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'live-purchase-10-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }, + { + id: 'live-purchase-30', + productId: 'dev.hyo.martie.30bulbs', + purchaseToken: 'live-purchase-30-jws', + store: 'apple', + transactionDate: Date.now() + 1, + purchaseState: 'purchased', + }, + ]; + const firstResult = { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', + store: 'apple', + }, + }; + let resolveFirst: ((value: typeof firstResult) => void) | undefined; + const firstVerification = new Promise((resolve) => { + resolveFirst = resolve; + }); + mockVerifyPurchaseWithProvider.mockImplementation((request) => { + const token = (request as {iapkit?: {apple?: {jws?: string}}}).iapkit + ?.apple?.jws; + if (token === 'live-purchase-10-jws') return firstVerification; + return Promise.resolve({ + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.30bulbs', + state: 'ready-to-consume', + store: 'apple', + }, + }); + }); + await render(); + if (!mockOnPurchaseSuccess) { + throw new Error('purchase success handler was not registered'); + } + + const firstCallback = mockOnPurchaseSuccess(purchases[0]!); + const secondCallback = mockOnPurchaseSuccess(purchases[1]!); + await waitFor(() => { + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + }); + expect(mockFinishTransaction).not.toHaveBeenCalled(); + + if (!resolveFirst) throw new Error('first verification was not pending'); + await act(async () => { + resolveFirst?.(firstResult); + await Promise.all([firstCallback, secondCallback]); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(2); + expect(mockFinishTransaction).toHaveBeenCalledTimes(2); + expect(mockFinishTransaction.mock.invocationCallOrder[0]).toBeLessThan( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[1]!, + ); + }); }); diff --git a/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx b/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx index 37feccd64..4e61aa832 100644 --- a/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx +++ b/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx @@ -36,14 +36,16 @@ const mockVerifyPurchaseWithProvider = jest Promise.resolve({ iapkit: { isValid: true, - state: 'purchased', + productId: 'dev.hyo.martie.premium', + state: 'entitled', store: 'google', }, }), ) .mockName('verifyPurchaseWithProvider'); let mockOnPurchaseSuccess: - ((purchase: Record) => Promise | void) | undefined; + | ((purchase: Record) => Promise | void) + | undefined; const createMockSubscription = (overrides = {}) => ({ id: 'dev.hyo.martie.premium', @@ -127,7 +129,8 @@ describe('SubscriptionFlow Component', () => { mockVerifyPurchaseWithProvider.mockResolvedValue({ iapkit: { isValid: true, - state: 'purchased', + productId: 'dev.hyo.martie.premium', + state: 'entitled', store: 'google', }, }); @@ -487,4 +490,507 @@ describe('SubscriptionFlow Component', () => { mockFinishTransaction.mock.invocationCallOrder[0]!, ); }); + + it.each([ + { + label: 'an invalid result', + result: { + provider: 'iapkit', + iapkit: { + isValid: false, + productId: 'dev.hyo.martie.premium', + state: 'expired', + store: 'google', + }, + }, + }, + { + label: 'a mismatched product', + result: { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.premium_year', + state: 'entitled', + store: 'google', + }, + }, + }, + ])('does not finish after $label', async ({result}) => { + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, + }); + mockVerifyPurchaseWithProvider.mockResolvedValue(result); + await renderConnectedSubscriptionFlow(); + + await act(async () => { + await mockOnPurchaseSuccess?.({ + id: 'transaction-rejected-sub-1', + store: 'google', + productId: 'dev.hyo.martie.premium', + purchaseToken: 'rejected-google-token', + transactionDate: Date.now(), + }); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + expect(mockFinishTransaction).not.toHaveBeenCalled(); + }); + + it('verifies a restored subscription and keeps a mismatch unfinished', async () => { + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + const restoredPurchase = { + id: 'restored-subscription-1', + originalTransactionIdentifierIOS: 'original-subscription-1', + productId: 'dev.hyo.martie.premium', + purchaseToken: 'restored-apple-jws', + store: 'apple', + transactionDate: Date.now(), + transactionReasonIOS: 'RENEWAL', + }; + mockVerifyPurchaseWithProvider.mockResolvedValue({ + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.premium_year', + state: 'entitled', + store: 'apple', + }, + }); + mockUseIAP.mockReturnValue({ + connected: true, + subscriptions: [createMockSubscription()], + availablePurchases: [restoredPurchase], + fetchProducts: mockFetchProducts, + getAvailablePurchases: mockGetAvailablePurchases, + finishTransaction: mockFinishTransaction, + getActiveSubscriptions: mockGetActiveSubscriptions, + activeSubscriptions: [], + verifyPurchase: mockVerifyPurchase, + verifyPurchaseWithProvider: mockVerifyPurchaseWithProvider, + }); + + await renderConnectedSubscriptionFlow(); + + await waitFor(() => { + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + }); + expect(mockFinishTransaction).not.toHaveBeenCalled(); + }); + + it('verifies and finishes multiple restored subscriptions sequentially', async () => { + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + const restoredSubscriptions = [ + { + id: 'restored-subscription-monthly', + originalTransactionIdentifierIOS: 'original-subscription-monthly', + productId: 'dev.hyo.martie.premium', + purchaseToken: 'restored-monthly-jws', + store: 'apple', + transactionDate: Date.now(), + transactionReasonIOS: 'RENEWAL', + }, + { + id: 'restored-subscription-yearly', + originalTransactionIdentifierIOS: 'original-subscription-yearly', + productId: 'dev.hyo.martie.premium_year', + purchaseToken: 'restored-yearly-jws', + store: 'apple', + transactionDate: Date.now() + 1, + transactionReasonIOS: 'RENEWAL', + }, + ]; + mockVerifyPurchaseWithProvider.mockImplementation((request) => { + const token = (request as {iapkit?: {apple?: {jws?: string}}}).iapkit + ?.apple?.jws; + return Promise.resolve({ + provider: 'iapkit', + iapkit: { + isValid: true, + productId: + token === 'restored-yearly-jws' + ? 'dev.hyo.martie.premium_year' + : 'dev.hyo.martie.premium', + state: 'entitled', + store: 'apple', + }, + }); + }); + mockUseIAP.mockReturnValue({ + connected: true, + subscriptions: [createMockSubscription()], + availablePurchases: restoredSubscriptions, + fetchProducts: mockFetchProducts, + getAvailablePurchases: mockGetAvailablePurchases, + finishTransaction: mockFinishTransaction, + getActiveSubscriptions: mockGetActiveSubscriptions, + activeSubscriptions: [], + verifyPurchase: mockVerifyPurchase, + verifyPurchaseWithProvider: mockVerifyPurchaseWithProvider, + }); + + await renderConnectedSubscriptionFlow(); + + await waitFor(() => { + expect(mockFinishTransaction).toHaveBeenCalledTimes(2); + }); + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(2); + expect( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[0], + ).toBeLessThan(mockFinishTransaction.mock.invocationCallOrder[0]!); + expect(mockFinishTransaction.mock.invocationCallOrder[0]).toBeLessThan( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[1]!, + ); + expect( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[1], + ).toBeLessThan(mockFinishTransaction.mock.invocationCallOrder[1]!); + }); + + it('keeps a preclaimed subscription restore queue across a hook rerender', async () => { + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + const restoredSubscriptions = [ + { + id: 'rerender-subscription-monthly', + originalTransactionIdentifierIOS: 'original-rerender-monthly', + productId: 'dev.hyo.martie.premium', + purchaseToken: 'rerender-monthly-jws', + store: 'apple', + transactionDate: Date.now(), + transactionReasonIOS: 'RENEWAL', + }, + { + id: 'rerender-subscription-yearly', + originalTransactionIdentifierIOS: 'original-rerender-yearly', + productId: 'dev.hyo.martie.premium_year', + purchaseToken: 'rerender-yearly-jws', + store: 'apple', + transactionDate: Date.now() + 1, + transactionReasonIOS: 'RENEWAL', + }, + ]; + const firstResult = { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.premium', + state: 'entitled', + store: 'apple', + }, + }; + let resolveFirst: ((value: typeof firstResult) => void) | undefined; + const firstVerification = new Promise((resolve) => { + resolveFirst = resolve; + }); + mockVerifyPurchaseWithProvider.mockImplementation((request) => { + const token = (request as {iapkit?: {apple?: {jws?: string}}}).iapkit + ?.apple?.jws; + if (token === 'rerender-monthly-jws') return firstVerification; + return Promise.resolve({ + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.premium_year', + state: 'entitled', + store: 'apple', + }, + }); + }); + const hookValue = { + connected: true, + subscriptions: [createMockSubscription()], + availablePurchases: restoredSubscriptions, + fetchProducts: mockFetchProducts, + getAvailablePurchases: mockGetAvailablePurchases, + finishTransaction: mockFinishTransaction, + getActiveSubscriptions: mockGetActiveSubscriptions, + activeSubscriptions: [], + verifyPurchase: mockVerifyPurchase, + verifyPurchaseWithProvider: mockVerifyPurchaseWithProvider, + }; + mockUseIAP.mockReturnValue(hookValue); + + const {rerender} = await renderConnectedSubscriptionFlow(); + await waitFor(() => { + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + }); + + mockUseIAP.mockReturnValue({ + ...hookValue, + availablePurchases: restoredSubscriptions.map((purchase) => ({ + ...purchase, + })), + }); + await rerender(); + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + + if (!resolveFirst) throw new Error('first verification was not pending'); + await act(async () => { + resolveFirst?.(firstResult); + }); + await waitFor(() => { + expect(mockFinishTransaction).toHaveBeenCalledTimes(2); + }); + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(2); + expect(mockFinishTransaction.mock.invocationCallOrder[0]).toBeLessThan( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[1]!, + ); + }); + + it('keeps an in-flight restored subscription deduped across reconnect', async () => { + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + const restoredSubscription = { + id: 'reconnect-subscription-monthly', + originalTransactionIdentifierIOS: 'original-reconnect-monthly', + productId: 'dev.hyo.martie.premium', + purchaseToken: 'reconnect-monthly-jws', + store: 'apple', + transactionDate: Date.now(), + transactionReasonIOS: 'RENEWAL', + }; + const result = { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.premium', + state: 'entitled', + store: 'apple', + }, + }; + let resolveVerification: ((value: typeof result) => void) | undefined; + mockVerifyPurchaseWithProvider.mockImplementation( + () => + new Promise((resolve) => { + resolveVerification = resolve; + }), + ); + const hookValue = { + connected: true, + subscriptions: [createMockSubscription()], + availablePurchases: [restoredSubscription], + fetchProducts: mockFetchProducts, + getAvailablePurchases: mockGetAvailablePurchases, + finishTransaction: mockFinishTransaction, + getActiveSubscriptions: mockGetActiveSubscriptions, + activeSubscriptions: [], + verifyPurchase: mockVerifyPurchase, + verifyPurchaseWithProvider: mockVerifyPurchaseWithProvider, + }; + mockUseIAP.mockReturnValue(hookValue); + + const {rerender} = await renderConnectedSubscriptionFlow(); + await waitFor(() => { + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + }); + + mockUseIAP.mockReturnValue({ + ...hookValue, + connected: false, + availablePurchases: [], + }); + await rerender(); + mockUseIAP.mockReturnValue({ + ...hookValue, + availablePurchases: [{...restoredSubscription}], + }); + await rerender(); + + if (!resolveVerification) { + throw new Error('restored subscription verification was not pending'); + } + await act(async () => { + resolveVerification?.(result); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await waitFor(() => { + expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + }); + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + }); + + it('does not duplicate verification or finish across a remount while finish is pending', async () => { + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + const purchase = { + id: 'remount-subscription-pending-finish', + originalTransactionIdentifierIOS: 'remount-subscription-original', + productId: 'dev.hyo.martie.premium', + purchaseToken: 'remount-subscription-pending-finish-jws', + store: 'apple', + transactionDate: Date.now(), + transactionReasonIOS: 'PURCHASE', + }; + let resolveFinish: (() => void) | undefined; + mockFinishTransaction.mockImplementation( + () => + new Promise((resolve) => { + resolveFinish = resolve; + }), + ); + mockVerifyPurchaseWithProvider.mockResolvedValue({ + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.premium', + state: 'entitled', + store: 'apple', + }, + }); + const hookValue = { + connected: true, + subscriptions: [createMockSubscription()], + availablePurchases: [], + fetchProducts: mockFetchProducts, + getAvailablePurchases: mockGetAvailablePurchases, + finishTransaction: mockFinishTransaction, + getActiveSubscriptions: mockGetActiveSubscriptions, + activeSubscriptions: [], + verifyPurchase: mockVerifyPurchase, + verifyPurchaseWithProvider: mockVerifyPurchaseWithProvider, + }; + mockUseIAP.mockReturnValue(hookValue); + + const firstMount = await render(); + const firstPurchaseSuccessHandler = mockOnPurchaseSuccess; + if (!firstPurchaseSuccessHandler) { + throw new Error('purchase success handler was not registered'); + } + + let processingPromise: Promise | undefined; + await act(async () => { + processingPromise = Promise.resolve( + firstPurchaseSuccessHandler(purchase), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + await firstMount.unmount(); + + mockUseIAP.mockReturnValue({ + ...hookValue, + availablePurchases: [{...purchase}], + }); + const secondMount = await render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + + if (!resolveFinish || !processingPromise) { + throw new Error('pending finish was not initialized'); + } + await act(async () => { + resolveFinish?.(); + await processingPromise; + await Promise.resolve(); + }); + + const remountedPurchaseSuccessHandler = mockOnPurchaseSuccess; + if (!remountedPurchaseSuccessHandler) { + throw new Error('remounted purchase success handler was not registered'); + } + await act(async () => { + await remountedPurchaseSuccessHandler({...purchase}); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + await secondMount.unmount(); + }); + + it('serializes two overlapping live subscription callbacks', async () => { + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + const purchases = [ + { + id: 'live-subscription-monthly', + originalTransactionIdentifierIOS: 'live-original-monthly', + productId: 'dev.hyo.martie.premium', + purchaseToken: 'live-monthly-jws', + store: 'apple', + transactionDate: Date.now(), + transactionReasonIOS: 'PURCHASE', + }, + { + id: 'live-subscription-yearly', + originalTransactionIdentifierIOS: 'live-original-yearly', + productId: 'dev.hyo.martie.premium_year', + purchaseToken: 'live-yearly-jws', + store: 'apple', + transactionDate: Date.now() + 1, + transactionReasonIOS: 'PURCHASE', + }, + ]; + const firstResult = { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.premium', + state: 'entitled', + store: 'apple', + }, + }; + let resolveFirst: ((value: typeof firstResult) => void) | undefined; + const firstVerification = new Promise((resolve) => { + resolveFirst = resolve; + }); + mockVerifyPurchaseWithProvider.mockImplementation((request) => { + const token = (request as {iapkit?: {apple?: {jws?: string}}}).iapkit + ?.apple?.jws; + if (token === 'live-monthly-jws') return firstVerification; + return Promise.resolve({ + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.premium_year', + state: 'entitled', + store: 'apple', + }, + }); + }); + await renderConnectedSubscriptionFlow(); + if (!mockOnPurchaseSuccess) { + throw new Error('purchase success handler was not registered'); + } + + const firstCallback = mockOnPurchaseSuccess(purchases[0]!); + const secondCallback = mockOnPurchaseSuccess(purchases[1]!); + await waitFor(() => { + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(1); + }); + expect(mockFinishTransaction).not.toHaveBeenCalled(); + + if (!resolveFirst) throw new Error('first verification was not pending'); + await act(async () => { + resolveFirst?.(firstResult); + await Promise.all([firstCallback, secondCallback]); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(2); + expect(mockFinishTransaction).toHaveBeenCalledTimes(2); + expect(mockFinishTransaction.mock.invocationCallOrder[0]).toBeLessThan( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[1]!, + ); + }); }); diff --git a/libraries/expo-iap/example/__tests__/vega-runtime.test.ts b/libraries/expo-iap/example/__tests__/vega-runtime.test.ts index a3a6a2f45..e63e7ee30 100644 --- a/libraries/expo-iap/example/__tests__/vega-runtime.test.ts +++ b/libraries/expo-iap/example/__tests__/vega-runtime.test.ts @@ -3,6 +3,7 @@ jest.mock('expo-constants', () => ({ default: { expoConfig: { extra: { + amazonRvsSandbox: 'true', iapkitApiKey: 'test-api-key', iapkitBaseUrl: 'http://localhost:3100', }, @@ -13,6 +14,9 @@ jest.mock('expo-constants', () => ({ import { createIapkitVerificationPayload, getDefaultVerificationMethod, + getDirectVerificationError, + getIapkitVerificationError, + rememberCompletedPurchaseKey, resolveIapkitVerificationBaseUrl, } from '../src/utils/vegaRuntime'; import type {Purchase} from '../../src/types'; @@ -34,6 +38,7 @@ describe('Vega runtime example helpers', () => { apiKey: 'test-api-key', baseUrl: 'http://localhost:3100', amazon: { + expectedProductId: 'dev.hyo.martie.10bulbs', receiptId: 'receipt-1', sandbox: true, }, @@ -101,4 +106,134 @@ describe('Vega runtime example helpers', () => { 'EXPO_PUBLIC_IAPKIT_BASE_URL not configured for Local (IAPKit) verification', ); }); + + it('accepts a valid Amazon Sandbox consumable for the expected product', () => { + expect( + getIapkitVerificationError( + { + provider: 'iapkit', + iapkit: { + environment: 'Sandbox', + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', + store: 'amazon', + }, + }, + 'dev.hyo.martie.10bulbs', + true, + ), + ).toBeNull(); + }); + + it('rejects Amazon verification without a product ID', () => { + expect( + getIapkitVerificationError( + { + provider: 'iapkit', + iapkit: { + environment: 'Sandbox', + isValid: true, + state: 'ready-to-consume', + store: 'amazon', + }, + }, + 'dev.hyo.martie.10bulbs', + true, + ), + ).toBe('IAPKit did not return a product ID for amazon'); + }); + + it('rejects the wrong Amazon environment', () => { + expect( + getIapkitVerificationError( + { + provider: 'iapkit', + iapkit: { + environment: 'Production', + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', + store: 'amazon', + }, + }, + 'dev.hyo.martie.10bulbs', + true, + ), + ).toContain('expected Sandbox'); + }); + + it('accepts ready-to-consume only for Google consumables', () => { + expect( + getIapkitVerificationError( + { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', + store: 'google', + }, + }, + 'dev.hyo.martie.10bulbs', + true, + ), + ).toBeNull(); + + expect( + getIapkitVerificationError( + { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', + store: 'google', + }, + }, + 'dev.hyo.martie.10bulbs', + false, + ), + ).toContain('cannot fulfill this non-consumable google purchase'); + + for (const state of ['entitled', 'pending-acknowledgment'] as const) { + expect( + getIapkitVerificationError( + { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state, + store: 'google', + }, + }, + 'dev.hyo.martie.10bulbs', + true, + ), + ).toBeNull(); + } + }); + + it('keeps the completed purchase cache bounded and refreshes recency', () => { + const completedKeys = new Set(['oldest', 'middle']); + + rememberCompletedPurchaseKey(completedKeys, 'oldest', 2); + rememberCompletedPurchaseKey(completedKeys, 'newest', 2); + + expect([...completedKeys]).toEqual(['oldest', 'newest']); + }); + + it('rejects explicit invalid direct-store results', () => { + expect( + getDirectVerificationError({ + isValid: false, + jwsRepresentation: '', + receiptData: '', + }), + ).toContain('invalid receipt'); + expect(getDirectVerificationError({success: false})).toContain( + 'rejected the entitlement', + ); + }); }); diff --git a/libraries/expo-iap/example/app.config.ts b/libraries/expo-iap/example/app.config.ts index 539684895..928bf901d 100644 --- a/libraries/expo-iap/example/app.config.ts +++ b/libraries/expo-iap/example/app.config.ts @@ -167,6 +167,7 @@ export default ({config}: ConfigContext): ExpoConfig => { }, extra: { ...config.extra, + amazonRvsSandbox: process.env.EXPO_PUBLIC_AMAZON_RVS_SANDBOX, iapkitApiKey: process.env.EXPO_PUBLIC_IAPKIT_API_KEY, iapkitBaseUrl: process.env.EXPO_PUBLIC_IAPKIT_BASE_URL, }, diff --git a/libraries/expo-iap/example/app/purchase-flow.tsx b/libraries/expo-iap/example/app/purchase-flow.tsx index eeec759a5..aabce37cf 100644 --- a/libraries/expo-iap/example/app/purchase-flow.tsx +++ b/libraries/expo-iap/example/app/purchase-flow.tsx @@ -1,4 +1,10 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { View, Text, @@ -37,7 +43,10 @@ import {useVegaTvSelection} from '../src/hooks/useVegaTvSelection'; import { createIapkitVerificationPayload, getDefaultVerificationMethod, + getDirectVerificationError, + getIapkitVerificationError, getPurchaseCleanupKey, + rememberCompletedPurchaseKey, resolveIapkitVerificationBaseUrl, showNativeAlert, type VerificationMethod, @@ -46,6 +55,14 @@ import { const CONSUMABLE_PRODUCT_ID_SET = new Set(CONSUMABLE_PRODUCT_IDS); const NON_CONSUMABLE_PRODUCT_ID_SET = new Set(NON_CONSUMABLE_PRODUCT_IDS); +type InFlightPurchaseTask = { + result: Promise<'abandoned' | 'failed' | 'finished'>; + complete: (result: 'abandoned' | 'failed' | 'finished') => void; +}; + +const inFlightPurchaseTasks = new Map(); +const completedPurchaseKeys = new Set(); + function isPurchaseFlowProduct(productId: string): boolean { return ( CONSUMABLE_PRODUCT_ID_SET.has(productId) || @@ -275,10 +292,10 @@ function PurchaseFlow({ {storefrontLoading ? 'Fetching…' : storefront - ? storefront - : storefrontError - ? 'Unavailable' - : 'Not available'} + ? storefront + : storefrontError + ? 'Unavailable' + : 'Not available'} {storefrontError ? ( @@ -313,10 +330,10 @@ function PurchaseFlow({ {verificationMethod === 'ignore' ? 'None (Skip)' : verificationMethod === 'local' - ? 'Local (Device)' - : verificationMethod === 'iapkit-localhost' - ? 'Local (IAPKit)' - : 'IAPKit'} + ? 'Local (Device)' + : verificationMethod === 'iapkit-localhost' + ? 'Local (IAPKit)' + : 'IAPKit'} Tap to change @@ -329,8 +346,8 @@ function PurchaseFlow({ {visibleProducts.length > 0 ? `${visibleProducts.length} product(s) available` : hasHiddenNonConsumables - ? 'All non-consumable products already purchased' - : 'Loading products...'} + ? 'All non-consumable products already purchased' + : 'Loading products...'} {visibleProducts.map((product, index) => ( @@ -348,15 +365,15 @@ function PurchaseFlow({ CONSUMABLE_PRODUCT_ID_SET.has(product.id) ? styles.productBadgeConsumable : NON_CONSUMABLE_PRODUCT_ID_SET.has(product.id) - ? styles.productBadgeNonConsumable - : null, + ? styles.productBadgeNonConsumable + : null, ]} > {CONSUMABLE_PRODUCT_ID_SET.has(product.id) ? 'Consumable product' : NON_CONSUMABLE_PRODUCT_ID_SET.has(product.id) - ? 'Non-consumable product' - : 'In-app product'} + ? 'Non-consumable product' + : 'In-app product'} (getDefaultVerificationMethod()); const verificationMethodRef = useRef(verificationMethod); - const isHandlingPurchaseRef = useRef(false); // Keep ref in sync with state useEffect(() => { @@ -747,192 +763,299 @@ function PurchaseFlowContainer() { const {showActionSheetWithOptions} = useActionSheet(); const cleanupPurchaseKeysRef = useRef(new Set()); + const purchaseSuccessHandlerRef = useRef< + (purchase: Purchase) => Promise + >(async () => {}); + const retryPurchaseRef = useRef<(purchase: Purchase) => Promise>( + async () => {}, + ); + const purchaseQueueTailRef = useRef>(Promise.resolve()); + const mountedRef = useRef(true); // ============================================================ // Step 1: initConnection // Step 2: subscribeEvent (onPurchaseSuccess, onPurchaseError) // ============================================================ - const { - connected, - products, - availablePurchases, - fetchProducts, - finishTransaction, - getAvailablePurchases, - verifyPurchase, - verifyPurchaseWithProvider, - } = useIAP({ - // ------------------------------------------------------------ - // Step 2: subscribeEvent - onPurchaseSuccess callback - // This handles the purchase flow after user completes payment - // ------------------------------------------------------------ - onPurchaseSuccess: async (purchase: Purchase) => { - // Prevent duplicate handling - if (isHandlingPurchaseRef.current) { - console.log('[PurchaseFlow] Already handling purchase, skipping'); - return; - } - - console.log('Purchase successful:', purchase.productId); - console.log('[PurchaseFlow] purchaseState:', purchase.purchaseState); - const productId = purchase.productId ?? ''; - if (!isPurchaseFlowProduct(productId)) { - console.log('[PurchaseFlow] ignoring non-purchase-flow product:', { - productId, - }); - return; - } + // Step 2: subscribeEvent - onPurchaseSuccess callback + // This handles both new and restored purchases through one verified path. + const handlePurchaseSuccess = async (purchase: Purchase): Promise => { + if (!mountedRef.current) return; + + const purchaseCleanupKey = getPurchaseCleanupKey(purchase); + + console.log('Purchase successful:', purchase.productId); + console.log('[PurchaseFlow] purchaseState:', purchase.purchaseState); + const productId = purchase.productId ?? ''; + if (!isPurchaseFlowProduct(productId)) { + console.log('[PurchaseFlow] ignoring non-purchase-flow product:', { + productId, + }); + cleanupPurchaseKeysRef.current.delete(purchaseCleanupKey); + return; + } - isHandlingPurchaseRef.current = true; - setLastPurchase(purchase); - setIsProcessing(false); + if (completedPurchaseKeys.has(purchaseCleanupKey)) { + console.log('[PurchaseFlow] ignoring duplicate purchase callback:', { + productId, + }); + return; + } + const inFlightTask = inFlightPurchaseTasks.get(purchaseCleanupKey); + if (inFlightTask) { + console.log('[PurchaseFlow] ignoring duplicate purchase task:', { + productId, + }); + void inFlightTask.result.then((result) => { + if (result === 'finished') { + rememberCompletedPurchaseKey( + completedPurchaseKeys, + purchaseCleanupKey, + ); + return; + } - setPurchaseResult( - `Purchase received (state: ${purchase.purchaseState}). Finishing transaction...`, - ); + cleanupPurchaseKeysRef.current.delete(purchaseCleanupKey); + if (result === 'abandoned' && mountedRef.current) { + void retryPurchaseRef.current(purchase); + } + }); + return; + } - const isConsumablePurchase = CONSUMABLE_PRODUCT_ID_SET.has(productId); - if (!isConsumablePurchase) { - console.log( - '[PurchaseFlow] Non-consumable purchase recorded:', - productId, - ); + let taskReleased = false; + let completeTask!: (result: 'abandoned' | 'failed' | 'finished') => void; + const taskResult = new Promise<'abandoned' | 'failed' | 'finished'>( + (resolve) => { + completeTask = resolve; + }, + ); + const task: InFlightPurchaseTask = { + result: taskResult, + complete: completeTask, + }; + const releasePurchaseTask = ( + result: 'abandoned' | 'failed' | 'finished' = 'failed', + ): void => { + if (taskReleased) return; + taskReleased = true; + if (inFlightPurchaseTasks.get(purchaseCleanupKey) === task) { + inFlightPurchaseTasks.delete(purchaseCleanupKey); } + task.complete(result); + }; + inFlightPurchaseTasks.set(purchaseCleanupKey, task); + + setLastPurchase(purchase); + setIsProcessing(false); + + setPurchaseResult( + `Purchase received (state: ${purchase.purchaseState}). Verifying purchase...`, + ); - // ------------------------------------------------------------ - // Step 4: four verification selections - // - ignore: Skip verification (for testing) - // - local: Direct Apple/Google verification on the device - // - iapkit-localhost: IAPKit provider through the local server - // - iapkit: IAPKit provider through the hosted service - // ------------------------------------------------------------ - const currentVerificationMethod = verificationMethodRef.current; - console.log('[PurchaseFlow] About to verify purchase:', { - verificationMethod: currentVerificationMethod, + const isConsumablePurchase = CONSUMABLE_PRODUCT_ID_SET.has(productId); + if (!isConsumablePurchase) { + console.log( + '[PurchaseFlow] Non-consumable purchase recorded:', productId, - willVerify: currentVerificationMethod !== 'ignore' && !!productId, - }); + ); + } - if (currentVerificationMethod !== 'ignore' && productId) { - setIsProcessing(true); - try { - if (currentVerificationMethod === 'local') { - console.log('[PurchaseFlow] Verifying with Local (Device)...'); - await verifyPurchase({ - apple: {sku: productId}, - google: { - sku: productId, - packageName: 'dev.hyo.martie', - purchaseToken: purchase.purchaseToken ?? '', - accessToken: '', // Requires a server-issued OAuth token. - }, - }); - console.log('[PurchaseFlow] Local (Device) verification completed'); - } else { - const verificationLabel = - currentVerificationMethod === 'iapkit-localhost' - ? 'Local (IAPKit)' - : 'IAPKit'; - console.log( - `[PurchaseFlow] Verifying with ${verificationLabel}...`, + // ------------------------------------------------------------ + // Step 4: four verification selections + // - ignore: Skip verification (for testing) + // - local: Direct Apple/Google verification on the device + // - iapkit-localhost: IAPKit provider through the local server + // - iapkit: IAPKit provider through the hosted service + // ------------------------------------------------------------ + const currentVerificationMethod = verificationMethodRef.current; + console.log('[PurchaseFlow] About to verify purchase:', { + verificationMethod: currentVerificationMethod, + productId, + willVerify: currentVerificationMethod !== 'ignore' && !!productId, + }); + + if (currentVerificationMethod !== 'ignore' && productId) { + setIsProcessing(true); + try { + if (currentVerificationMethod === 'local') { + console.log('[PurchaseFlow] Verifying with Local (Device)...'); + const result = await verifyPurchase({ + apple: {sku: productId}, + google: { + sku: productId, + packageName: 'dev.hyo.martie', + purchaseToken: purchase.purchaseToken ?? '', + accessToken: '', // Requires a server-issued OAuth token. + }, + }); + const verificationError = getDirectVerificationError(result); + if (verificationError) { + throw new Error(verificationError); + } + console.log('[PurchaseFlow] Local (Device) verification completed'); + } else { + const verificationLabel = + currentVerificationMethod === 'iapkit-localhost' + ? 'Local (IAPKit)' + : 'IAPKit'; + console.log(`[PurchaseFlow] Verifying with ${verificationLabel}...`); + + const jwsOrToken = purchase.purchaseToken ?? ''; + if (!jwsOrToken) { + throw new Error( + 'No purchase token available for IAPKit verification', ); + } - const jwsOrToken = purchase.purchaseToken ?? ''; - if (!jwsOrToken) { - throw new Error( - 'No purchase token available for IAPKit verification', - ); - } + const baseUrl = resolveIapkitVerificationBaseUrl( + currentVerificationMethod, + ); + const iapkitPayload = createIapkitVerificationPayload( + purchase, + jwsOrToken, + baseUrl, + ); + const verifyRequest: VerifyPurchaseWithProviderProps = { + provider: 'iapkit', + iapkit: iapkitPayload, + }; + console.log( + `[PurchaseFlow] Sending ${verificationLabel} verification request`, + ); - const baseUrl = resolveIapkitVerificationBaseUrl( - currentVerificationMethod, - ); - const iapkitPayload = createIapkitVerificationPayload( - purchase, - jwsOrToken, - baseUrl, - ); - const verifyRequest: VerifyPurchaseWithProviderProps = { - provider: 'iapkit', - iapkit: iapkitPayload, - }; - console.log( - `[PurchaseFlow] Sending ${verificationLabel} verification request`, - ); + const result = await verifyPurchaseWithProvider(verifyRequest); + console.log('[PurchaseFlow] IAPKit verification result:', result); + + const verificationError = getIapkitVerificationError( + result, + productId, + isConsumablePurchase, + ); + if (verificationError) { + throw new Error(verificationError); + } + + if (result.iapkit && mountedRef.current) { + const iapkitResult = result.iapkit; + const statusEmoji = iapkitResult.isValid ? '✅' : '⚠️'; + const stateText = iapkitResult.state || 'unknown'; - const result = await verifyPurchaseWithProvider(verifyRequest); - console.log('[PurchaseFlow] IAPKit verification result:', result); - - if (result.iapkit) { - const iapkitResult = result.iapkit; - const statusEmoji = iapkitResult.isValid ? '✅' : '⚠️'; - const stateText = iapkitResult.state || 'unknown'; - - showNativeAlert( - `${statusEmoji} ${verificationLabel} Verification`, - `Valid: ${iapkitResult.isValid}\nState: ${stateText}\nStore: ${ - iapkitResult.store || 'unknown' - }`, - ); - } + showNativeAlert( + `${statusEmoji} ${verificationLabel} Verification`, + `Valid: ${iapkitResult.isValid}\nState: ${stateText}\nStore: ${ + iapkitResult.store || 'unknown' + }`, + ); } - } catch (error) { - console.log('[PurchaseFlow] Verification failed:', error); + } + } catch (error) { + console.log('[PurchaseFlow] Verification failed:', error); + const message = extractErrorMessage(error); + if (mountedRef.current) { + setPurchaseResult(`Purchase verification failed: ${message}`); showNativeAlert( 'Verification Failed', - `Purchase verification failed: ${extractErrorMessage(error)}`, + `Purchase verification failed: ${message}`, ); - } finally { + cleanupPurchaseKeysRef.current.delete(purchaseCleanupKey); + } + releasePurchaseTask(mountedRef.current ? 'failed' : 'abandoned'); + return; + } finally { + if (mountedRef.current) { setIsProcessing(false); } } + } - // ------------------------------------------------------------ - // Step 6: finish transaction - // IMPORTANT: Must call finishTransaction to complete the purchase - // ------------------------------------------------------------ - let didFinishTransaction = false; - const finishCleanupKey = getPurchaseCleanupKey(purchase); - cleanupPurchaseKeysRef.current.add(finishCleanupKey); - try { - await finishTransaction({ - purchase, - isConsumable: isConsumablePurchase, - }); - didFinishTransaction = true; - setPurchaseResult( - `Purchase completed and finished successfully (state: ${purchase.purchaseState}).`, - ); - } catch (error) { - const message = extractErrorMessage(error); + if (!mountedRef.current) { + releasePurchaseTask('abandoned'); + return; + } + + // ------------------------------------------------------------ + // Step 6: finish transaction + // IMPORTANT: Must call finishTransaction to complete the purchase + // ------------------------------------------------------------ + try { + await finishTransaction({ + purchase, + isConsumable: isConsumablePurchase, + }); + rememberCompletedPurchaseKey(completedPurchaseKeys, purchaseCleanupKey); + releasePurchaseTask('finished'); + } catch (error) { + const message = extractErrorMessage(error); + console.log('[PurchaseFlow] finishTransaction failed:', error); + releasePurchaseTask(mountedRef.current ? 'failed' : 'abandoned'); + if (mountedRef.current) { setPurchaseResult( `Purchase completed, but finishTransaction failed: ${message}`, ); - console.log('[PurchaseFlow] finishTransaction failed:', error); - cleanupPurchaseKeysRef.current.delete(finishCleanupKey); + cleanupPurchaseKeysRef.current.delete(purchaseCleanupKey); } + return; + } - // ------------------------------------------------------------ - // Step 5: grant entitlement - // Refresh available purchases to update UI state - // ------------------------------------------------------------ - try { - await getAvailablePurchases(); - console.log('[PurchaseFlow] Available purchases refreshed'); - } catch (error) { - console.log( - '[PurchaseFlow] Failed to refresh available purchases:', - error, - ); - } + if (!mountedRef.current) return; - if (didFinishTransaction) { - showNativeAlert('Success', 'Purchase completed successfully!'); - } + setPurchaseResult( + `Purchase completed and finished successfully (state: ${purchase.purchaseState}).`, + ); - // Reset handling state after all operations complete - isHandlingPurchaseRef.current = false; - }, + // ------------------------------------------------------------ + // Step 5: grant entitlement + // Refresh available purchases to update UI state + // ------------------------------------------------------------ + try { + await getAvailablePurchases(); + console.log('[PurchaseFlow] Available purchases refreshed'); + } catch (error) { + console.log( + '[PurchaseFlow] Failed to refresh available purchases:', + error, + ); + } + + if (mountedRef.current) { + showNativeAlert('Success', 'Purchase completed successfully!'); + } + }; + + const enqueuePurchase = useCallback((purchase: Purchase): Promise => { + const cleanupKey = getPurchaseCleanupKey(purchase); + if (completedPurchaseKeys.has(cleanupKey)) { + return Promise.resolve(); + } + if (cleanupPurchaseKeysRef.current.has(cleanupKey)) { + return Promise.resolve(); + } + cleanupPurchaseKeysRef.current.add(cleanupKey); + + const queued = purchaseQueueTailRef.current.then(() => + purchaseSuccessHandlerRef.current(purchase), + ); + purchaseQueueTailRef.current = queued.catch((error) => { + cleanupPurchaseKeysRef.current.delete(cleanupKey); + console.log( + '[PurchaseFlow] queued purchase handler failed unexpectedly:', + error, + ); + }); + return purchaseQueueTailRef.current; + }, []); + + const { + connected, + products, + availablePurchases, + fetchProducts, + finishTransaction, + getAvailablePurchases, + verifyPurchase, + verifyPurchaseWithProvider, + } = useIAP({ + onPurchaseSuccess: enqueuePurchase, // ------------------------------------------------------------ // Step 2: subscribeEvent - onPurchaseError callback // ------------------------------------------------------------ @@ -941,15 +1064,27 @@ function PurchaseFlowContainer() { setIsProcessing(false); if (error.code === ErrorCode.UserCancelled) { setPurchaseResult('Purchase cancelled by user'); - isHandlingPurchaseRef.current = false; return; } setPurchaseResult(`Purchase failed: ${error.message}`); - isHandlingPurchaseRef.current = false; }, }); + useLayoutEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + purchaseSuccessHandlerRef.current = async () => {}; + retryPurchaseRef.current = async () => {}; + }; + }, []); + + useLayoutEffect(() => { + purchaseSuccessHandlerRef.current = handlePurchaseSuccess; + retryPurchaseRef.current = enqueuePurchase; + }); + const didFetchRef = useRef(false); useEffect(() => { @@ -977,7 +1112,6 @@ function PurchaseFlowContainer() { }); } else if (!connected) { didFetchRef.current = false; - cleanupPurchaseKeysRef.current.clear(); console.log('[PurchaseFlow] Not fetching products - not connected'); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -995,31 +1129,11 @@ function PurchaseFlowContainer() { ); continue; } - const cleanupKey = getPurchaseCleanupKey(purchase); - if (cleanupPurchaseKeysRef.current.has(cleanupKey)) continue; - cleanupPurchaseKeysRef.current.add(cleanupKey); - - const isConsumablePurchase = CONSUMABLE_PRODUCT_ID_SET.has(productId); - finishTransaction({ - purchase, - isConsumable: isConsumablePurchase, - }) - .then(() => { - console.log('[PurchaseFlow] cleaned up available purchase:', { - productId, - isConsumable: isConsumablePurchase, - }); - }) - .catch((error) => { - cleanupPurchaseKeysRef.current.delete(cleanupKey); - console.log( - '[PurchaseFlow] available purchase cleanup failed:', - error, - ); - }); + if (completedPurchaseKeys.has(cleanupKey)) continue; + void enqueuePurchase(purchase); } - }, [availablePurchases, connected, finishTransaction]); + }, [availablePurchases, connected, enqueuePurchase]); const handleRefreshAvailablePurchases = useCallback(async () => { if (refreshingAvailablePurchases) { diff --git a/libraries/expo-iap/example/app/subscription-flow.tsx b/libraries/expo-iap/example/app/subscription-flow.tsx index 78eb1cb0c..bac2ba439 100644 --- a/libraries/expo-iap/example/app/subscription-flow.tsx +++ b/libraries/expo-iap/example/app/subscription-flow.tsx @@ -1,4 +1,10 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { View, Text, @@ -35,12 +41,24 @@ import {useVegaTvSelection} from '../src/hooks/useVegaTvSelection'; import { createIapkitVerificationPayload, getDefaultVerificationMethod, + getDirectVerificationError, + getIapkitVerificationError, getPurchaseCleanupKey, + rememberCompletedPurchaseKey, resolveIapkitVerificationBaseUrl, showNativeAlert, type VerificationMethod, } from '../src/utils/vegaRuntime'; +type InFlightSubscriptionTask = { + result: Promise<'abandoned' | 'failed' | 'finished'>; + complete: (result: 'abandoned' | 'failed' | 'finished') => void; + owner: object; +}; + +const inFlightSubscriptionTasks = new Map(); +const completedSubscriptionKeys = new Set(); + // Subscription tier mapping - defined outside component to avoid recreation const TIER_MAP: Record = { 'dev.hyo.martie.premium': 1, // Monthly tier @@ -270,8 +288,8 @@ function SubscriptionFlow({ message: canUpgrade ? 'Upgrade available' : isDowngrade - ? 'Downgrade option' - : undefined, + ? 'Downgrade option' + : undefined, }; }, [getCurrentSubscription, isCancelled], @@ -696,10 +714,10 @@ function SubscriptionFlow({ {verificationMethod === 'ignore' ? 'None (Skip)' : verificationMethod === 'local' - ? 'Local (Device)' - : verificationMethod === 'iapkit-localhost' - ? 'Local (IAPKit)' - : 'IAPKit'} + ? 'Local (Device)' + : verificationMethod === 'iapkit-localhost' + ? 'Local (IAPKit)' + : 'IAPKit'} ▼ @@ -1382,353 +1400,451 @@ function SubscriptionFlowContainer() { const {showActionSheetWithOptions} = useActionSheet(); - const isHandlingPurchaseRef = useRef(false); const isCheckingStatusRef = useRef(false); const didFetchSubsRef = useRef(false); const cleanupPurchaseKeysRef = useRef(new Set()); - - const resetHandlingState = useCallback(() => { - isHandlingPurchaseRef.current = false; - }, []); + const purchaseSuccessHandlerRef = useRef< + (purchase: Purchase) => Promise + >(async () => {}); + const retryPurchaseRef = useRef<(purchase: Purchase) => Promise>( + async () => {}, + ); + const purchaseQueueTailRef = useRef>(Promise.resolve()); + const taskOwnerRef = useRef({}); + const mountedRef = useRef(true); // ============================================================ // Step 1: initConnection (automatic) // Step 2: subscribeEvent (onPurchaseSuccess, onPurchaseError) // ============================================================ - const { - connected, - subscriptions, - availablePurchases, - fetchProducts, - finishTransaction, - getAvailablePurchases, - getActiveSubscriptions, - activeSubscriptions, - verifyPurchase, - verifyPurchaseWithProvider, - } = useIAP({ - // ------------------------------------------------------------ - // Step 2: onPurchaseSuccess - New Purchase Flow - // iOS: Check transactionState (purchased/pending/failed/deferred) - // Android: purchaseState check - // ------------------------------------------------------------ - onPurchaseSuccess: async (purchase) => { - console.log('Subscription successful:', purchase.productId); - console.log('[SubscriptionFlow] onPurchaseSuccess called'); - console.log( - '[SubscriptionFlow] Current verificationMethod ref:', - verificationMethodRef.current, - ); + // Step 2: onPurchaseSuccess - New Purchase Flow + // Restored purchases reuse this verified path before they are finished. + const handlePurchaseSuccess = async (purchase: Purchase): Promise => { + if (!mountedRef.current) return; - const productId = purchase.productId ?? ''; - if (!isSubscriptionFlowProduct(productId)) { - console.log('[SubscriptionFlow] ignoring non-subscription product:', { - productId, - }); - return; - } + const purchaseCleanupKey = getPurchaseCleanupKey(purchase); + + console.log('Subscription successful:', purchase.productId); + console.log('[SubscriptionFlow] onPurchaseSuccess called'); + console.log( + '[SubscriptionFlow] Current verificationMethod ref:', + verificationMethodRef.current, + ); - setLastPurchase(purchase); + const productId = purchase.productId ?? ''; + if (!isSubscriptionFlowProduct(productId)) { + console.log('[SubscriptionFlow] ignoring non-subscription product:', { + productId, + }); + cleanupPurchaseKeysRef.current.delete(purchaseCleanupKey); + return; + } - if (isHandlingPurchaseRef.current) { - console.log('Already handling a purchase, skipping duplicate callback'); - console.log( - '[SubscriptionFlow] Early return: already handling purchase', - ); - return; + if (completedSubscriptionKeys.has(purchaseCleanupKey)) { + console.log('[SubscriptionFlow] ignoring duplicate purchase callback:', { + productId, + }); + return; + } + const inFlightTask = inFlightSubscriptionTasks.get(purchaseCleanupKey); + if (inFlightTask) { + const shouldRefreshAfterRemount = + inFlightTask.owner !== taskOwnerRef.current; + console.log('[SubscriptionFlow] ignoring duplicate purchase task:', { + productId, + }); + void inFlightTask.result.then((result) => { + if (result === 'finished') { + rememberCompletedPurchaseKey( + completedSubscriptionKeys, + purchaseCleanupKey, + ); + if (shouldRefreshAfterRemount && mountedRef.current) { + void getActiveSubscriptions().catch((error) => { + console.log( + 'Failed to refresh subscriptions after remount:', + extractErrorMessage(error), + ); + }); + } + return; + } + + cleanupPurchaseKeysRef.current.delete(purchaseCleanupKey); + if (result === 'abandoned' && mountedRef.current) { + void retryPurchaseRef.current(purchase); + } + }); + return; + } + + let taskReleased = false; + let completeTask!: (result: 'abandoned' | 'failed' | 'finished') => void; + const taskResult = new Promise<'abandoned' | 'failed' | 'finished'>( + (resolve) => { + completeTask = resolve; + }, + ); + const task: InFlightSubscriptionTask = { + result: taskResult, + complete: completeTask, + owner: taskOwnerRef.current, + }; + const releasePurchaseTask = ( + result: 'abandoned' | 'failed' | 'finished' = 'failed', + ): void => { + if (taskReleased) return; + taskReleased = true; + if (inFlightSubscriptionTasks.get(purchaseCleanupKey) === task) { + inFlightSubscriptionTasks.delete(purchaseCleanupKey); } + task.complete(result); + }; + inFlightSubscriptionTasks.set(purchaseCleanupKey, task); - isHandlingPurchaseRef.current = true; + setLastPurchase(purchase); - let isPurchased = false; - let isRestoration = false; - const normalizedPurchaseStore = purchase.store.toLowerCase(); - const hasAndroidPurchaseIdentity = Boolean( - purchase.purchaseToken || + let isPurchased = false; + let isRestoration = false; + const normalizedPurchaseStore = purchase.store.toLowerCase(); + const hasAndroidPurchaseIdentity = Boolean( + purchase.purchaseToken || purchase.id || purchase.transactionId || purchase.productId, - ); + ); - if (Platform.OS === 'ios' && normalizedPurchaseStore === 'apple') { - const hasValidToken = !!( - purchase.purchaseToken && - typeof purchase.purchaseToken === 'string' && - purchase.purchaseToken.length > 0 - ); - const hasValidTransactionId = !!(purchase.id && purchase.id.length > 0); + if (Platform.OS === 'ios' && normalizedPurchaseStore === 'apple') { + const hasValidToken = !!( + purchase.purchaseToken && + typeof purchase.purchaseToken === 'string' && + purchase.purchaseToken.length > 0 + ); + const hasValidTransactionId = !!(purchase.id && purchase.id.length > 0); - isPurchased = hasValidToken || hasValidTransactionId; - isRestoration = Boolean( - 'originalTransactionIdentifierIOS' in purchase && + isPurchased = hasValidToken || hasValidTransactionId; + isRestoration = Boolean( + 'originalTransactionIdentifierIOS' in purchase && purchase.originalTransactionIdentifierIOS && purchase.originalTransactionIdentifierIOS !== purchase.id && 'transactionReasonIOS' in purchase && purchase.transactionReasonIOS && purchase.transactionReasonIOS !== 'PURCHASE', - ); - - console.log('iOS Purchase Analysis:'); - console.log(' hasValidToken:', hasValidToken); - console.log(' hasValidTransactionId:', hasValidTransactionId); - console.log(' isPurchased:', isPurchased); - console.log(' isRestoration:', isRestoration); - console.log( - ' originalTransactionId:', - 'originalTransactionIdentifierIOS' in purchase - ? purchase.originalTransactionIdentifierIOS - : undefined, - ); - console.log(' currentTransactionId:', purchase.id); - console.log( - ' transactionReason:', - 'transactionReasonIOS' in purchase - ? purchase.transactionReasonIOS - : undefined, - ); - } else if ( - Platform.OS === 'android' || - normalizedPurchaseStore === 'google' || - normalizedPurchaseStore === 'amazon' || - normalizedPurchaseStore === 'horizon' - ) { - isPurchased = hasAndroidPurchaseIdentity; - isRestoration = false; + ); - console.log('Android Purchase Analysis:'); - console.log(' runtime:', Platform.OS); - console.log(' store:', normalizedPurchaseStore || 'unknown'); - console.log( - ' hasAndroidPurchaseIdentity:', - hasAndroidPurchaseIdentity, - ); - console.log(' isPurchased:', isPurchased); - console.log(' isRestoration:', isRestoration); - } + console.log('iOS Purchase Analysis:'); + console.log(' hasValidToken:', hasValidToken); + console.log(' hasValidTransactionId:', hasValidTransactionId); + console.log(' isPurchased:', isPurchased); + console.log(' isRestoration:', isRestoration); + console.log( + ' originalTransactionId:', + 'originalTransactionIdentifierIOS' in purchase + ? purchase.originalTransactionIdentifierIOS + : undefined, + ); + console.log(' currentTransactionId:', purchase.id); + console.log( + ' transactionReason:', + 'transactionReasonIOS' in purchase + ? purchase.transactionReasonIOS + : undefined, + ); + } else if ( + Platform.OS === 'android' || + normalizedPurchaseStore === 'google' || + normalizedPurchaseStore === 'amazon' || + normalizedPurchaseStore === 'horizon' + ) { + isPurchased = hasAndroidPurchaseIdentity; + isRestoration = false; + + console.log('Android Purchase Analysis:'); + console.log(' runtime:', Platform.OS); + console.log(' store:', normalizedPurchaseStore || 'unknown'); + console.log(' hasAndroidPurchaseIdentity:', hasAndroidPurchaseIdentity); + console.log(' isPurchased:', isPurchased); + console.log(' isRestoration:', isRestoration); + } - if (!isPurchased) { - console.log( - 'Purchase callback received but purchase validation failed', - ); + if (!isPurchased) { + console.log('Purchase callback received but purchase validation failed'); + if (mountedRef.current) { setPurchaseResult('Purchase validation failed.'); setIsProcessing(false); showNativeAlert( 'Purchase Issue', 'Purchase could not be validated. Please try again.', ); - resetHandlingState(); - return; + cleanupPurchaseKeysRef.current.delete(purchaseCleanupKey); } + releasePurchaseTask(mountedRef.current ? 'failed' : 'abandoned'); + return; + } - // ------------------------------------------------------------ - // Restoring Purchases Flow - // iOS: StoreKit fetches from Apple ID's purchase history - // Android: queryPurchases returns purchase history - // Note: iOS requires "Restore Purchases" button per App Store guidelines - // ------------------------------------------------------------ - if (isRestoration) { - console.log( - '[SubscriptionFlow] This is a restoration, skipping verification', - ); - setPurchaseResult('Subscription restored; finishing transaction...'); + // ------------------------------------------------------------ + // Restoring Purchases Flow + // iOS: StoreKit fetches from Apple ID's purchase history + // Android: queryPurchases returns purchase history + // Note: iOS requires "Restore Purchases" button per App Store guidelines + // ------------------------------------------------------------ + console.log( + isRestoration + ? '[SubscriptionFlow] Verifying restored subscription before finishing' + : '[SubscriptionFlow] Verifying new subscription before finishing', + ); - // Step 6: finish transaction (restoration) - const finishCleanupKey = getPurchaseCleanupKey(purchase); - cleanupPurchaseKeysRef.current.add(finishCleanupKey); - try { - await finishTransaction({ - purchase, - isConsumable: false, + setPurchaseResult( + isRestoration + ? 'Subscription restored; verifying purchase...' + : 'Subscription received; verifying purchase...', + ); + + // ------------------------------------------------------------ + // Step 4: four verification selections + // - ignore: Skip verification (for testing) + // - local: Direct Apple/Google verification on the device + // - iapkit-localhost: IAPKit provider through the local server + // - iapkit: IAPKit provider through the hosted service + // + // Server-side validation recommended for: + // iOS: App Store Server API + Server Notifications V2 + // Android: Google Play Developer API + RTDN + // ------------------------------------------------------------ + const currentVerificationMethod = verificationMethodRef.current; + let iapkitVerifyRequest: VerifyPurchaseWithProviderProps | null = null; + console.log('[SubscriptionFlow] About to verify purchase:', { + verificationMethod: currentVerificationMethod, + productId, + willVerify: currentVerificationMethod !== 'ignore' && !!productId, + }); + + if (currentVerificationMethod !== 'ignore' && productId) { + setIsProcessing(true); + try { + if (currentVerificationMethod === 'local') { + console.log('[SubscriptionFlow] Verifying with Local (Device)...'); + const result = await verifyPurchase({ + apple: {sku: productId}, + google: { + sku: productId, + packageName: 'dev.hyo.martie', + purchaseToken: purchase.purchaseToken ?? '', + accessToken: '', // Requires a server-issued OAuth token. + isSub: true, + }, }); - setPurchaseResult('Subscription restored and finished successfully.'); - } catch (error) { - setPurchaseResult( - `Subscription restored, but finishTransaction failed: ${extractErrorMessage( - error, - )}`, + const verificationError = getDirectVerificationError(result); + if (verificationError) { + throw new Error(verificationError); + } + console.log( + '[SubscriptionFlow] Local (Device) verification completed', + ); + } else { + const verificationLabel = + currentVerificationMethod === 'iapkit-localhost' + ? 'Local (IAPKit)' + : 'IAPKit'; + console.log( + `[SubscriptionFlow] Verifying with ${verificationLabel}...`, ); - console.log('finishTransaction failed during restoration:', error); - cleanupPurchaseKeysRef.current.delete(finishCleanupKey); - } - - console.log('✅ Subscription restoration completed'); - - // Step 5: grant entitlement - refresh active subscriptions - try { - await getActiveSubscriptions(); - } catch (error) { - console.log('Failed to refresh status:', error); - } - - resetHandlingState(); - setIsProcessing(false); - return; - } - console.log( - '[SubscriptionFlow] Not a restoration, proceeding to verification check', - ); - - setPurchaseResult('Subscription received; finishing transaction...'); - - // ------------------------------------------------------------ - // Step 4: four verification selections - // - ignore: Skip verification (for testing) - // - local: Direct Apple/Google verification on the device - // - iapkit-localhost: IAPKit provider through the local server - // - iapkit: IAPKit provider through the hosted service - // - // Server-side validation recommended for: - // iOS: App Store Server API + Server Notifications V2 - // Android: Google Play Developer API + RTDN - // ------------------------------------------------------------ - const currentVerificationMethod = verificationMethodRef.current; - let iapkitVerifyRequest: VerifyPurchaseWithProviderProps | null = null; - console.log('[SubscriptionFlow] About to verify purchase:', { - verificationMethod: currentVerificationMethod, - productId, - willVerify: currentVerificationMethod !== 'ignore' && !!productId, - }); - if (currentVerificationMethod !== 'ignore' && productId) { - setIsProcessing(true); - try { - if (currentVerificationMethod === 'local') { - console.log('[SubscriptionFlow] Verifying with Local (Device)...'); - await verifyPurchase({ - apple: {sku: productId}, - google: { - sku: productId, - packageName: 'dev.hyo.martie', - purchaseToken: purchase.purchaseToken ?? '', - accessToken: '', // Requires a server-issued OAuth token. - isSub: true, - }, - }); - console.log( - '[SubscriptionFlow] Local (Device) verification completed', - ); - } else { - const verificationLabel = - currentVerificationMethod === 'iapkit-localhost' - ? 'Local (IAPKit)' - : 'IAPKit'; - console.log( - `[SubscriptionFlow] Verifying with ${verificationLabel}...`, + const jwsOrToken = purchase.purchaseToken ?? ''; + if (!jwsOrToken) { + throw new Error( + 'No purchase token available for IAPKit verification', ); + } - const jwsOrToken = purchase.purchaseToken ?? ''; - if (!jwsOrToken) { - throw new Error( - 'No purchase token available for IAPKit verification', - ); - } + const baseUrl = resolveIapkitVerificationBaseUrl( + currentVerificationMethod, + ); + const iapkitPayload = createIapkitVerificationPayload( + purchase, + jwsOrToken, + baseUrl, + ); + const verifyRequest: VerifyPurchaseWithProviderProps = { + provider: 'iapkit', + iapkit: iapkitPayload, + }; + iapkitVerifyRequest = verifyRequest; + console.log( + `[SubscriptionFlow] Sending ${verificationLabel} verification request`, + ); - const baseUrl = resolveIapkitVerificationBaseUrl( - currentVerificationMethod, - ); - const iapkitPayload = createIapkitVerificationPayload( - purchase, - jwsOrToken, - baseUrl, - ); - const verifyRequest: VerifyPurchaseWithProviderProps = { - provider: 'iapkit', - iapkit: iapkitPayload, - }; - iapkitVerifyRequest = verifyRequest; - console.log( - `[SubscriptionFlow] Sending ${verificationLabel} verification request`, - ); + const result = await verifyPurchaseWithProvider(verifyRequest); + console.log('[SubscriptionFlow] IAPKit verification result:', result); - const result = await verifyPurchaseWithProvider(verifyRequest); - console.log( - '[SubscriptionFlow] IAPKit verification result:', - result, - ); + const verificationError = getIapkitVerificationError( + result, + productId, + false, + ); + if (verificationError) { + throw new Error(verificationError); + } - if (result.iapkit) { - const iapkitResult = result.iapkit; - const statusEmoji = iapkitResult.isValid ? '✅' : '⚠️'; - const stateText = iapkitResult.state || 'unknown'; + if (result.iapkit && mountedRef.current) { + const iapkitResult = result.iapkit; + const statusEmoji = iapkitResult.isValid ? '✅' : '⚠️'; + const stateText = iapkitResult.state || 'unknown'; - showNativeAlert( - `${statusEmoji} ${verificationLabel} Verification`, - `Valid: ${iapkitResult.isValid}\nState: ${stateText}\nStore: ${ - iapkitResult.store || 'unknown' - }`, - ); - } + showNativeAlert( + `${statusEmoji} ${verificationLabel} Verification`, + `Valid: ${iapkitResult.isValid}\nState: ${stateText}\nStore: ${ + iapkitResult.store || 'unknown' + }`, + ); } - } catch (error) { - console.log('[SubscriptionFlow] Verification failed:', error); + } + } catch (error) { + console.log('[SubscriptionFlow] Verification failed:', error); + const message = extractErrorMessage(error); + if (mountedRef.current) { + setPurchaseResult(`Subscription verification failed: ${message}`); showNativeAlert( 'Verification Failed', - `Purchase verification failed: ${extractErrorMessage(error)}`, + `Purchase verification failed: ${message}`, ); - } finally { + cleanupPurchaseKeysRef.current.delete(purchaseCleanupKey); + } + releasePurchaseTask(mountedRef.current ? 'failed' : 'abandoned'); + return; + } finally { + if (mountedRef.current) { setIsProcessing(false); } } + } - // ------------------------------------------------------------ - // Step 6: finish transaction - // IMPORTANT: Must call finishTransaction to complete the purchase - // Subscriptions are NOT consumable (isConsumable: false) - // ------------------------------------------------------------ - let didFinishTransaction = false; - const finishCleanupKey = getPurchaseCleanupKey(purchase); - cleanupPurchaseKeysRef.current.add(finishCleanupKey); - try { - await finishTransaction({ - purchase, - isConsumable: false, - }); - didFinishTransaction = true; - setPurchaseResult('Subscription activated and finished successfully.'); - } catch (error) { + if (!mountedRef.current) { + releasePurchaseTask('abandoned'); + return; + } + + // ------------------------------------------------------------ + // Step 6: finish transaction + // IMPORTANT: Must call finishTransaction to complete the purchase + // Subscriptions are NOT consumable (isConsumable: false) + // ------------------------------------------------------------ + try { + await finishTransaction({ + purchase, + isConsumable: false, + }); + rememberCompletedPurchaseKey( + completedSubscriptionKeys, + purchaseCleanupKey, + ); + releasePurchaseTask('finished'); + } catch (error) { + console.log('finishTransaction failed:', error); + releasePurchaseTask(mountedRef.current ? 'failed' : 'abandoned'); + if (mountedRef.current) { + setIsProcessing(false); setPurchaseResult( - `Subscription activated, but finishTransaction failed: ${extractErrorMessage( - error, - )}`, + `Subscription ${ + isRestoration ? 'restored' : 'activated' + }, but finishTransaction failed: ${extractErrorMessage(error)}`, ); - console.log('finishTransaction failed (new purchase):', error); - cleanupPurchaseKeysRef.current.delete(finishCleanupKey); + cleanupPurchaseKeysRef.current.delete(purchaseCleanupKey); } + return; + } - if (didFinishTransaction) { - if (Platform.OS === 'android' && iapkitVerifyRequest) { - try { - const refreshedResult = - await verifyPurchaseWithProvider(iapkitVerifyRequest); - console.log( - '[SubscriptionFlow] IAPKit state after finishTransaction:', - refreshedResult, - ); - } catch (error) { - console.log( - '[SubscriptionFlow] IAPKit post-finish verification failed:', - error, - ); - } - } - showNativeAlert('Success', 'New subscription activated successfully!'); - console.log('✅ New subscription purchase completed'); - } + if (!mountedRef.current) return; - // ------------------------------------------------------------ - // Step 5: grant entitlement - // Refresh active subscriptions to update UI state - // getActiveSubscriptions: Returns only currently active subscriptions - // ------------------------------------------------------------ + setPurchaseResult( + isRestoration + ? 'Subscription restored and finished successfully.' + : 'Subscription activated and finished successfully.', + ); + + if (Platform.OS === 'android' && iapkitVerifyRequest) { try { - await getActiveSubscriptions(); + const refreshedResult = await verifyPurchaseWithProvider( + iapkitVerifyRequest, + ); + console.log( + '[SubscriptionFlow] IAPKit state after finishTransaction:', + refreshedResult, + ); } catch (error) { - console.log('Failed to refresh status:', error); + console.log( + '[SubscriptionFlow] IAPKit post-finish verification failed:', + error, + ); } + } - resetHandlingState(); + if (!mountedRef.current) return; + + showNativeAlert( + 'Success', + isRestoration + ? 'Subscription restored successfully!' + : 'New subscription activated successfully!', + ); + console.log( + isRestoration + ? '✅ Subscription restoration completed' + : '✅ New subscription purchase completed', + ); + + // ------------------------------------------------------------ + // Step 5: grant entitlement + // Refresh active subscriptions to update UI state + // getActiveSubscriptions: Returns only currently active subscriptions + // ------------------------------------------------------------ + try { + await getActiveSubscriptions(); + } catch (error) { + console.log('Failed to refresh status:', error); + } + + if (mountedRef.current) { setIsProcessing(false); - }, + } + }; + + const enqueuePurchase = useCallback((purchase: Purchase): Promise => { + const cleanupKey = getPurchaseCleanupKey(purchase); + if (completedSubscriptionKeys.has(cleanupKey)) { + return Promise.resolve(); + } + if (cleanupPurchaseKeysRef.current.has(cleanupKey)) { + return Promise.resolve(); + } + cleanupPurchaseKeysRef.current.add(cleanupKey); + + const queued = purchaseQueueTailRef.current.then(() => + purchaseSuccessHandlerRef.current(purchase), + ); + purchaseQueueTailRef.current = queued.catch((error) => { + cleanupPurchaseKeysRef.current.delete(cleanupKey); + console.log( + '[SubscriptionFlow] queued purchase handler failed unexpectedly:', + error, + ); + }); + return purchaseQueueTailRef.current; + }, []); + + const { + connected, + subscriptions, + availablePurchases, + fetchProducts, + finishTransaction, + getAvailablePurchases, + getActiveSubscriptions, + activeSubscriptions, + verifyPurchase, + verifyPurchaseWithProvider, + } = useIAP({ + onPurchaseSuccess: enqueuePurchase, // ------------------------------------------------------------ // Step 2: onPurchaseError callback // Handle purchase failures (user cancelled, payment failed, etc.) @@ -1736,7 +1852,6 @@ function SubscriptionFlowContainer() { onPurchaseError: (error: PurchaseError) => { console.log('Subscription failed:', error.message); setIsProcessing(false); - resetHandlingState(); if (error.code === ErrorCode.UserCancelled) { setPurchaseResult('Subscription cancelled by user'); return; @@ -1746,6 +1861,20 @@ function SubscriptionFlowContainer() { }, }); + useLayoutEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + purchaseSuccessHandlerRef.current = async () => {}; + retryPurchaseRef.current = async () => {}; + }; + }, []); + + useLayoutEffect(() => { + purchaseSuccessHandlerRef.current = handlePurchaseSuccess; + retryPurchaseRef.current = enqueuePurchase; + }); + // ============================================================ // Checking Subscription Status (Periodically) // ============================================================ @@ -1801,7 +1930,6 @@ function SubscriptionFlowContainer() { console.log('Product loading request sent - waiting for results...'); } else if (!connected) { didFetchSubsRef.current = false; - cleanupPurchaseKeysRef.current.clear(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [connected]); @@ -1818,29 +1946,11 @@ function SubscriptionFlowContainer() { ); continue; } - const cleanupKey = getPurchaseCleanupKey(purchase); - if (cleanupPurchaseKeysRef.current.has(cleanupKey)) continue; - cleanupPurchaseKeysRef.current.add(cleanupKey); - - finishTransaction({ - purchase, - isConsumable: false, - }) - .then(() => { - console.log('[SubscriptionFlow] cleaned up available purchase:', { - productId, - }); - }) - .catch((error) => { - cleanupPurchaseKeysRef.current.delete(cleanupKey); - console.log( - '[SubscriptionFlow] available purchase cleanup failed:', - error, - ); - }); + if (completedSubscriptionKeys.has(cleanupKey)) continue; + void enqueuePurchase(purchase); } - }, [availablePurchases, connected, finishTransaction]); + }, [availablePurchases, connected, enqueuePurchase]); // ============================================================ // On App Launch - Check Existing Subscriptions diff --git a/libraries/expo-iap/example/scripts/build-vega-example.mjs b/libraries/expo-iap/example/scripts/build-vega-example.mjs index da027a8b2..e993cc68b 100644 --- a/libraries/expo-iap/example/scripts/build-vega-example.mjs +++ b/libraries/expo-iap/example/scripts/build-vega-example.mjs @@ -15,10 +15,11 @@ const packageRoot = path.resolve(exampleRoot, '..'); const tempRoot = path.join(os.tmpdir(), 'openiap-expo-iap-vega-example'); const tempPackageSourceRoot = path.join(tempRoot, 'openiap-expo-iap-src'); const buildType = process.argv[2] === 'Release' ? 'Release' : 'Debug'; -const {iapkitApiKey, iapkitBaseUrl} = loadVegaBuildEnvironment({ - buildType, - projectRoot: exampleRoot, -}); +const {amazonRvsSandbox, iapkitApiKey, iapkitBaseUrl} = + loadVegaBuildEnvironment({ + buildType, + projectRoot: exampleRoot, + }); const vegaPackageId = 'dev.hyo.openiap.expo.example'; const vegaComponentId = `${vegaPackageId}.main`; const vegaAppName = 'ExpoIapVegaExample'; @@ -195,6 +196,7 @@ export const getStringAsync = async () => value; `export default { expoConfig: { extra: { + amazonRvsSandbox: ${JSON.stringify(amazonRvsSandbox)}, iapkitApiKey: ${JSON.stringify(iapkitApiKey)}, iapkitBaseUrl: ${JSON.stringify(iapkitBaseUrl)}, }, diff --git a/libraries/expo-iap/example/scripts/vega-build-config.mjs b/libraries/expo-iap/example/scripts/vega-build-config.mjs index 147bd57cc..2399ee801 100644 --- a/libraries/expo-iap/example/scripts/vega-build-config.mjs +++ b/libraries/expo-iap/example/scripts/vega-build-config.mjs @@ -13,6 +13,7 @@ export const loadVegaBuildEnvironment = ({ }); return { + amazonRvsSandbox: systemEnv.EXPO_PUBLIC_AMAZON_RVS_SANDBOX ?? '', iapkitApiKey: systemEnv.EXPO_PUBLIC_IAPKIT_API_KEY ?? '', iapkitBaseUrl: systemEnv.EXPO_PUBLIC_IAPKIT_BASE_URL ?? '', }; diff --git a/libraries/expo-iap/example/scripts/vega-build-config.test.mjs b/libraries/expo-iap/example/scripts/vega-build-config.test.mjs index 9fe93d078..0884b820f 100644 --- a/libraries/expo-iap/example/scripts/vega-build-config.test.mjs +++ b/libraries/expo-iap/example/scripts/vega-build-config.test.mjs @@ -18,6 +18,7 @@ test('loads Expo public IAPKit values from the normal environment file chain', ( fs.writeFileSync( path.join(projectRoot, '.env'), [ + 'EXPO_PUBLIC_AMAZON_RVS_SANDBOX=false', 'EXPO_PUBLIC_IAPKIT_API_KEY=env-key', 'EXPO_PUBLIC_IAPKIT_BASE_URL=http://env.example', '', @@ -26,6 +27,7 @@ test('loads Expo public IAPKit values from the normal environment file chain', ( fs.writeFileSync( path.join(projectRoot, '.env.local'), [ + 'EXPO_PUBLIC_AMAZON_RVS_SANDBOX=true', 'EXPO_PUBLIC_IAPKIT_API_KEY=local-key', 'EXPO_PUBLIC_IAPKIT_BASE_URL=http://local.example', '', @@ -40,6 +42,7 @@ test('loads Expo public IAPKit values from the normal environment file chain', ( }); assert.deepEqual(result, { + amazonRvsSandbox: 'true', iapkitApiKey: 'local-key', iapkitBaseUrl: 'http://local.example', }); @@ -57,6 +60,7 @@ test('keeps explicitly exported values ahead of environment files', () => { fs.writeFileSync( path.join(projectRoot, '.env.local'), [ + 'EXPO_PUBLIC_AMAZON_RVS_SANDBOX=false', 'EXPO_PUBLIC_IAPKIT_API_KEY=file-key', 'EXPO_PUBLIC_IAPKIT_BASE_URL=http://file.example', '', @@ -64,6 +68,7 @@ test('keeps explicitly exported values ahead of environment files', () => { ); const systemEnv = { + EXPO_PUBLIC_AMAZON_RVS_SANDBOX: 'true', EXPO_PUBLIC_IAPKIT_API_KEY: 'exported-key', EXPO_PUBLIC_IAPKIT_BASE_URL: 'http://exported.example', }; @@ -74,6 +79,7 @@ test('keeps explicitly exported values ahead of environment files', () => { }); assert.deepEqual(result, { + amazonRvsSandbox: 'true', iapkitApiKey: 'exported-key', iapkitBaseUrl: 'http://exported.example', }); diff --git a/libraries/expo-iap/example/src/utils/vegaRuntime.ts b/libraries/expo-iap/example/src/utils/vegaRuntime.ts index 8543ca2b1..65cf2db46 100644 --- a/libraries/expo-iap/example/src/utils/vegaRuntime.ts +++ b/libraries/expo-iap/example/src/utils/vegaRuntime.ts @@ -2,7 +2,9 @@ import {Alert, Platform} from 'react-native'; import Constants from 'expo-constants'; import type { Purchase, + VerifyPurchaseResult, VerifyPurchaseWithProviderProps, + VerifyPurchaseWithProviderResult, } from '../../../src/types'; export type IapkitVerificationPayload = NonNullable< @@ -10,6 +12,7 @@ export type IapkitVerificationPayload = NonNullable< >; type ExpoExtraWithIapkit = { + amazonRvsSandbox?: string; iapkitApiKey?: string; iapkitBaseUrl?: string; }; @@ -30,6 +33,13 @@ function getConfiguredIapkitBaseUrl(): string | undefined { return extra?.iapkitBaseUrl ?? process.env.EXPO_PUBLIC_IAPKIT_BASE_URL; } +function isAmazonRvsSandboxEnabled(): boolean { + const extra = Constants.expoConfig?.extra as ExpoExtraWithIapkit | undefined; + const configuredValue = + extra?.amazonRvsSandbox ?? process.env.EXPO_PUBLIC_AMAZON_RVS_SANDBOX; + return configuredValue === 'true'; +} + export function getDefaultVerificationMethod( apiKey: string | null | undefined = getConfiguredIapkitApiKey(), baseUrl: string | null | undefined = getConfiguredIapkitBaseUrl(), @@ -99,6 +109,103 @@ export function showNativeAlert(title: string, message?: string): void { } } +function isIapkitStateReadyForFulfillment( + verified: NonNullable, + isConsumable: boolean, +): boolean { + switch (verified.store) { + case 'apple': + case 'amazon': + return ( + verified.state === (isConsumable ? 'ready-to-consume' : 'entitled') + ); + case 'google': + return ( + verified.state === 'entitled' || + verified.state === 'pending-acknowledgment' || + (isConsumable && verified.state === 'ready-to-consume') + ); + default: + return false; + } +} + +export function getIapkitVerificationError( + result: VerifyPurchaseWithProviderResult, + expectedProductId: string, + isConsumable: boolean, +): string | null { + const verified = result.iapkit; + if (!verified) { + const providerErrors = result.errors + ?.map((error) => + error.code ? `[${error.code}] ${error.message}` : error.message, + ) + .filter(Boolean); + return providerErrors?.length + ? providerErrors.join('\n') + : 'IAPKit did not return a verification result'; + } + + if (!verified.isValid) { + return `IAPKit rejected the purchase (state: ${verified.state}, store: ${verified.store})`; + } + + if (!verified.productId) { + return `IAPKit did not return a product ID for ${verified.store}`; + } + + if (verified.productId !== expectedProductId) { + return `IAPKit verified ${verified.productId}, expected ${expectedProductId}`; + } + + if (verified.store === 'amazon') { + const expectedEnvironment = isAmazonRvsSandboxEnabled() + ? 'Sandbox' + : 'Production'; + if (verified.environment !== expectedEnvironment) { + return `IAPKit verified Amazon in ${ + verified.environment ?? 'an unknown environment' + }, expected ${expectedEnvironment}`; + } + } + + if (!isIapkitStateReadyForFulfillment(verified, isConsumable)) { + return `IAPKit state ${verified.state} cannot fulfill this ${ + isConsumable ? 'consumable' : 'non-consumable' + } ${verified.store} purchase`; + } + + return null; +} + +export function getDirectVerificationError( + result: VerifyPurchaseResult, +): string | null { + if ('isValid' in result && result.isValid === false) { + return 'Store verification returned an invalid receipt'; + } + if ('success' in result && result.success === false) { + return 'Store verification rejected the entitlement'; + } + return null; +} + +export function rememberCompletedPurchaseKey( + completedKeys: Set, + key: string, + maxSize = 100, +): void { + completedKeys.delete(key); + completedKeys.add(key); + + while (completedKeys.size > maxSize) { + const oldestKey = completedKeys.values().next().value; + if (typeof oldestKey !== 'string') break; + completedKeys.delete(oldestKey); + } +} + export function createIapkitVerificationPayload( purchase: Purchase, purchaseToken: string, @@ -117,8 +224,9 @@ export function createIapkitVerificationPayload( { apiKey, amazon: { + expectedProductId: purchase.productId, receiptId: purchaseToken, - sandbox: __DEV__, + sandbox: isAmazonRvsSandboxEnabled(), }, }, baseUrl, diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index 40ada0ba2..c742c7b92 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -1938,11 +1938,12 @@ describe('Public API (index.ts)', () => { provider: 'iapkit', iapkit: { clientPayload: null, + environment: 'Sandbox', futureProviderField: 'preserved', isValid: true, productId: null, state: 'ready-to-consume', - store: 'google', + store: 'amazon', }, }; (ExpoIapModule.verifyPurchaseWithProvider as jest.Mock) = jest @@ -1953,8 +1954,12 @@ describe('Public API (index.ts)', () => { provider: 'iapkit' as const, iapkit: { apiKey: 'test-api-key', - apple: {jws: 'jws-token'}, - google: {purchaseToken: 'purchase-token'}, + amazon: { + expectedProductId: 'amazon.premium.monthly', + receiptId: 'amazon-receipt', + sandbox: true, + userId: 'amazon-user', + }, }, }; @@ -1964,12 +1969,13 @@ describe('Public API (index.ts)', () => { request, ); expect(result.iapkit).toEqual({ + environment: 'Sandbox', futureProviderField: 'preserved', isValid: true, state: 'ready-to-consume', - store: 'google', + store: 'amazon', }); - expect(result.iapkit?.store).toBe('google'); + expect(result.iapkit?.store).toBe('amazon'); }); it('throws on unsupported platform', async () => { diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts index 5ee96feab..fb2e16017 100644 --- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -65,7 +65,7 @@ const createService = (): jest.Mocked => notifyFulfillment: jest.fn(async () => ({ responseCode: 1, })), - } as unknown as jest.Mocked); + }) as unknown as jest.Mocked; describe('Amazon Vega Expo adapter', () => { it('initializes without fetching Amazon user data', async () => { @@ -1154,6 +1154,7 @@ describe('Amazon Vega Expo adapter', () => { const fetchMock = jest.fn( async (_input: RequestInfo | URL, _init?: RequestInit) => Response.json({ + environment: 'Sandbox', isValid: true, state: 'ENTITLED', store: 'amazon', @@ -1170,6 +1171,7 @@ describe('Amazon Vega Expo adapter', () => { iapkit: { apiKey: 'kit-key', amazon: { + expectedProductId: 'amazon.premium.monthly', receiptId: 'receipt-vega-1', sandbox: true, }, @@ -1178,6 +1180,7 @@ describe('Amazon Vega Expo adapter', () => { ).resolves.toEqual({ provider: 'iapkit', iapkit: { + environment: 'Sandbox', isValid: true, state: 'entitled', store: 'amazon', @@ -1201,6 +1204,7 @@ describe('Amazon Vega Expo adapter', () => { store: 'amazon', userId: 'amazon-user', receiptId: 'receipt-vega-1', + expectedProductId: 'amazon.premium.monthly', sandbox: true, }); } finally { @@ -1631,6 +1635,44 @@ describe('Amazon Vega Expo adapter', () => { } }); + it.each([42, 'Staging'])( + 'rejects an invalid IAPKit environment: %s', + async (environment) => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn(async () => + Response.json({ + environment, + isValid: true, + state: 'ENTITLED', + store: 'amazon', + }), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.PurchaseVerificationFailed, + message: 'IAPKit returned malformed response (HTTP 200).', + }); + } finally { + globalThis.fetch = originalFetch; + } + }, + ); + it('rejects successful IAPKit payloads for another store', async () => { const service = createService(); const originalFetch = globalThis.fetch; diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 920bb7db2..275f0b6d7 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -1296,9 +1296,11 @@ export const verifyPurchase: MutationField<'verifyPurchase'> = async ( * // apple: { jws: purchase.purchaseToken }, * // google: { purchaseToken: purchase.purchaseToken }, * amazon: { + * expectedProductId: purchase.productId, * userId: amazonUserId, * receiptId: purchase.purchaseToken, - * sandbox: __DEV__, + * // Enable only for App Tester after the IAPKit project opt-in. + * sandbox: amazonSandboxEnabled, * } * } * }); diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 5934056f1..7e9696c79 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1791,6 +1791,11 @@ export interface RequestSubscriptionPropsByPlatforms { } export interface RequestVerifyPurchaseWithIapkitAmazonProps { + /** + * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + * Optional Amazon product id that must match the product id verified by RVS. + */ + expectedProductId?: (string | null); /** Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). */ receiptId: string; /** Use Amazon RVS Cloud Sandbox for App Tester receipts. */ @@ -1848,6 +1853,12 @@ export interface RequestVerifyPurchaseWithIapkitResult { * Apple or Google receipt is valid, and a payload exists for that product. */ clientPayload?: (IapkitProductClientPayload | null); + /** + * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + * Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + * `Production` on handled Amazon verification results. + */ + environment?: (string | null); /** * True when the purchase is valid and actionable. * Only entitled, pending-acknowledgment, or ready-to-consume return true. diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts index b124eb660..a63311558 100644 --- a/libraries/expo-iap/src/vega-adapter.ts +++ b/libraries/expo-iap/src/vega-adapter.ts @@ -1177,8 +1177,20 @@ export function createExpoIapVegaModule( `IAPKit returned malformed response (HTTP ${status}).`, ); } + const environment = json.environment; + if ( + environment != null && + (typeof environment !== 'string' || + (environment !== 'Sandbox' && environment !== 'Production')) + ) { + throw createVegaError( + ErrorCode.PurchaseVerificationFailed, + `IAPKit returned malformed response (HTTP ${status}).`, + ); + } return { + ...(environment == null ? {} : {environment}), isValid: json.isValid, ...(productId == null ? {} : {productId}), state: normalizeIapkitState(json.state), @@ -1247,6 +1259,9 @@ export function createExpoIapVegaModule( store: 'amazon', userId, receiptId, + ...(amazon.expectedProductId == null + ? {} + : {expectedProductId: amazon.expectedProductId}), ...(amazon.sandbox == null ? {} : {sandbox: amazon.sandbox}), }), signal: controller.signal, diff --git a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt index 212ffd6df..ddd581ff4 100644 --- a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt @@ -1037,6 +1037,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act (amazon["sandbox"] as? Boolean)?.let { sandbox -> amazonMap["sandbox"] = sandbox } + (amazon["expectedProductId"] as? String)?.let { expectedProductId -> + amazonMap["expectedProductId"] = expectedProductId + } (amazon["userId"] as? String)?.let { userId -> amazonMap["userId"] = userId } diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index b4c7bcfb8..057a7c7d1 100644 --- a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -979,6 +979,9 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { if let sandbox = amazon["sandbox"] as? Bool { amazonDict["sandbox"] = sandbox } + if let expectedProductId = amazon["expectedProductId"] as? String { + amazonDict["expectedProductId"] = expectedProductId + } if let userId = amazon["userId"] as? String { let trimmedUserId = userId.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedUserId.isEmpty { diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index aeb47dcda..7237efbdf 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -1848,6 +1848,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { 'includeClientPayload': iapkit.includeClientPayload, if (iapkit.amazon != null) 'amazon': { + if (iapkit.amazon!.expectedProductId != null) + 'expectedProductId': iapkit.amazon!.expectedProductId, 'receiptId': iapkit.amazon!.receiptId, if (iapkit.amazon!.sandbox != null) 'sandbox': iapkit.amazon!.sandbox, @@ -1919,6 +1921,18 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); } + final environmentValue = itemMap['environment']; + if (environmentValue != null && + (environmentValue is! String || + (environmentValue != 'Sandbox' && + environmentValue != 'Production'))) { + throw PurchaseError( + code: gentype.ErrorCode.PurchaseVerificationFailed, + message: + 'Malformed IAPKit verification result: environment must be Sandbox or Production', + ); + } + gentype.IapkitProductClientPayload? clientPayload; final clientPayloadValue = itemMap['clientPayload']; if (clientPayloadValue != null) { @@ -1977,6 +1991,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { return gentype.RequestVerifyPurchaseWithIapkitResult( clientPayload: clientPayload, + environment: environmentValue as String?, isValid: isValid, productId: productIdValue as String?, state: gentype.IapkitPurchaseState.fromJson( diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 06fd9d24a..b839a4840 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -3333,6 +3333,7 @@ class RequestPurchaseResultPurchases extends RequestPurchaseResult { class RequestVerifyPurchaseWithIapkitResult { const RequestVerifyPurchaseWithIapkitResult({ this.clientPayload, + this.environment, required this.isValid, this.productId, required this.state, @@ -3343,6 +3344,10 @@ class RequestVerifyPurchaseWithIapkitResult { /// Public product payload when includeClientPayload was requested, the /// Apple or Google receipt is valid, and a payload exists for that product. final IapkitProductClientPayload? clientPayload; + /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + /// Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + /// `Production` on handled Amazon verification results. + final String? environment; /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. /// Callers must still match productId and use the platform plus app-owned product @@ -3358,6 +3363,7 @@ class RequestVerifyPurchaseWithIapkitResult { factory RequestVerifyPurchaseWithIapkitResult.fromJson(Map json) { return RequestVerifyPurchaseWithIapkitResult( clientPayload: json['clientPayload'] != null ? IapkitProductClientPayload.fromJson(json['clientPayload'] as Map) : null, + environment: json['environment'] as String?, isValid: json['isValid'] as bool, productId: json['productId'] as String?, state: IapkitPurchaseState.fromJson(json['state'] as String), @@ -3369,6 +3375,7 @@ class RequestVerifyPurchaseWithIapkitResult { return { '__typename': 'RequestVerifyPurchaseWithIapkitResult', 'clientPayload': clientPayload?.toJson(), + 'environment': environment, 'isValid': isValid, 'productId': productId, 'state': state.toJson(), @@ -4774,11 +4781,15 @@ class RequestSubscriptionPropsByPlatforms { class RequestVerifyPurchaseWithIapkitAmazonProps { const RequestVerifyPurchaseWithIapkitAmazonProps({ + this.expectedProductId, required this.receiptId, this.sandbox, this.userId, }); + /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + /// Optional Amazon product id that must match the product id verified by RVS. + final String? expectedProductId; /// Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). final String receiptId; /// Use Amazon RVS Cloud Sandbox for App Tester receipts. @@ -4788,6 +4799,7 @@ class RequestVerifyPurchaseWithIapkitAmazonProps { factory RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(Map json) { return RequestVerifyPurchaseWithIapkitAmazonProps( + expectedProductId: json['expectedProductId'] as String?, receiptId: json['receiptId'] as String, sandbox: json['sandbox'] as bool?, userId: json['userId'] as String?, @@ -4796,6 +4808,7 @@ class RequestVerifyPurchaseWithIapkitAmazonProps { Map toJson() { return { + 'expectedProductId': expectedProductId, 'receiptId': receiptId, 'sandbox': sandbox, 'userId': userId, diff --git a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index d6c321847..f4b3ed070 100644 --- a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -914,6 +914,9 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { if let sandbox = amazon["sandbox"] as? Bool { amazonDict["sandbox"] = sandbox } + if let expectedProductId = amazon["expectedProductId"] as? String { + amazonDict["expectedProductId"] = expectedProductId + } if let userId = amazon["userId"] as? String { let trimmedUserId = userId.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedUserId.isEmpty { diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart index 2ab8115fe..c16a594f7 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart @@ -2802,8 +2802,9 @@ void main() { expect(result.iapkit!.store, types.IapStore.Google); }); - test('sends correct payload for Amazon verification', () async { + test('sends Amazon payload and preserves valid environments', () async { final calls = []; + var environment = 'Sandbox'; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { calls.add(call); @@ -2817,6 +2818,7 @@ void main() { 'isValid': true, 'state': 'entitled', 'store': 'amazon', + 'environment': environment, }, }); } @@ -2829,16 +2831,18 @@ void main() { await iap.initConnection(); + const verificationRequest = types.RequestVerifyPurchaseWithIapkitProps( + apiKey: 'test-api-key', + amazon: types.RequestVerifyPurchaseWithIapkitAmazonProps( + expectedProductId: 'dev.hyo.martie.10bulbs', + receiptId: 'amzn1.receipt.test', + sandbox: true, + userId: 'amzn1.account.test', + ), + ); final result = await iap.verifyPurchaseWithProvider( provider: types.PurchaseVerificationProvider.Iapkit, - iapkit: const types.RequestVerifyPurchaseWithIapkitProps( - apiKey: 'test-api-key', - amazon: types.RequestVerifyPurchaseWithIapkitAmazonProps( - receiptId: 'amzn1.receipt.test', - sandbox: true, - userId: 'amzn1.account.test', - ), - ), + iapkit: verificationRequest, ); final verifyCall = calls.singleWhere( @@ -2857,13 +2861,25 @@ void main() { iapkitPayload['amazon'] as Map, ); expect(amazonPayload['receiptId'], 'amzn1.receipt.test'); + expect( + amazonPayload['expectedProductId'], + 'dev.hyo.martie.10bulbs', + ); expect(amazonPayload['sandbox'], true); expect(amazonPayload['userId'], 'amzn1.account.test'); expect(result.iapkit, isNotNull); expect(result.iapkit!.isValid, true); + expect(result.iapkit!.environment, 'Sandbox'); expect(result.iapkit!.state, types.IapkitPurchaseState.Entitled); expect(result.iapkit!.store, types.IapStore.Amazon); + + environment = 'Production'; + final productionResult = await iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: verificationRequest, + ); + expect(productionResult.iapkit!.environment, 'Production'); }); test('throws PurchaseError on platform exception', () async { @@ -3123,5 +3139,43 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed IAPKit environment', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + switch (call.method) { + case 'initConnection': + return true; + case 'verifyPurchaseWithProvider': + return { + 'provider': 'iapkit', + 'iapkit': { + 'environment': true, + 'isValid': true, + 'state': 'entitled', + 'store': 'amazon', + }, + }; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + await iap.initConnection(); + + await expectLater( + iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + amazon: types.RequestVerifyPurchaseWithIapkitAmazonProps( + receiptId: 'amzn1.receipt.test', + ), + ), + ), + throwsA(isA()), + ); + }); }); } diff --git a/libraries/godot-iap/Example/tests/test_types_only.gd b/libraries/godot-iap/Example/tests/test_types_only.gd index 2b468b380..86893f9bc 100644 --- a/libraries/godot-iap/Example/tests/test_types_only.gd +++ b/libraries/godot-iap/Example/tests/test_types_only.gd @@ -305,6 +305,47 @@ func _test_iapkit_product_client_payload() -> void: _assert_equal(round_trip.client_payload.version, 2.0, "Nested payload should round-trip version") _assert_equal(round_trip.client_payload.updated_at, 1720000000000.0, "Nested payload should round-trip updatedAt") + var amazon_props = Types.RequestVerifyPurchaseWithIapkitAmazonProps.from_dict({ + "expectedProductId": "dev.hyo.martie.10bulbs", + "receiptId": "amzn1.receipt.test", + "sandbox": true, + "userId": "amzn1.account.test" + }) + var amazon_props_round_trip = Types.RequestVerifyPurchaseWithIapkitAmazonProps.from_dict( + amazon_props.to_dict() + ) + _assert_equal( + amazon_props_round_trip.expected_product_id, + "dev.hyo.martie.10bulbs", + "Amazon verification props should round-trip expectedProductId" + ) + _assert_equal( + amazon_props_round_trip.receipt_id, + "amzn1.receipt.test", + "Amazon verification props should round-trip receiptId" + ) + + var amazon_result = Types.RequestVerifyPurchaseWithIapkitResult.from_dict({ + "environment": "Sandbox", + "isValid": true, + "productId": "dev.hyo.martie.10bulbs", + "state": "ready-to-consume", + "store": "amazon" + }) + var amazon_result_round_trip = Types.RequestVerifyPurchaseWithIapkitResult.from_dict( + amazon_result.to_dict() + ) + _assert_equal( + amazon_result_round_trip.environment, + "Sandbox", + "Amazon verification result should round-trip environment" + ) + _assert_equal( + amazon_result_round_trip.store, + Types.IapStore.AMAZON, + "Amazon verification result should round-trip store" + ) + # ============================================ # VoidResult Tests diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index b03a5e6b2..bb0b4a65d 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -2755,6 +2755,8 @@ class RentalDetailsAndroid: class RequestVerifyPurchaseWithIapkitResult: var store: IapStore + ## Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment selected by IAPKit. Present as `Sandbox` or `Production` on handled Amazon verification results. + var environment: Variant = null ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. var is_valid: bool = false ## The current state of the purchase. @@ -2772,6 +2774,8 @@ class RequestVerifyPurchaseWithIapkitResult: obj.store = IAP_STORE_FROM_STRING.get(enum_str, IapStore.UNKNOWN) else: obj.store = enum_str + if data.has("environment") and data["environment"] != null: + obj.environment = data["environment"] if data.has("isValid") and data["isValid"] != null: obj.is_valid = data["isValid"] if data.has("state") and data["state"] != null: @@ -2795,6 +2799,8 @@ class RequestVerifyPurchaseWithIapkitResult: dict["store"] = IAP_STORE_VALUES[store] else: dict["store"] = store + if environment != null: + dict["environment"] = environment dict["isValid"] = is_valid if IAPKIT_PURCHASE_STATE_VALUES.has(state): dict["state"] = IAPKIT_PURCHASE_STATE_VALUES[state] @@ -4400,6 +4406,8 @@ class RequestSubscriptionPropsByPlatforms: return dict class RequestVerifyPurchaseWithIapkitAmazonProps: + ## Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Optional Amazon product id that must match the product id verified by RVS. + var expected_product_id: Variant = null ## Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). var user_id: Variant = null ## Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). @@ -4409,6 +4417,8 @@ class RequestVerifyPurchaseWithIapkitAmazonProps: static func from_dict(data: Dictionary) -> RequestVerifyPurchaseWithIapkitAmazonProps: var obj = RequestVerifyPurchaseWithIapkitAmazonProps.new() + if data.has("expectedProductId") and data["expectedProductId"] != null: + obj.expected_product_id = data["expectedProductId"] if data.has("userId") and data["userId"] != null: obj.user_id = data["userId"] if data.has("receiptId") and data["receiptId"] != null: @@ -4419,6 +4429,8 @@ class RequestVerifyPurchaseWithIapkitAmazonProps: func to_dict() -> Dictionary: var dict = {} + if expected_product_id != null: + dict["expectedProductId"] = expected_product_id if user_id != null: dict["userId"] = user_id if receipt_id != null: diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index eab62c702..7d1f1caaf 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt @@ -2194,6 +2194,7 @@ internal class InAppPurchaseAndroid( apple = null, amazon = amazonOptions?.let { amazon -> AndroidVerifyPurchaseWithIapkitAmazonProps( + expectedProductId = amazon.expectedProductId, receiptId = amazon.receiptId, sandbox = amazon.sandbox, userId = amazon.userId @@ -2219,6 +2220,7 @@ internal class InAppPurchaseAndroid( version = payload.version ) }, + environment = androidResult.environment, isValid = androidResult.isValid, productId = androidResult.productId, state = IapkitPurchaseState.fromJson(androidResult.state.toJson()), diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt index e0dad4555..a99298350 100644 --- a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt @@ -13,4 +13,19 @@ class IapkitBaseUrlBridgeTest { assertTrue(source.contains("baseUrl = iapkitOptions.baseUrl")) } + + @Test + fun platformBridgesPreserveAmazonVerificationFields() { + val androidSource = File( + "src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt" + ).readText() + val iosSource = File( + "src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt" + ).readText() + + assertTrue(androidSource.contains("expectedProductId = amazon.expectedProductId")) + assertTrue(androidSource.contains("environment = androidResult.environment")) + assertTrue(iosSource.contains("environment = environment")) + assertTrue(iosSource.contains("\"Sandbox\", \"Production\"")) + } } diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index 8c7849bd4..3b0c5a561 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -3434,6 +3434,14 @@ public data class RequestVerifyPurchaseWithIapkitResult( var productId: String? = null private set + /** + * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + * Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + * `Production` on handled Amazon verification results. + */ + var environment: String? = null + private set + constructor( isValid: Boolean, state: IapkitPurchaseState, @@ -3449,6 +3457,23 @@ public data class RequestVerifyPurchaseWithIapkitResult( this.productId = productId } + constructor( + isValid: Boolean, + state: IapkitPurchaseState, + store: IapStore, + clientPayload: IapkitProductClientPayload?, + productId: String?, + environment: String?, + ) : this( + isValid = isValid, + state = state, + store = store, + ) { + this.clientPayload = clientPayload + this.productId = productId + this.environment = environment + } + companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitResult { return RequestVerifyPurchaseWithIapkitResult( @@ -3457,6 +3482,7 @@ public data class RequestVerifyPurchaseWithIapkitResult( store = runCatching { (json["store"] as? String)?.let { IapStore.fromJson(it) } }.getOrNull() ?: IapStore.Unknown, clientPayload = (json["clientPayload"] as? Map)?.let { IapkitProductClientPayload.fromJson(it) }, productId = json["productId"] as? String, + environment = json["environment"] as? String, ) } } @@ -3464,6 +3490,7 @@ public data class RequestVerifyPurchaseWithIapkitResult( fun toJson(): Map = mapOf( "__typename" to "RequestVerifyPurchaseWithIapkitResult", "store" to store.toJson(), + "environment" to environment, "isValid" to isValid, "state" to state.toJson(), "productId" to productId, @@ -4959,24 +4986,48 @@ public data class RequestVerifyPurchaseWithIapkitAmazonProps( */ val userId: String? = null ) { + + /** + * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + * Optional Amazon product id that must match the product id verified by RVS. + */ + var expectedProductId: String? = null + private set + + constructor( + receiptId: String, + sandbox: Boolean? = null, + userId: String? = null, + expectedProductId: String?, + ) : this( + receiptId = receiptId, + sandbox = sandbox, + userId = userId, + ) { + this.expectedProductId = expectedProductId + } + companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitAmazonProps? { val receiptId = json["receiptId"] as? String val sandbox = json["sandbox"] as? Boolean val userId = json["userId"] as? String + val expectedProductId = json["expectedProductId"] as? String if (receiptId == null) return null return RequestVerifyPurchaseWithIapkitAmazonProps( receiptId = receiptId, sandbox = sandbox, userId = userId, + expectedProductId = expectedProductId, ) } } fun toJson(): Map = mapOf( + "expectedProductId" to expectedProductId, + "userId" to userId, "receiptId" to receiptId, "sandbox" to sandbox, - "userId" to userId, ) } diff --git a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/VerificationTest.kt b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/VerificationTest.kt index f4341ec58..cc081ff73 100644 --- a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/VerificationTest.kt +++ b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/VerificationTest.kt @@ -458,6 +458,24 @@ class VerificationTest { assertNotNull(json["apple"]) } + @Test + fun testAmazonIapkitPropsRoundTripPreservesExpectedProductId() { + val original = RequestVerifyPurchaseWithIapkitAmazonProps( + expectedProductId = "dev.hyo.martie.10bulbs", + receiptId = "amzn1.receipt.test", + sandbox = true, + userId = "amzn1.account.test" + ) + + val restored = RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(original.toJson()) + + assertNotNull(restored) + assertEquals(original.expectedProductId, restored.expectedProductId) + assertEquals(original.receiptId, restored.receiptId) + assertEquals(original.sandbox, restored.sandbox) + assertEquals(original.userId, restored.userId) + } + // MARK: - RequestVerifyPurchaseWithIapkitResult Tests @Test @@ -721,6 +739,7 @@ class VerificationTest { updatedAt = 1720000000000.0, version = 2.0 ), + environment = "Sandbox", isValid = true, productId = "premium.monthly", state = IapkitPurchaseState.Entitled, @@ -730,6 +749,7 @@ class VerificationTest { val restored = RequestVerifyPurchaseWithIapkitResult.fromJson(json) assertEquals(original.isValid, restored.isValid) + assertEquals(original.environment, restored.environment) assertEquals(original.productId, restored.productId) assertEquals(original.clientPayload?.body, restored.clientPayload?.body) assertEquals(original.clientPayload?.format, restored.clientPayload?.format) diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index bdc979c40..2f4a1ff4f 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -1071,6 +1071,13 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { is String -> rawProductId else -> throw IllegalArgumentException("IAPKit result productId must be a string") } + val environment = when (val rawEnvironment = map["environment"]) { + null, is NSNull -> null + "Sandbox", "Production" -> rawEnvironment as String + else -> throw IllegalArgumentException( + "IAPKit result environment must be Sandbox or Production" + ) + } val clientPayload = when (val rawClientPayload = map["clientPayload"]) { null, is NSNull -> null is Map<*, *> -> { @@ -1102,6 +1109,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } val iapkitResult = RequestVerifyPurchaseWithIapkitResult( clientPayload = clientPayload, + environment = environment, isValid = isValid, productId = productId, state = state, diff --git a/libraries/maui-iap/example/OpenIap.Maui.Example/Utils/IapKitSettings.cs b/libraries/maui-iap/example/OpenIap.Maui.Example/Utils/IapKitSettings.cs index eb50eb806..1b0dd0d57 100644 --- a/libraries/maui-iap/example/OpenIap.Maui.Example/Utils/IapKitSettings.cs +++ b/libraries/maui-iap/example/OpenIap.Maui.Example/Utils/IapKitSettings.cs @@ -59,6 +59,7 @@ public static RequestVerifyPurchaseWithIapkitProps CreateVerifyProps(Purchase pu BaseUrl = BaseUrl, Amazon = new RequestVerifyPurchaseWithIapkitAmazonProps { + ExpectedProductId = common.ProductId, ReceiptId = token, UserId = (purchase as PurchaseAndroid)?.UserIdAmazon, // The example catalog is exercised with Amazon App Tester. diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index 904799465..5c1f7b963 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -3461,6 +3461,13 @@ public sealed record RequestVerifyPurchaseWithIapkitResult [JsonPropertyName("clientPayload")] public IapkitProductClientPayload? ClientPayload { get; init; } /// + /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + /// Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + /// `Production` on handled Amazon verification results. + /// + [JsonPropertyName("environment")] + public string? Environment { get; init; } + /// /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. /// Callers must still match productId and use the platform plus app-owned product @@ -4252,6 +4259,12 @@ public sealed record RequestSubscriptionPropsByPlatforms public sealed record RequestVerifyPurchaseWithIapkitAmazonProps { + /// + /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + /// Optional Amazon product id that must match the product id verified by RVS. + /// + [JsonPropertyName("expectedProductId")] + public string? ExpectedProductId { get; init; } /// Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). [JsonPropertyName("userId")] public string? UserId { get; init; } diff --git a/libraries/maui-iap/tests/OpenIap.Maui.ContractTests/Program.cs b/libraries/maui-iap/tests/OpenIap.Maui.ContractTests/Program.cs index 3a346462d..159b64218 100644 --- a/libraries/maui-iap/tests/OpenIap.Maui.ContractTests/Program.cs +++ b/libraries/maui-iap/tests/OpenIap.Maui.ContractTests/Program.cs @@ -18,6 +18,7 @@ private static readonly (string Name, Func Run)[] Tests = (nameof(ProductsDeserializeNestedClientPayload), ProductsDeserializeNestedClientPayload), (nameof(ClientPayloadUsesEscapedUriAndDeserializes), ClientPayloadUsesEscapedUriAndDeserializes), (nameof(GeneratedVerificationResultDeserializesClientPayload), GeneratedVerificationResultDeserializesClientPayload), + (nameof(GeneratedAmazonVerificationContractRoundTrips), GeneratedAmazonVerificationContractRoundTrips), ]; public static async Task Main() @@ -207,6 +208,40 @@ private static Task GeneratedVerificationResultDeserializesClientPayload() return Task.CompletedTask; } + private static Task GeneratedAmazonVerificationContractRoundTrips() + { + var props = new RequestVerifyPurchaseWithIapkitAmazonProps + { + ExpectedProductId = "dev.hyo.martie.10bulbs", + ReceiptId = "amzn1.receipt.test", + Sandbox = true, + UserId = "amzn1.account.test", + }; + var result = new RequestVerifyPurchaseWithIapkitResult + { + Environment = "Sandbox", + IsValid = true, + ProductId = "dev.hyo.martie.10bulbs", + State = IapkitPurchaseState.ReadyToConsume, + Store = IapStore.Amazon, + }; + + var restoredProps = AssertNotNull( + JsonSerializer.Deserialize( + JsonSerializer.Serialize(props)), + "generated Amazon verification props"); + var restoredResult = AssertNotNull( + JsonSerializer.Deserialize( + JsonSerializer.Serialize(result)), + "generated Amazon verification result"); + + AssertEqual(props.ExpectedProductId, restoredProps.ExpectedProductId, "expected product id"); + AssertEqual(props.ReceiptId, restoredProps.ReceiptId, "Amazon receipt id"); + AssertEqual("Sandbox", restoredResult.Environment, "Amazon environment"); + AssertEqual(IapStore.Amazon, restoredResult.Store, "Amazon store"); + return Task.CompletedTask; + } + private static KitApiClient CreateClient(HttpClient httpClient) => OpenIapClient.KitApi(new KitApiOptions { diff --git a/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs b/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs index 45e8da893..d14f8f7c1 100644 --- a/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs +++ b/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs @@ -532,6 +532,40 @@ public void RequestVerifyPurchaseWithIapkitResult_DeserializesClientPayload() Assert.Equal(1720000000789D, payload.UpdatedAt); } + [Fact] + public void AmazonIapkitContract_RoundTripsProductBindingAndEnvironment() + { + var props = new RequestVerifyPurchaseWithIapkitAmazonProps + { + ExpectedProductId = "dev.hyo.martie.10bulbs", + ReceiptId = "amzn1.receipt.test", + Sandbox = true, + UserId = "amzn1.account.test", + }; + var result = new RequestVerifyPurchaseWithIapkitResult + { + Environment = "Sandbox", + IsValid = true, + ProductId = "dev.hyo.martie.10bulbs", + State = IapkitPurchaseState.ReadyToConsume, + Store = IapStore.Amazon, + }; + + var restoredProps = JsonSerializer.Deserialize( + JsonSerializer.Serialize(props, Options), + Options + ); + var restoredResult = JsonSerializer.Deserialize( + JsonSerializer.Serialize(result, Options), + Options + ); + + Assert.Equal(props.ExpectedProductId, restoredProps?.ExpectedProductId); + Assert.Equal(props.ReceiptId, restoredProps?.ReceiptId); + Assert.Equal("Sandbox", restoredResult?.Environment); + Assert.Equal(IapStore.Amazon, restoredResult?.Store); + } + // ------------------------------------------------------------------ // RequestPurchaseProps input validation // ------------------------------------------------------------------ diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 8534703d2..c26f7a677 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -1524,6 +1524,9 @@ class HybridRnIap : HybridRnIapSpec() { val amazonMap = mutableMapOf( "receiptId" to amazon.receiptId ) + amazon.expectedProductId.unwrapString()?.let { + amazonMap["expectedProductId"] = it + } amazon.userId.unwrapString()?.let { amazonMap["userId"] = it } amazon.sandbox.unwrapBool()?.let { amazonMap["sandbox"] = it } iapkitMap["amazon"] = amazonMap @@ -1558,6 +1561,7 @@ class HybridRnIap : HybridRnIapSpec() { clientPayload = clientPayload?.let { Variant_NullType_NitroIapkitProductClientPayload.Second(it) }, + environment = item.environment?.let { Variant_NullType_String.Second(it) }, isValid = item.isValid, productId = item.productId?.let { Variant_NullType_String.Second(it) }, // Use rawValue ("pending-acknowledgment"), not the Kotlin diff --git a/libraries/react-native-iap/example/.env.example b/libraries/react-native-iap/example/.env.example index 9f93d57c3..beaf7ecc9 100644 --- a/libraries/react-native-iap/example/.env.example +++ b/libraries/react-native-iap/example/.env.example @@ -6,3 +6,6 @@ IAPKIT_API_KEY=openiap-kit_pk_your_publishable_key_here # Required when selecting Local (IAPKit). Use your Mac's LAN IP on a device. # Example: http://192.168.0.10:3100 IAPKIT_BASE_URL= +# Set true only for Amazon App Tester receipts after enabling the matching +# sandbox option in the IAPKit project settings. +AMAZON_RVS_SANDBOX=false diff --git a/libraries/react-native-iap/example/README.md b/libraries/react-native-iap/example/README.md index 944d31590..87e46cbac 100644 --- a/libraries/react-native-iap/example/README.md +++ b/libraries/react-native-iap/example/README.md @@ -37,7 +37,7 @@ Create the ignored environment file from the example before testing IAPKit: cp example/.env.example example/.env ``` -For hosted IAPKit, get an `openiap-kit_pk_` publishable key from the [IAPKit dashboard](https://kit.openiap.dev) and set it as `IAPKIT_API_KEY`. This value is bundled into the example, so never use an `openiap-kit_sk_` secret admin key. For **Local (IAPKit)**, the publishable key and local server must target the same Convex deployment. Also set `IAPKIT_BASE_URL` to the device-reachable HTTP(S) origin only; do not append `/v1/purchase/verify`. A physical iPhone must use the Mac's LAN address. An Android device connected over USB can use `http://127.0.0.1:3100`: inspect `adb -s "$ANDROID_SERIAL" reverse --list`, reuse an exact `tcp:3100` mapping when present, or create it with `adb -s "$ANDROID_SERIAL" reverse --no-rebind tcp:3100 tcp:3100`. Record whether this run created the rule and remove only that rule during cleanup; if `--no-rebind` fails, use another port instead of overwriting an existing mapping. +For hosted IAPKit, get an `openiap-kit_pk_` publishable key from the [IAPKit dashboard](https://kit.openiap.dev) and set it as `IAPKIT_API_KEY`. This value is bundled into the example, so never use an `openiap-kit_sk_` secret admin key. For **Local (IAPKit)**, the publishable key and local server must target the same Convex deployment. Also set `IAPKIT_BASE_URL` to the device-reachable HTTP(S) origin only; do not append `/v1/purchase/verify`. Set `AMAZON_RVS_SANDBOX=true` only for Amazon App Tester receipts after enabling the matching sandbox option in the IAPKit project settings. A physical iPhone must use the Mac's LAN address. An Android device connected over USB can use `http://127.0.0.1:3100`: inspect `adb -s "$ANDROID_SERIAL" reverse --list`, reuse an exact `tcp:3100` mapping when present, or create it with `adb -s "$ANDROID_SERIAL" reverse --no-rebind tcp:3100 tcp:3100`. Record whether this run created the rule and remove only that rule during cleanup; if `--no-rebind` fails, use another port instead of overwriting an existing mapping. The purchase and subscription screens list verification in this order: diff --git a/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx b/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx index 8821df14d..e86ee8e20 100644 --- a/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx @@ -14,6 +14,7 @@ import type { jest.mock( '@env', () => ({ + AMAZON_RVS_SANDBOX: 'false', IAPKIT_API_KEY: 'test-api-key', IAPKIT_BASE_URL: 'http://192.168.0.10:3100', }), @@ -219,7 +220,7 @@ describe('PurchaseFlow Screen', () => { iapkit: { isValid: true, productId: 'dev.hyo.martie.10bulbs', - state: 'ready-to-consume', + state: 'entitled', store: 'google', }, }), @@ -338,7 +339,7 @@ describe('PurchaseFlow Screen', () => { ); }); - it('verifies and finishes a restored Local IAPKit consumable exactly once without a success callback', async () => { + it('verifies and finishes a restored ready-to-consume Google purchase exactly once', async () => { Platform.OS = 'android'; const restoredPurchase: Purchase = { id: 'transaction-restored-1', @@ -437,7 +438,7 @@ describe('PurchaseFlow Screen', () => { iapkit: { isValid: true, productId: 'dev.hyo.martie.30bulbs', - state: 'ready-to-consume', + state: 'entitled', store: 'google', }, }, @@ -504,7 +505,7 @@ describe('PurchaseFlow Screen', () => { iapkit: { isValid: true, productId: purchase.productId, - state: 'ready-to-consume', + state: 'entitled', store: 'google', }, }; @@ -679,7 +680,7 @@ describe('PurchaseFlow Screen', () => { iapkit: { isValid: true, productId: purchase.productId, - state: 'ready-to-consume', + state: 'entitled', store: 'google', }, }); @@ -721,7 +722,7 @@ describe('PurchaseFlow Screen', () => { iapkit: { isValid: true, productId: purchase.productId, - state: 'ready-to-consume', + state: 'entitled', store: 'google', }, }), @@ -810,7 +811,7 @@ describe('PurchaseFlow Screen', () => { iapkit: { isValid: true, productId: purchase.productId, - state: 'ready-to-consume', + state: 'entitled', store: 'google', }, }), @@ -943,7 +944,7 @@ describe('PurchaseFlow Screen', () => { iapkit: { isValid: true, productId: 'dev.hyo.martie.10bulbs', - state: 'ready-to-consume', + state: 'entitled', store: 'google', }, }); @@ -1314,7 +1315,9 @@ describe('PurchaseFlow Screen', () => { it('closes modal when close button pressed', async () => { mockIapState({products: [androidProductWithOffers]}); - const {getByText, getAllByText, queryByText} = await render(); + const {getByText, getAllByText, queryByText} = await render( + , + ); // Open modal const detailsButton = getAllByText('Details')[0]; diff --git a/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx b/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx index e6bb8bab2..76c9331dc 100644 --- a/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx @@ -13,6 +13,7 @@ import type { jest.mock( '@env', () => ({ + AMAZON_RVS_SANDBOX: 'false', IAPKIT_API_KEY: 'test-api-key', IAPKIT_BASE_URL: 'http://192.168.0.10:3100', }), diff --git a/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts b/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts index 33f7984ac..e4d811651 100644 --- a/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts +++ b/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts @@ -19,6 +19,7 @@ describe('Vega runtime example helpers', () => { } as unknown as Purchase, 'receipt-1', 'test-api-key', + true, 'http://localhost:3100', ); @@ -26,6 +27,7 @@ describe('Vega runtime example helpers', () => { apiKey: 'test-api-key', baseUrl: 'http://localhost:3100', amazon: { + expectedProductId: 'dev.hyo.martie.10bulbs', receiptId: 'receipt-1', sandbox: true, }, @@ -42,6 +44,7 @@ describe('Vega runtime example helpers', () => { } as unknown as Purchase, 'token-1', 'test-api-key', + false, ); expect(payload).toMatchObject({ @@ -62,6 +65,7 @@ describe('Vega runtime example helpers', () => { } as unknown as Purchase, 'jws-1', 'test-api-key', + false, ); expect(payload).toMatchObject({ @@ -105,6 +109,7 @@ describe('Vega runtime example helpers', () => { } as unknown as Purchase, 'token-1', ' ', + false, ), ).toThrow('IAPKIT_API_KEY not configured'); }); @@ -117,11 +122,13 @@ describe('Vega runtime example helpers', () => { iapkit: { isValid: true, productId: 'dev.hyo.martie.10bulbs', - state: 'ready-to-consume', + state: 'entitled', store: 'google', }, }, 'dev.hyo.martie.10bulbs', + true, + false, ), ).toBeNull(); }); @@ -139,6 +146,8 @@ describe('Vega runtime example helpers', () => { }, }, 'dev.hyo.martie.10bulbs', + true, + false, ), ).toContain('state: consumed'); }); @@ -151,17 +160,113 @@ describe('Vega runtime example helpers', () => { iapkit: { isValid: true, productId: 'dev.hyo.martie.30bulbs', - state: 'ready-to-consume', + state: 'entitled', store: 'google', }, }, 'dev.hyo.martie.10bulbs', + true, + false, ), ).toContain( 'IAPKit verified dev.hyo.martie.30bulbs, expected dev.hyo.martie.10bulbs', ); }); + it('requires an Amazon product ID before fulfillment', () => { + expect( + getIapkitVerificationError( + { + provider: 'iapkit', + iapkit: { + environment: 'Production', + isValid: true, + state: 'ready-to-consume', + store: 'amazon', + }, + }, + 'dev.hyo.martie.10bulbs', + true, + false, + ), + ).toBe('IAPKit did not return a product ID for amazon'); + }); + + it('requires the configured Amazon environment', () => { + expect( + getIapkitVerificationError( + { + provider: 'iapkit', + iapkit: { + environment: 'Sandbox', + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', + store: 'amazon', + }, + }, + 'dev.hyo.martie.10bulbs', + true, + false, + ), + ).toContain('expected Production'); + }); + + it('accepts ready-to-consume only for Google consumables', () => { + expect( + getIapkitVerificationError( + { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', + store: 'google', + }, + }, + 'dev.hyo.martie.10bulbs', + true, + false, + ), + ).toBeNull(); + + expect( + getIapkitVerificationError( + { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state: 'ready-to-consume', + store: 'google', + }, + }, + 'dev.hyo.martie.10bulbs', + false, + false, + ), + ).toContain('cannot fulfill this non-consumable google purchase'); + + for (const state of ['entitled', 'pending-acknowledgment'] as const) { + expect( + getIapkitVerificationError( + { + provider: 'iapkit', + iapkit: { + isValid: true, + productId: 'dev.hyo.martie.10bulbs', + state, + store: 'google', + }, + }, + 'dev.hyo.martie.10bulbs', + true, + false, + ), + ).toBeNull(); + } + }); + it('keeps the completed purchase cache bounded and refreshes recency', () => { const completedKeys = new Set(['oldest', 'middle']); diff --git a/libraries/react-native-iap/example/jest.setup.js b/libraries/react-native-iap/example/jest.setup.js index c072702a0..3e7c4bfee 100644 --- a/libraries/react-native-iap/example/jest.setup.js +++ b/libraries/react-native-iap/example/jest.setup.js @@ -9,6 +9,7 @@ global.__fbBatchedBridgeConfig = { jest.mock( '@env', () => ({ + AMAZON_RVS_SANDBOX: 'false', IAPKIT_API_KEY: '', IAPKIT_BASE_URL: '', }), diff --git a/libraries/react-native-iap/example/screens/PurchaseFlow.tsx b/libraries/react-native-iap/example/screens/PurchaseFlow.tsx index f3ec96d16..f81fb1f06 100644 --- a/libraries/react-native-iap/example/screens/PurchaseFlow.tsx +++ b/libraries/react-native-iap/example/screens/PurchaseFlow.tsx @@ -17,7 +17,7 @@ import { getStorefront, ErrorCode, } from 'react-native-iap'; -import {IAPKIT_API_KEY, IAPKIT_BASE_URL} from '@env'; +import {AMAZON_RVS_SANDBOX, IAPKIT_API_KEY, IAPKIT_BASE_URL} from '@env'; import Loading from '../src/components/Loading'; import { CONSUMABLE_PRODUCT_IDS, @@ -697,7 +697,7 @@ function PurchaseFlowContainer() { setIsProcessing(false); setPurchaseResult( - `Purchase received (state: ${purchase.purchaseState}). Finishing transaction...`, + `Purchase received (state: ${purchase.purchaseState}). Verifying purchase...`, ); const isConsumablePurchase = CONSUMABLE_PRODUCT_ID_SET.has(productId); @@ -772,6 +772,7 @@ function PurchaseFlowContainer() { purchase, jwsOrToken, apiKey, + AMAZON_RVS_SANDBOX === 'true', baseUrl, ); const verifyRequest: VerifyPurchaseWithProviderProps = { @@ -788,6 +789,8 @@ function PurchaseFlowContainer() { const verificationError = getIapkitVerificationError( result, productId, + isConsumablePurchase, + AMAZON_RVS_SANDBOX === 'true', ); if (verificationError) { throw new Error(verificationError); diff --git a/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx b/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx index 74c2e49ea..7d2ebdf88 100644 --- a/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx +++ b/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx @@ -50,7 +50,7 @@ import { } from '../src/utils/vegaRuntime'; import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; import VerificationMethodSelectorModal from '../src/components/VerificationMethodSelectorModal'; -import {IAPKIT_API_KEY, IAPKIT_BASE_URL} from '@env'; +import {AMAZON_RVS_SANDBOX, IAPKIT_API_KEY, IAPKIT_BASE_URL} from '@env'; type InFlightSubscriptionTask = { result: Promise<'abandoned' | 'failed' | 'finished'>; @@ -1755,7 +1755,7 @@ function SubscriptionFlowContainer() { setIsProcessing(false); setPurchaseResult( - `Subscription received; finishing transaction...\n` + + `Subscription received; verifying purchase...\n` + `Product: ${purchase.productId}\n` + `Transaction ID: ${purchase.id}\n` + `Date: ${formatPurchaseDate(purchase.transactionDate)}`, @@ -1834,6 +1834,7 @@ function SubscriptionFlowContainer() { purchase, jwsOrToken, apiKey, + AMAZON_RVS_SANDBOX === 'true', baseUrl, ); const verifyRequest: VerifyPurchaseWithProviderProps = { @@ -1851,6 +1852,8 @@ function SubscriptionFlowContainer() { const verificationError = getIapkitVerificationError( result, productId, + false, + AMAZON_RVS_SANDBOX === 'true', ); if (verificationError) { throw new Error(verificationError); diff --git a/libraries/react-native-iap/example/scripts/build-vega-example.mjs b/libraries/react-native-iap/example/scripts/build-vega-example.mjs index 6294fe798..b022676c1 100644 --- a/libraries/react-native-iap/example/scripts/build-vega-example.mjs +++ b/libraries/react-native-iap/example/scripts/build-vega-example.mjs @@ -13,6 +13,7 @@ const tempPackageSourceRoot = path.join( 'openiap-react-native-iap-src', ); const buildType = process.argv[2] === 'Release' ? 'Release' : 'Debug'; +const amazonRvsSandbox = process.env.AMAZON_RVS_SANDBOX ?? ''; const iapkitApiKey = process.env.IAPKIT_API_KEY ?? ''; const iapkitBaseUrl = process.env.IAPKIT_BASE_URL ?? ''; @@ -145,7 +146,8 @@ const copyExampleSources = () => { const writeExampleShims = () => { writeLocalJavaScriptModule( '@env', - `export const IAPKIT_API_KEY = ${JSON.stringify(iapkitApiKey)}; + `export const AMAZON_RVS_SANDBOX = ${JSON.stringify(amazonRvsSandbox)}; +export const IAPKIT_API_KEY = ${JSON.stringify(iapkitApiKey)}; export const IAPKIT_BASE_URL = ${JSON.stringify(iapkitBaseUrl)}; `, ); diff --git a/libraries/react-native-iap/example/src/types/env.d.ts b/libraries/react-native-iap/example/src/types/env.d.ts index e5c2d22f6..3f5aebf0b 100644 --- a/libraries/react-native-iap/example/src/types/env.d.ts +++ b/libraries/react-native-iap/example/src/types/env.d.ts @@ -1,4 +1,5 @@ declare module '@env' { + export const AMAZON_RVS_SANDBOX: string; export const IAPKIT_API_KEY: string; export const IAPKIT_BASE_URL: string; } diff --git a/libraries/react-native-iap/example/src/utils/vegaRuntime.ts b/libraries/react-native-iap/example/src/utils/vegaRuntime.ts index e984590f0..035f1f4ac 100644 --- a/libraries/react-native-iap/example/src/utils/vegaRuntime.ts +++ b/libraries/react-native-iap/example/src/utils/vegaRuntime.ts @@ -52,9 +52,32 @@ export function showNativeAlert(title: string, message?: string): void { } } +function isIapkitStateReadyForFulfillment( + verified: NonNullable, + isConsumable: boolean, +): boolean { + switch (verified.store) { + case 'apple': + case 'amazon': + return ( + verified.state === (isConsumable ? 'ready-to-consume' : 'entitled') + ); + case 'google': + return ( + verified.state === 'entitled' || + verified.state === 'pending-acknowledgment' || + (isConsumable && verified.state === 'ready-to-consume') + ); + default: + return false; + } +} + export function getIapkitVerificationError( result: VerifyPurchaseWithProviderResult, expectedProductId: string, + isConsumable: boolean, + amazonRvsSandbox: boolean, ): string | null { const verified = result.iapkit; if (!verified) { @@ -72,16 +95,29 @@ export function getIapkitVerificationError( return `IAPKit rejected the purchase (state: ${verified.state}, store: ${verified.store})`; } - const requiresProductId = - verified.store === 'apple' || verified.store === 'google'; - if (requiresProductId && !verified.productId) { + if (!verified.productId) { return `IAPKit did not return a product ID for ${verified.store}`; } - if (verified.productId && verified.productId !== expectedProductId) { + if (verified.productId !== expectedProductId) { return `IAPKit verified ${verified.productId}, expected ${expectedProductId}`; } + if (verified.store === 'amazon') { + const expectedEnvironment = amazonRvsSandbox ? 'Sandbox' : 'Production'; + if (verified.environment !== expectedEnvironment) { + return `IAPKit verified Amazon in ${ + verified.environment ?? 'an unknown environment' + }, expected ${expectedEnvironment}`; + } + } + + if (!isIapkitStateReadyForFulfillment(verified, isConsumable)) { + return `IAPKit state ${verified.state} cannot fulfill this ${ + isConsumable ? 'consumable' : 'non-consumable' + } ${verified.store} purchase`; + } + return null; } @@ -116,6 +152,7 @@ export function createIapkitVerificationPayload( purchase: Purchase, purchaseToken: string, apiKey: string, + amazonRvsSandbox: boolean, baseUrl?: string | null, ): IapkitVerificationPayload { const trimmedApiKey = apiKey.trim(); @@ -131,8 +168,9 @@ export function createIapkitVerificationPayload( { apiKey: trimmedApiKey, amazon: { + expectedProductId: purchase.productId, receiptId: purchaseToken, - sandbox: __DEV__, + sandbox: amazonRvsSandbox, }, }, baseUrl, diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index 48601d873..ff373afb4 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -475,6 +475,9 @@ class HybridRnIap: HybridRnIapSpec { var amazonDict: [String: Any] = [ "receiptId": amazon.receiptId ] + if case .second(let expectedProductId) = amazon.expectedProductId { + amazonDict["expectedProductId"] = expectedProductId + } if case .second(let sandbox) = amazon.sandbox { amazonDict["sandbox"] = sandbox } @@ -506,6 +509,7 @@ class HybridRnIap: HybridRnIapSpec { } nitroIapkitResult = NitroVerifyPurchaseWithIapkitResult( clientPayload: clientPayload.map { .second($0) }, + environment: RnIapHelper.wrapString(item.environment), isValid: item.isValid, productId: RnIapHelper.wrapString(item.productId), state: IapkitPurchaseState(fromString: item.state.rawValue) ?? .unknown, diff --git a/libraries/react-native-iap/src/__tests__/iapkit-base-url-bridge.test.js b/libraries/react-native-iap/src/__tests__/iapkit-base-url-bridge.test.js index e646b04a4..d57c1e9b9 100644 --- a/libraries/react-native-iap/src/__tests__/iapkit-base-url-bridge.test.js +++ b/libraries/react-native-iap/src/__tests__/iapkit-base-url-bridge.test.js @@ -7,7 +7,7 @@ function readSource(path) { return readFileSync(resolve(rootDir, path), 'utf8'); } -describe('IAPKit baseUrl native bridge parity', () => { +describe('IAPKit native bridge parity', () => { it('declares baseUrl in the Nitro contract', () => { const spec = readSource('src/specs/RnIap.nitro.ts'); @@ -28,4 +28,33 @@ describe('IAPKit baseUrl native bridge parity', () => { 'iapkit.baseUrl.unwrapString()?.let { iapkitMap["baseUrl"] = it }', ); }); + + it('forwards Amazon expectedProductId and preserves environment', () => { + const spec = readSource('src/specs/RnIap.nitro.ts'); + const ios = readSource('ios/HybridRnIap.swift'); + const android = readSource( + 'android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt', + ); + + expect(spec).toMatch( + /interface NitroVerifyPurchaseWithIapkitAmazonProps[\s\S]*?expectedProductId\?: string \| null;/, + ); + expect(spec).toMatch( + /interface NitroVerifyPurchaseWithIapkitResult[\s\S]*?environment\?: string \| null;/, + ); + expect(ios).toContain( + 'if case .second(let expectedProductId) = amazon.expectedProductId', + ); + expect(ios).toContain( + 'amazonDict["expectedProductId"] = expectedProductId', + ); + expect(ios).toContain( + 'environment: RnIapHelper.wrapString(item.environment)', + ); + expect(android).toContain('amazon.expectedProductId.unwrapString()?.let {'); + expect(android).toContain('amazonMap["expectedProductId"] = it'); + expect(android).toContain( + 'environment = item.environment?.let { Variant_NullType_String.Second(it) }', + ); + }); }); diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index ee852ff3b..b7b0f0fd9 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -2208,6 +2208,7 @@ describe('Public API (src/index.ts)', () => { const mockResult = { provider: 'iapkit', iapkit: { + environment: 'Sandbox', isValid: true, state: 'ready-to-consume', store: 'amazon', @@ -2220,6 +2221,7 @@ describe('Public API (src/index.ts)', () => { iapkit: { apiKey: 'test-api-key', amazon: { + expectedProductId: 'amazon.premium.monthly', userId: 'amazon-user', receiptId: 'amazon-receipt', sandbox: true, @@ -2232,12 +2234,14 @@ describe('Public API (src/index.ts)', () => { iapkit: { apiKey: 'test-api-key', amazon: { + expectedProductId: 'amazon.premium.monthly', userId: 'amazon-user', receiptId: 'amazon-receipt', sandbox: true, }, }, }); + expect(result.iapkit?.environment).toBe('Sandbox'); expect(result.iapkit?.store).toBe('amazon'); }); diff --git a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts index 4cd3b3c0f..350f4cb54 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -1228,6 +1228,7 @@ describe('Amazon Vega adapter', () => { const fetchMock = jest.fn( async (_input: RequestInfo | URL, _init?: RequestInit) => Response.json({ + environment: 'Sandbox', isValid: true, state: 'READY_TO_CONSUME', store: 'amazon', @@ -1244,6 +1245,7 @@ describe('Amazon Vega adapter', () => { iapkit: { apiKey: 'kit-key', amazon: { + expectedProductId: 'amazon.premium.monthly', receiptId: 'receipt-vega-1', sandbox: true, }, @@ -1252,6 +1254,7 @@ describe('Amazon Vega adapter', () => { ).resolves.toEqual({ provider: 'iapkit', iapkit: { + environment: 'Sandbox', isValid: true, state: 'ready-to-consume', store: 'amazon', @@ -1275,6 +1278,7 @@ describe('Amazon Vega adapter', () => { store: 'amazon', userId: 'amazon-user', receiptId: 'receipt-vega-1', + expectedProductId: 'amazon.premium.monthly', sandbox: true, }); } finally { @@ -1682,6 +1686,41 @@ describe('Amazon Vega adapter', () => { } }); + it.each([42, 'Staging'])( + 'rejects an invalid IAPKit environment: %s', + async (environment) => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn(async () => + Response.json({ + environment, + isValid: true, + state: 'ENTITLED', + store: 'amazon', + }), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toThrow('IAPKit returned malformed response (HTTP 200).'); + } finally { + globalThis.fetch = originalFetch; + } + }, + ); + it('rejects successful IAPKit payloads for another store', async () => { const service = createService(); const originalFetch = globalThis.fetch; diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index c46a93b67..ee76ef818 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -2194,9 +2194,11 @@ export const verifyPurchase: MutationField<'verifyPurchase'> = async ( * // apple: { jws: purchase.purchaseToken }, * // google: { purchaseToken: purchase.purchaseToken }, * amazon: { + * expectedProductId: purchase.productId, * userId: amazonUserId, * receiptId: purchase.purchaseToken, - * sandbox: __DEV__, + * // Enable only for App Tester after the IAPKit project opt-in. + * sandbox: amazonSandboxEnabled, * }, * }, * }); @@ -2226,6 +2228,9 @@ export const verifyPurchaseWithProvider: MutationField< ...(result.iapkit.clientPayload == null ? {} : {clientPayload: result.iapkit.clientPayload}), + ...(result.iapkit.environment == null + ? {} + : {environment: result.iapkit.environment}), isValid: result.iapkit.isValid, ...(result.iapkit.productId == null ? {} diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index f73a85fea..bdbe5792a 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -441,6 +441,8 @@ export interface NitroVerifyPurchaseWithIapkitGoogleProps { } export interface NitroVerifyPurchaseWithIapkitAmazonProps { + /** Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Optional Amazon product id that must match the product id verified by RVS. */ + expectedProductId?: string | null; /** Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). */ receiptId: string; /** Use Amazon RVS Cloud Sandbox for App Tester receipts. */ @@ -475,6 +477,8 @@ export interface NitroVerifyPurchaseWithProviderProps { export interface NitroVerifyPurchaseWithIapkitResult { /** Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. */ clientPayload?: NitroIapkitProductClientPayload | null; + /** Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment selected by IAPKit. */ + environment?: string | null; isValid: boolean; /** Available in OpenIAP Spec 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. */ productId?: string | null; diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 5934056f1..7e9696c79 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1791,6 +1791,11 @@ export interface RequestSubscriptionPropsByPlatforms { } export interface RequestVerifyPurchaseWithIapkitAmazonProps { + /** + * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + * Optional Amazon product id that must match the product id verified by RVS. + */ + expectedProductId?: (string | null); /** Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). */ receiptId: string; /** Use Amazon RVS Cloud Sandbox for App Tester receipts. */ @@ -1848,6 +1853,12 @@ export interface RequestVerifyPurchaseWithIapkitResult { * Apple or Google receipt is valid, and a payload exists for that product. */ clientPayload?: (IapkitProductClientPayload | null); + /** + * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + * Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + * `Production` on handled Amazon verification results. + */ + environment?: (string | null); /** * True when the purchase is valid and actionable. * Only entitled, pending-acknowledgment, or ready-to-consume return true. diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts index a2ea15948..2bbb406b7 100644 --- a/libraries/react-native-iap/src/vega-adapter.ts +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -1271,8 +1271,20 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { `IAPKit returned malformed response (HTTP ${status}).`, ); } + const environment = json.environment; + if ( + environment != null && + (typeof environment !== 'string' || + (environment !== 'Sandbox' && environment !== 'Production')) + ) { + throw createVegaError( + ErrorCode.PurchaseVerificationFailed, + `IAPKit returned malformed response (HTTP ${status}).`, + ); + } return { + ...(environment == null ? {} : {environment}), isValid: json.isValid, ...(productId == null ? {} : {productId}), state: normalizeIapkitState(json.state), @@ -1341,6 +1353,9 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { store: 'amazon', userId, receiptId, + ...(amazon.expectedProductId == null + ? {} + : {expectedProductId: amazon.expectedProductId}), ...(amazon.sandbox == null ? {} : {sandbox: amazon.sandbox}), }), signal: controller.signal, diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index d3bfac2ef..89d702a11 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -1230,6 +1230,10 @@ public struct RequestVerifyPurchaseWithIapkitResult: Codable { /// Public product payload when includeClientPayload was requested, the /// Apple or Google receipt is valid, and a payload exists for that product. public var clientPayload: IapkitProductClientPayload? = nil + /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + /// Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + /// `Production` on handled Amazon verification results. + public var environment: String? = nil /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. /// Callers must still match productId and use the platform plus app-owned product @@ -2027,6 +2031,9 @@ public struct RequestSubscriptionPropsByPlatforms: Codable { } public struct RequestVerifyPurchaseWithIapkitAmazonProps: Codable { + /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + /// Optional Amazon product id that must match the product id verified by RVS. + public var expectedProductId: String? /// Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). public var receiptId: String /// Use Amazon RVS Cloud Sandbox for App Tester receipts. @@ -2035,10 +2042,12 @@ public struct RequestVerifyPurchaseWithIapkitAmazonProps: Codable { public var userId: String? public init( + expectedProductId: String? = nil, receiptId: String, sandbox: Bool? = nil, userId: String? = nil ) { + self.expectedProductId = expectedProductId self.receiptId = receiptId self.sandbox = sandbox self.userId = userId diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index d748ace67..cf8697aab 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -7,6 +7,15 @@ private struct IndexedProductEntry: @unchecked Sendable { let entry: OpenIAP.ProductOrSubscription } +struct IapkitAmazonVerificationPayload: Codable { + let store: IapStore + let expectedProductId: String? + let receiptId: String + let sandbox: Bool? + let userId: String? + let includeClientPayload: Bool? +} + struct EntitlementSelectionKey: Comparable { let purchaseDate: Date let transactionId: UInt64 @@ -109,6 +118,39 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return value.boolValue } + static func iapkitEnvironment(from rawValue: Any?) throws -> String? { + guard let rawValue, !(rawValue is NSNull) else { return nil } + guard let environment = rawValue as? String, + environment == "Sandbox" || environment == "Production" else { + throw PurchaseError.make( + code: .purchaseVerificationFailed, + message: "IAPKit returned malformed response" + ) + } + + return environment + } + + static func iapkitAmazonPayload( + from amazon: RequestVerifyPurchaseWithIapkitAmazonProps, + includeClientPayload: Bool? + ) throws -> IapkitAmazonVerificationPayload { + let receiptId = amazon.receiptId.trimmingCharacters(in: .whitespacesAndNewlines) + guard receiptId.isEmpty == false else { + throw PurchaseError.make(code: .developerError, message: "Amazon receiptId is required") + } + let userId = amazon.userId?.trimmingCharacters(in: .whitespacesAndNewlines) + + return IapkitAmazonVerificationPayload( + store: .amazon, + expectedProductId: amazon.expectedProductId, + receiptId: receiptId, + sandbox: amazon.sandbox, + userId: userId?.isEmpty == true ? nil : userId, + includeClientPayload: includeClientPayload + ) + } + /// Objective-C accessor for [OpenIapModule.shared]. Exists so the .NET MAUI /// binding (`OpenIap.Maui.Bindings.iOS`) can surface the singleton via /// `[OpenIapModule sharedInstance]`; Swift's static stored properties @@ -829,13 +871,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { let jws: String let includeClientPayload: Bool? } - struct IapkitAmazonPayload: Codable { - let store: IapStore - let receiptId: String - let sandbox: Bool? - let userId: String? - let includeClientPayload: Bool? - } struct IapkitGooglePayload: Codable { let store: IapStore let purchaseToken: String @@ -906,16 +941,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } if let amazon = props.amazon { - let receiptId = amazon.receiptId.trimmingCharacters(in: .whitespacesAndNewlines) - guard receiptId.isEmpty == false else { - throw makePurchaseError(code: .developerError, message: "Amazon receiptId is required") - } - let userId = amazon.userId?.trimmingCharacters(in: .whitespacesAndNewlines) - let payload = IapkitAmazonPayload( - store: .amazon, - receiptId: receiptId, - sandbox: amazon.sandbox, - userId: userId?.isEmpty == true ? nil : userId, + let payload = try Self.iapkitAmazonPayload( + from: amazon, includeClientPayload: props.includeClientPayload ) return (.amazon, try encoder.encode(payload)) @@ -1004,6 +1031,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } else { productId = nil } + let environment = try Self.iapkitEnvironment(from: json["environment"]) let clientPayload: IapkitProductClientPayload? do { clientPayload = try Self.iapkitClientPayload(from: json["clientPayload"]) @@ -1014,6 +1042,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { OpenIapLog.info("IAPKit verification result: store=\(parsedStore.rawValue), isValid=\(isValid), state=\(parsedState.rawValue)") return RequestVerifyPurchaseWithIapkitResult( clientPayload: clientPayload, + environment: environment, isValid: isValid, productId: productId, state: parsedState, diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift index 0df502275..b6b895af9 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift @@ -125,6 +125,44 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { } } + func testIapkitEnvironmentAcceptsOnlyCanonicalValues() throws { + XCTAssertNil(try OpenIapModule.iapkitEnvironment(from: nil)) + XCTAssertNil(try OpenIapModule.iapkitEnvironment(from: NSNull())) + XCTAssertEqual("Sandbox", try OpenIapModule.iapkitEnvironment(from: "Sandbox")) + XCTAssertEqual("Production", try OpenIapModule.iapkitEnvironment(from: "Production")) + + for invalidValue: Any in ["sandbox", "Xcode", "", 1, true, [:], []] { + XCTAssertThrowsError( + try OpenIapModule.iapkitEnvironment(from: invalidValue) + ) + } + } + + func testIapkitAmazonPayloadForwardsExpectedProductId() throws { + let payload = try OpenIapModule.iapkitAmazonPayload( + from: RequestVerifyPurchaseWithIapkitAmazonProps( + expectedProductId: "premium.monthly", + receiptId: " amzn1.receipt.ABC123456789 ", + sandbox: true, + userId: " amzn1.account.ABC123 " + ), + includeClientPayload: false + ) + + XCTAssertEqual(.amazon, payload.store) + XCTAssertEqual("premium.monthly", payload.expectedProductId) + XCTAssertEqual("amzn1.receipt.ABC123456789", payload.receiptId) + XCTAssertEqual(true, payload.sandbox) + XCTAssertEqual("amzn1.account.ABC123", payload.userId) + XCTAssertEqual(false, payload.includeClientPayload) + + let encoded = try JSONEncoder().encode(payload) + let body = try XCTUnwrap( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + XCTAssertEqual("premium.monthly", body["expectedProductId"] as? String) + } + @MainActor func testStoreReturnsIapkitResult() async throws { let iapkitResult = RequestVerifyPurchaseWithIapkitResult( diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 7c29a587d..09b0643d0 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-08-11T02:32:04.619Z +> Generated: 2026-08-11T09:33:24.495Z ## Table of Contents 1. Installation @@ -2036,9 +2036,17 @@ Retention: - Events are retained for the bounded IAPKit operational window and pruned by a Convex cron job. They are not exposed as a public replay stream. -Meta Horizon has no inbound webhook. Its bounded polling reconciler may record -synthetic lifecycle events under the `MetaHorizonReconciler` source for the same -private state machine and retention policy. +Meta Horizon has no inbound webhook or background lifecycle lane in IAPKit. +`POST /v1/purchase/verify` performs a synchronous entitlement check only. The +`MetaHorizonReconciler` source remains schema-compatible for legacy retained +rows, but those synthetic events are excluded from current revenue rollups. + +Amazon RVS also has no inbound webhook receiver in IAPKit. A bounded purchase +reconciler schedules active Amazon receipt rows for revisits on a 48-hour due +cadence, but backlog, retries, and lease recovery mean it does not guarantee +that every row is checked within 72 hours. It updates state only from +authoritative RVS outcomes and preserves the last confirmed state across +transient or malformed responses. --- @@ -2047,7 +2055,7 @@ private state machine and retention policy. > Receipt-validation SaaS managed by OpenIAP. Hosted at https://kit.openiap.dev. > One Bearer-authed endpoint for Apple / Google / Horizon / Amazon; -> harmonized response shape with `{ store, isValid, state, productId? }` so your backend has a single code path for +> harmonized response shape with `{ store, isValid, state, productId?, environment? }` so your backend has a single code path for > entitlement + refund detection. IAPKit lives in the OpenIAP monorepo as a Bun + Hono server, Convex backend, @@ -2104,15 +2112,26 @@ body-only reads; use raw HTTP or an app wrapper to retain response headers. - Horizon — `{ store: "horizon", userId, sku }` (≤ 256 chars each). IAPKit holds the App ID + App Secret server-side and composes the `OC|APP_ID|APP_SECRET` access token per-request. -- Amazon — `{ store: "amazon", userId, receiptId, sandbox? }` where - `userId` and `receiptId` come from Amazon Appstore RVS. +- Amazon — `{ store: "amazon", userId, receiptId, sandbox?, expectedProductId? }` + where `userId` and `receiptId` come from Amazon Appstore RVS. Production uses + the project-held RVS shared secret; sandbox requires the project's explicit + App Tester / Cloud Sandbox opt-in and never sends that production secret. ## Success response ```json -{ "store": "amazon", "isValid": true, "state": "ENTITLED" } +{ + "store": "amazon", + "isValid": true, + "state": "ENTITLED", + "productId": "premium_monthly", + "environment": "Sandbox" +} ``` +Handled Amazon results identify the selected `Sandbox` or `Production` +environment. Match the store-verified `productId` before fulfillment. + For Apple/Google only, `includeClientPayload: true` may add a top-level `clientPayload` when verification is valid, the store supplies a verified productId, and that exact platform/product has a payload: diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index 7515eb4ae..f5aed8940 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-08-11T02:32:04.619Z +> Generated: 2026-08-11T09:33:24.495Z ## Installation diff --git a/packages/docs/src/pages/docs/examples/fireos.tsx b/packages/docs/src/pages/docs/examples/fireos.tsx index 3d236c98e..e55c2ca85 100644 --- a/packages/docs/src/pages/docs/examples/fireos.tsx +++ b/packages/docs/src/pages/docs/examples/fireos.tsx @@ -651,7 +651,9 @@ function FireOSExample() { Use the IAPKit Amazon payload with sandbox: true{' '} for tester receipts so Amazon RVS validation is routed to the - correct environment. + correct environment. This requires enabling{' '} + Allow Amazon App Tester / RVS Cloud Sandbox in + the IAPKit project settings first. @@ -732,9 +734,11 @@ function FireOSExample() { - For Amazon, pass the IAPKit Amazon payload with the receipt ID; - the provider path can resolve the Amazon user ID when supported - by the platform adapter. + For Amazon, pass the receipt ID and{' '} + expectedProductId. The provider path can resolve + the Amazon user ID when supported by the platform adapter. + Handled results identify the RVS environment as{' '} + 'Sandbox' or 'Production'. @@ -778,6 +782,7 @@ async function onPurchaseUpdated(purchase: Purchase) { provider: 'iapkit', iapkit: { amazon: { + expectedProductId: purchase.productId, receiptId: purchase.purchaseToken ?? purchase.id, sandbox: true, }, @@ -787,6 +792,7 @@ async function onPurchaseUpdated(purchase: Purchase) { const verified = result.iapkit; if ( verified?.isValid === true && + verified.environment === 'Sandbox' && verified.productId != null && verified.productId === purchase.productId ) { diff --git a/packages/docs/src/pages/docs/examples/index.tsx b/packages/docs/src/pages/docs/examples/index.tsx index 0e9cb68e5..5d7967471 100644 --- a/packages/docs/src/pages/docs/examples/index.tsx +++ b/packages/docs/src/pages/docs/examples/index.tsx @@ -186,7 +186,8 @@ const FIREOS_CONFIG: StoreExampleConfig = { explanation: ( <> Use verifyPurchaseWithProvider with an{' '} - iapkit.amazon payload containing the Amazon receipt ID. + iapkit.amazon payload containing the receipt ID and{' '} + expectedProductId. ), }, @@ -194,8 +195,9 @@ const FIREOS_CONFIG: StoreExampleConfig = { part: 'Unlock decision', explanation: ( <> - Grant access only after verification succeeds; do not trust a - client-only premium flag or a button tap. + Grant access only after the verified product ID and RVS environment + match the request; do not trust a client-only premium flag or a button + tap. ), }, @@ -260,7 +262,9 @@ const FIREOS_CONFIG: StoreExampleConfig = { expected: ( <> Use the IAPKit Amazon payload with sandbox: true for - tester receipts so RVS validation uses the correct environment. + tester receipts so RVS validation uses the correct environment. First + enable Allow Amazon App Tester / RVS Cloud Sandbox in + the IAPKit project settings. ), }, @@ -275,9 +279,9 @@ const FIREOS_CONFIG: StoreExampleConfig = { ), frameworkNote: ( <> - For Amazon, pass the IAPKit Amazon payload with the receipt ID. Expo and - React Native reuse the Android purchase request shape while the Fire OS - build selects the Amazon native module underneath. + For Amazon, pass the receipt ID and expected product ID in the IAPKit + Amazon payload. Expo and React Native reuse the Android purchase request + shape while the Fire OS build selects the Amazon native module underneath. ), frameworkVerificationApi: { @@ -310,6 +314,7 @@ async function onPurchaseUpdated(purchase: Purchase) { provider: 'iapkit', iapkit: { amazon: { + expectedProductId: purchase.productId, receiptId: purchase.purchaseToken ?? purchase.id, sandbox: true, }, @@ -319,6 +324,7 @@ async function onPurchaseUpdated(purchase: Purchase) { const verified = result.iapkit; if ( verified?.isValid === true && + verified.environment === 'Sandbox' && verified.productId != null && verified.productId === purchase.productId ) { diff --git a/packages/docs/src/pages/docs/features/purchase.tsx b/packages/docs/src/pages/docs/features/purchase.tsx index e38fea82d..41da12cc3 100644 --- a/packages/docs/src/pages/docs/features/purchase.tsx +++ b/packages/docs/src/pages/docs/features/purchase.tsx @@ -809,6 +809,16 @@ async Task VerifyOnServerAsync(Purchase purchase) product your app expected; isValid alone is not enough.

+

+ For Amazon, include expectedProductId in the verification + payload. Amazon App Tester receipts require enabling{' '} + Allow Amazon App Tester / RVS Cloud Sandbox in the + IAPKit project before passing sandbox: true. Handled + Amazon results report exactly 'Sandbox' or{' '} + 'Production' in environment; require the + value expected by the build. +

+ Sign up at{' '} { const token = purchase.purchaseToken ?? ''; const runtimeOS = Platform.OS as string; @@ -848,8 +861,9 @@ const iapkitPayloadFor = async (purchase: Purchase) => { if (isAmazonRuntime) { return { amazon: { + expectedProductId: purchase.productId, receiptId: token, - sandbox: __DEV__, + sandbox: amazonSandbox, }, }; } @@ -870,8 +884,12 @@ const verifyWithIapkit = async (purchase: Purchase) => { const verified = result.iapkit; const verifiedProductId = verified?.productId; + const hasExpectedEnvironment = + verified?.store !== 'amazon' || + verified?.environment === (amazonSandbox ? 'Sandbox' : 'Production'); if ( verified?.isValid === true && + hasExpectedEnvironment && verifiedProductId != null && verifiedProductId === purchase.productId ) { @@ -899,6 +917,9 @@ function PurchaseScreen() { const verified = result.iapkit; if ( verified?.isValid !== true || + (verified.store === 'amazon' && + verified.environment !== + (amazonSandbox ? 'Sandbox' : 'Production')) || verified.productId == null || verified.productId !== purchase.productId ) { @@ -956,7 +977,9 @@ suspend fun verifyWithIapkit(purchase: PurchaseAndroid): Boolean { google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = purchase.purchaseToken.orEmpty() ) - // Fire OS: replace google with amazon(userId, receiptId, sandbox). + // Fire OS: replace google with amazon(expectedProductId, + // userId, receiptId, sandbox). App Tester needs project opt-in; + // handled Amazon results expose environment. ) ) ) @@ -992,7 +1015,9 @@ suspend fun verifyWithIapkit(purchase: PurchaseAndroid): Boolean { google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = purchase.purchaseToken.orEmpty() ) - // Fire OS builds use amazon(userId, receiptId, sandbox). + // Fire OS builds use amazon(expectedProductId, userId, + // receiptId, sandbox). App Tester needs project opt-in; + // handled Amazon results expose environment. ) ) ) @@ -1036,7 +1061,9 @@ Future verifyWithIapkit(Purchase purchase) async { purchaseToken: purchase.purchaseToken ?? '', ) : null, - // Fire OS builds can pass amazon with userId, receiptId, and sandbox. + // Fire OS builds can pass amazon with expectedProductId, userId, + // receiptId, and sandbox. App Tester needs project opt-in; + // handled Amazon results expose environment. ), ); diff --git a/packages/docs/src/pages/docs/features/validation.tsx b/packages/docs/src/pages/docs/features/validation.tsx index de1f848e1..7c3266cd8 100644 --- a/packages/docs/src/pages/docs/features/validation.tsx +++ b/packages/docs/src/pages/docs/features/validation.tsx @@ -328,6 +328,16 @@ if result is VerifyPurchaseResultIOS and result.is_valid: the app. The local purchase ID is an expected value, not a fallback when verification omits the ID.

+

+ For Amazon, send that expected value as{' '} + iapkit.amazon.expectedProductId. Amazon App Tester + receipts also require enabling{' '} + Allow Amazon App Tester / RVS Cloud Sandbox in the + IAPKit project before passing sandbox: true. Handled + Amazon results report exactly 'Sandbox' or{' '} + 'Production' in environment; require the + value expected by the build. +

{{ typescript: ( @@ -591,7 +601,8 @@ try { const stateAllowsFulfillment = isConsumable ? isApple ? verified?.state === 'ready-to-consume' - : verified?.state === 'entitled' || + : verified?.state === 'ready-to-consume' || + verified?.state === 'entitled' || verified?.state === 'pending-acknowledgment' : verified?.state === 'entitled' || (!isApple && verified?.state === 'pending-acknowledgment'); diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx index 2569cbc5b..797583bf4 100644 --- a/packages/docs/src/pages/docs/kit-backend.tsx +++ b/packages/docs/src/pages/docs/kit-backend.tsx @@ -273,9 +273,13 @@ function KitBackend() {

For Fire OS and Vega OS, choose the Amazon branch and pass the Amazon - receipt ID. The SDK resolves the Amazon user ID from the runtime when - available. Set sandbox: true when validating Amazon App - Tester sandbox receipts. + receipt ID plus expectedProductId. The SDK resolves the + Amazon user ID from the runtime when available. Before setting{' '} + sandbox: true for an Amazon App Tester receipt, enable{' '} + Allow Amazon App Tester / RVS Cloud Sandbox in the + IAPKit project settings. Handled Amazon results identify the selected + environment as exactly 'Sandbox' or{' '} + 'Production'; require the value expected by the build.

Grant access only when IAPKit returns a store-verified{' '} @@ -294,6 +298,8 @@ const token = purchase.purchaseToken ?? ''; const runtimeOS = Platform.OS as string; const isFireOSBuild = process.env.EXPO_PUBLIC_STORE === 'amazon'; const isAmazonRuntime = runtimeOS === 'kepler' || isFireOSBuild; +const amazonSandbox = + process.env.EXPO_PUBLIC_AMAZON_RVS_SANDBOX === 'true'; const result = await verifyPurchaseWithProvider({ provider: 'iapkit', iapkit: { @@ -307,8 +313,10 @@ const result = await verifyPurchaseWithProvider({ : isAmazonRuntime ? { amazon: { + expectedProductId: purchase.productId, receiptId: token, - sandbox: __DEV__, + // Requires the matching IAPKit project opt-in. + sandbox: amazonSandbox, }, } : { google: { purchaseToken: token } }), @@ -322,9 +330,13 @@ const hasAllowedState = (Platform.OS === 'android' && !isAmazonRuntime && verified?.state === 'pending-acknowledgment'); +const hasExpectedEnvironment = + verified?.store !== 'amazon' || + verified?.environment === (amazonSandbox ? 'Sandbox' : 'Production'); if ( verified?.isValid === true && hasAllowedState && + hasExpectedEnvironment && verifiedProductId != null && verifiedProductId === purchase.productId ) { @@ -371,7 +383,8 @@ val result = module.verifyPurchaseWithProvider( ), includeClientPayload = true, // Fire OS: use amazon = RequestVerifyPurchaseWithIapkitAmazonProps(...) - // with userId, receiptId, and sandbox for Amazon App Tester. + // with expectedProductId, userId, receiptId, and sandbox. + // Amazon App Tester sandbox needs the IAPKit project opt-in. ), ), ) @@ -408,7 +421,8 @@ final result = await FlutterInappPurchase.instance.verifyPurchaseWithProvider( ) : null, includeClientPayload: true, - // Fire OS builds can pass amazon with userId, receiptId, and sandbox. + // Fire OS builds can pass amazon with expectedProductId, userId, + // receiptId, and sandbox. App Tester sandbox needs the project opt-in. ), ); @@ -448,7 +462,8 @@ var result = await mutate.VerifyPurchaseWithProviderAsync( Google = isIos ? null : new RequestVerifyPurchaseWithIapkitGoogleProps { PurchaseToken = token }, - // Amazon Fire OS uses Amazon = new RequestVerifyPurchaseWithIapkitAmazonProps { ... }. + // Amazon Fire OS uses Amazon with ExpectedProductId, UserId, + // ReceiptId, and Sandbox. App Tester needs the project opt-in. }, }); @@ -477,7 +492,8 @@ val result = kmpIapInstance.verifyPurchaseWithProvider( apple = if (isIos) RequestVerifyPurchaseWithIapkitAppleProps(jws = token) else null, google = if (!isIos) RequestVerifyPurchaseWithIapkitGoogleProps(purchaseToken = token) else null, includeClientPayload = true, - // Amazon Fire OS builds use amazon with userId, receiptId, and sandbox. + // Amazon Fire OS builds use amazon with expectedProductId, userId, + // receiptId, and sandbox. App Tester needs the project opt-in. ), ), ) @@ -507,7 +523,9 @@ var result = await GodotIapPlugin.verify_purchase_with_provider({ "purchaseToken": purchase.get("purchaseToken", ""), }, # iOS: use "apple": { "jws": token } - # Fire OS: use "amazon": { "userId": user_id, "receiptId": receipt_id } + # Fire OS: use "amazon": { "expectedProductId": product_id, + # "userId": user_id, "receiptId": receipt_id, "sandbox": true } + # App Tester sandbox requires the IAPKit project opt-in. }, }) @@ -529,10 +547,11 @@ if (

These checks cover non-consumables and subscriptions. Choose the finish path from the app-owned product type and platform, not the - state alone: Apple and Amazon consumables use{' '} - ready-to-consume, while an unconsumed Google product may - be entitled or pending-acknowledgment. - Persist consumable delivery before finishing it; see the{' '} + state alone: Apple, Amazon, and catalog-known Google consumables use{' '} + ready-to-consume. When the catalog type is unknown, an + unconsumed Google product may instead be entitled or{' '} + pending-acknowledgment. Persist consumable delivery + before finishing it; see the{' '} state-aware verification flow diff --git a/packages/docs/src/pages/docs/setup/store/amazon.tsx b/packages/docs/src/pages/docs/setup/store/amazon.tsx index 61927978d..2183a3e97 100644 --- a/packages/docs/src/pages/docs/setup/store/amazon.tsx +++ b/packages/docs/src/pages/docs/setup/store/amazon.tsx @@ -141,8 +141,9 @@ function AmazonStoreSetup() { Verification payload - iapkit.amazon.receiptId, optional{' '} - userId, and sandbox for App Tester. + iapkit.amazon.receiptId; optional{' '} + userId and expectedProductId; plus{' '} + sandbox for App Tester. Same iapkit.amazon payload. The Vega adapter can @@ -438,7 +439,10 @@ id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreServ Fire OS and Vega OS both use the{' '} IAPKit Amazon payload. Pass the Amazon user id when available, the Amazon receipt id, and{' '} - sandbox: true for Amazon App Tester validation. + expectedProductId for server-side product binding. For + Amazon App Tester, first enable{' '} + Allow Amazon App Tester / RVS Cloud Sandbox in the + IAPKit project settings, then pass sandbox: true.

The example below uses the TypeScript SDKs (expo-iap,{' '} @@ -446,18 +450,29 @@ id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreServ iapkit.amazon payload through their own{' '} verifyPurchaseWithProvider call.

- {`await verifyPurchaseWithProvider({ + {`const expectedProductId = 'dev.your.app.product'; +const result = await verifyPurchaseWithProvider({ provider: 'iapkit', iapkit: { // Use an openiap-kit_pk_ publishable key in the app. apiKey: process.env.EXPO_PUBLIC_IAPKIT_PUBLISHABLE_KEY, amazon: { + expectedProductId, userId: amazonUserId, receiptId, sandbox: true, }, }, -});`} +}); + +const verified = result.iapkit; +if ( + verified?.isValid !== true || + verified.environment !== 'Sandbox' || + verified.productId !== expectedProductId +) { + throw new Error('Amazon Sandbox verification failed'); +}`}

See Validation for the cross-store verification model. diff --git a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-props.tsx b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-props.tsx index 4037e7667..d31155b58 100644 --- a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-props.tsx +++ b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-props.tsx @@ -271,7 +271,8 @@ function VerifyPurchaseWithProviderProps() {

Amazon Appstore receipt verification parameters. Fire OS and Vega OS both use this amazon payload when verifying through - IAPKit. + IAPKit. Amazon App Tester receipts also require the project-level{' '} + Allow Amazon App Tester / RVS Cloud Sandbox opt-in.

@@ -282,6 +283,19 @@ function VerifyPurchaseWithProviderProps() { + + + + + diff --git a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx index d799cab89..0ac64c182 100644 --- a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx +++ b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx @@ -156,6 +156,20 @@ function VerifyPurchaseWithProviderResult() { fulfillment path. + + + + + + {subscriptions.items.length === 0 && ( + + + + )} + {subscriptions.items.map((sub) => ( + + + + + + + + + ))} + +
+ expectedProductId + + string? + + Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / + openiap-google 3.3.0. Optional product ID that must exactly + match the product ID returned by Amazon RVS. +
userId @@ -314,7 +328,8 @@ function VerifyPurchaseWithProviderProps() { boolean? - Use Amazon RVS Cloud Sandbox for Amazon App Tester receipts. + Use Amazon RVS Cloud Sandbox for Amazon App Tester receipts. The + IAPKit project opt-in is disabled by default.
+ environment + + string? + + Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / + openiap-google 3.3.0. Amazon RVS environment. Handled Amazon + responses use exactly 'Sandbox' or{' '} + 'Production'; other stores omit it. +
state @@ -654,9 +668,10 @@ if ( These examples cover non-consumables and subscriptions: Apple examples require entitled, while Google examples also allow{' '} pending-acknowledgment. Choose the finish path from the - app-owned product type and platform, not the state alone. Apple and - Amazon consumables use ready-to-consume, while an - unconsumed Google product may be entitled or{' '} + app-owned product type and platform, not the state alone. Apple, + Amazon, and catalog-known Google consumables use{' '} + ready-to-consume. When the catalog type is unknown, an + unconsumed Google product may instead be entitled or{' '} pending-acknowledgment. Persist consumable delivery before finishing it; see the{' '} diff --git a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt index 86145ae9e..5ec9fa58f 100644 --- a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt @@ -58,6 +58,26 @@ private const val AMAZON_PRODUCT_DATA_BATCH_SIZE = 100 private const val AMAZON_PURCHASE_UPDATES_MAX_PAGES = 100 private const val AMAZON_EARLY_RESPONSE_CACHE_MAX = 128 +internal fun withResolvedAmazonUserId( + options: RequestVerifyPurchaseWithIapkitProps, + userId: String, +): RequestVerifyPurchaseWithIapkitProps { + val amazon = requireNotNull(options.amazon) + return RequestVerifyPurchaseWithIapkitProps( + amazon = RequestVerifyPurchaseWithIapkitAmazonProps( + expectedProductId = amazon.expectedProductId, + receiptId = amazon.receiptId, + sandbox = amazon.sandbox, + userId = userId, + ), + apiKey = options.apiKey, + apple = options.apple, + baseUrl = options.baseUrl, + google = options.google, + includeClientPayload = options.includeClientPayload, + ) +} + internal fun shouldIncludeAmazonReceipt( isCanceled: Boolean, hasCancelDate: Boolean, @@ -828,7 +848,7 @@ class OpenIapModule( val userDataResponse = requestUserData() val userId = userDataResponse.userData?.userId ?: throw OpenIapError.DeveloperError("Amazon IAPKit verification could not resolve userId") - options.copy(amazon = amazon.copy(userId = userId)) + withResolvedAmazonUserId(options, userId) } else { options } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index 5ec22d2a9..94114a587 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -3486,6 +3486,14 @@ public data class RequestVerifyPurchaseWithIapkitResult( var productId: String? = null private set + /** + * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + * Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + * `Production` on handled Amazon verification results. + */ + var environment: String? = null + private set + constructor( isValid: Boolean, state: IapkitPurchaseState, @@ -3501,6 +3509,23 @@ public data class RequestVerifyPurchaseWithIapkitResult( this.productId = productId } + constructor( + isValid: Boolean, + state: IapkitPurchaseState, + store: IapStore, + clientPayload: IapkitProductClientPayload?, + productId: String?, + environment: String?, + ) : this( + isValid = isValid, + state = state, + store = store, + ) { + this.clientPayload = clientPayload + this.productId = productId + this.environment = environment + } + companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitResult { return RequestVerifyPurchaseWithIapkitResult( @@ -3509,6 +3534,7 @@ public data class RequestVerifyPurchaseWithIapkitResult( store = runCatching { (json["store"] as? String)?.let { IapStore.fromJson(it) } }.getOrNull() ?: IapStore.Unknown, clientPayload = (json["clientPayload"] as? Map)?.let { IapkitProductClientPayload.fromJson(it) }, productId = json["productId"] as? String, + environment = json["environment"] as? String, ) } } @@ -3516,6 +3542,7 @@ public data class RequestVerifyPurchaseWithIapkitResult( fun toJson(): Map = mapOf( "__typename" to "RequestVerifyPurchaseWithIapkitResult", "store" to store.toJson(), + "environment" to environment, "isValid" to isValid, "state" to state.toJson(), "productId" to productId, @@ -5011,24 +5038,48 @@ public data class RequestVerifyPurchaseWithIapkitAmazonProps( */ val userId: String? = null ) { + + /** + * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + * Optional Amazon product id that must match the product id verified by RVS. + */ + var expectedProductId: String? = null + private set + + constructor( + receiptId: String, + sandbox: Boolean? = null, + userId: String? = null, + expectedProductId: String?, + ) : this( + receiptId = receiptId, + sandbox = sandbox, + userId = userId, + ) { + this.expectedProductId = expectedProductId + } + companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitAmazonProps? { val receiptId = json["receiptId"] as? String val sandbox = json["sandbox"] as? Boolean val userId = json["userId"] as? String + val expectedProductId = json["expectedProductId"] as? String if (receiptId == null) return null return RequestVerifyPurchaseWithIapkitAmazonProps( receiptId = receiptId, sandbox = sandbox, userId = userId, + expectedProductId = expectedProductId, ) } } fun toJson(): Map = mapOf( + "expectedProductId" to expectedProductId, + "userId" to userId, "receiptId" to receiptId, "sandbox" to sandbox, - "userId" to userId, ) } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt index 8d74ebabf..eed7395cb 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt @@ -246,6 +246,7 @@ suspend fun verifyPurchaseWithIapkit( "receiptId" to receiptId ).apply { amazon.sandbox?.let { put("sandbox", it) } + amazon.expectedProductId?.let { put("expectedProductId", it) } } } @@ -386,9 +387,17 @@ suspend fun verifyPurchaseWithIapkit( is String -> rawProductId else -> throw malformedIapkitResponse() } + val environment = when (val rawEnvironment = parsed["environment"]) { + null -> null + is String -> rawEnvironment.takeIf { + it == "Sandbox" || it == "Production" + } ?: throw malformedIapkitResponse() + else -> throw malformedIapkitResponse() + } return RequestVerifyPurchaseWithIapkitResult( clientPayload = clientPayload, + environment = environment, isValid = isValid, productId = productId, state = parsedState, diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt index ff6bdc8da..fd9f84656 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt @@ -431,16 +431,21 @@ class PurchaseVerificationValidatorTest { amazon = RequestVerifyPurchaseWithIapkitAmazonProps( userId = "amzn1.account.ABC123", receiptId = "amzn1.receipt.ABC123456789", - sandbox = true + sandbox = true, + expectedProductId = "premium.monthly" ) ) - val connection = FakeHttpURLConnection(200, """{"store":"amazon","isValid":true,"state":"ENTITLED"}""") + val connection = FakeHttpURLConnection( + 200, + """{"store":"amazon","isValid":true,"state":"ENTITLED","environment":"Sandbox"}""" + ) val result = verifyPurchaseWithIapkit(props, "TEST") { _ -> connection } assertEquals(IapStore.Amazon, result.store) assertTrue(result.isValid) assertEquals(IapkitPurchaseState.Entitled, result.state) + assertEquals("Sandbox", result.environment) assertEquals("Bearer secret", connection.headers["Authorization"]) val bodyMap = Gson().fromJson(requireNotNull(connection.writtenBody), Map::class.java) as Map<*, *> @@ -448,6 +453,61 @@ class PurchaseVerificationValidatorTest { assertEquals("amzn1.account.ABC123", bodyMap["userId"]) assertEquals("amzn1.receipt.ABC123456789", bodyMap["receiptId"]) assertEquals(true, bodyMap["sandbox"]) + assertEquals("premium.monthly", bodyMap["expectedProductId"]) + } + + @Test + fun `verifyPurchaseWithIapkit accepts absent null and production environments`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + amazon = RequestVerifyPurchaseWithIapkitAmazonProps( + userId = "amzn1.account.ABC123", + receiptId = "amzn1.receipt.ABC123456789" + ) + ) + val responses = listOf( + """{"store":"amazon","isValid":true,"state":"ENTITLED"}""" to null, + """{"store":"amazon","isValid":true,"state":"ENTITLED","environment":null}""" to null, + """{"store":"amazon","isValid":true,"state":"ENTITLED","environment":"Production"}""" to "Production" + ) + + for ((response, expectedEnvironment) in responses) { + val result = verifyPurchaseWithIapkit(props, "TEST") { _ -> + FakeHttpURLConnection(200, response) + } + assertEquals(expectedEnvironment, result.environment) + } + } + + @Test + fun `verifyPurchaseWithIapkit rejects invalid environments`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + amazon = RequestVerifyPurchaseWithIapkitAmazonProps( + userId = "amzn1.account.ABC123", + receiptId = "amzn1.receipt.ABC123456789" + ) + ) + val invalidEnvironments = listOf( + "\"sandbox\"", + "\"Xcode\"", + "42", + "true", + "{}", + "[]" + ) + + for (environment in invalidEnvironments) { + try { + verifyPurchaseWithIapkit(props, "TEST") { _ -> + FakeHttpURLConnection( + 200, + """{"store":"amazon","isValid":true,"state":"ENTITLED","environment":$environment}""" + ) + } + throw AssertionError("Expected malformed environment to fail: $environment") + } catch (error: OpenIapError.PurchaseVerificationFailed) { + assertTrue(error.message.contains("malformed")) + } + } } @Test diff --git a/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonIapkitOptionsTest.kt b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonIapkitOptionsTest.kt new file mode 100644 index 000000000..ed451eb5f --- /dev/null +++ b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonIapkitOptionsTest.kt @@ -0,0 +1,33 @@ +package dev.hyo.openiap + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AmazonIapkitOptionsTest { + @Test + fun resolvedUserIdPreservesProductBindingAndOptions() { + val options = RequestVerifyPurchaseWithIapkitProps( + amazon = RequestVerifyPurchaseWithIapkitAmazonProps( + expectedProductId = "dev.hyo.martie.10bulbs", + receiptId = "amzn1.receipt.test", + sandbox = true, + userId = null, + ), + apiKey = "openiap-kit_pk_test", + apple = null, + baseUrl = "https://kit.openiap.dev", + google = null, + includeClientPayload = true, + ) + + val resolved = withResolvedAmazonUserId(options, "amzn1.account.test") + + assertEquals("dev.hyo.martie.10bulbs", resolved.amazon?.expectedProductId) + assertEquals("amzn1.receipt.test", resolved.amazon?.receiptId) + assertEquals(true, resolved.amazon?.sandbox) + assertEquals("amzn1.account.test", resolved.amazon?.userId) + assertEquals("openiap-kit_pk_test", resolved.apiKey) + assertEquals("https://kit.openiap.dev", resolved.baseUrl) + assertEquals(true, resolved.includeClientPayload) + } +} diff --git a/packages/gql/codegen/plugins/kotlin.ts b/packages/gql/codegen/plugins/kotlin.ts index 5a0af91a6..1d1d94454 100644 --- a/packages/gql/codegen/plugins/kotlin.ts +++ b/packages/gql/codegen/plugins/kotlin.ts @@ -30,6 +30,7 @@ import { interface CompatibleDataClassShape { primaryFields: string[]; extraFields: string[]; + legacyExtraFieldCounts?: number[]; } const COMPATIBLE_DATA_CLASS_SHAPES: Record = { @@ -43,11 +44,16 @@ const COMPATIBLE_DATA_CLASS_SHAPES: Record = { }, RequestVerifyPurchaseWithIapkitResult: { primaryFields: ['isValid', 'state', 'store'], - extraFields: ['clientPayload', 'productId'], + extraFields: ['clientPayload', 'productId', 'environment'], + legacyExtraFieldCounts: [2], }, }; const COMPATIBLE_INPUT_DATA_CLASS_SHAPES: Record = { + RequestVerifyPurchaseWithIapkitAmazonProps: { + primaryFields: ['receiptId', 'sandbox', 'userId'], + extraFields: ['expectedProductId'], + }, RequestVerifyPurchaseWithIapkitProps: { primaryFields: ['amazon', 'apiKey', 'apple', 'baseUrl', 'google'], extraFields: ['includeClientPayload'], @@ -383,25 +389,36 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(''); } - this.emit(' constructor('); - for (const value of primaryFields) { - const defaultValue = this.getObjectFieldDefault(value); - this.emit(` ${value.name}: ${this.getPropertyType(value.type)}${defaultValue},`); - } - extraFields.forEach((value, index) => { - const defaultValue = index === 0 ? '' : ' = null'; - this.emit(` ${value.name}: ${this.getPropertyType(value.type)}${defaultValue},`); - }); - this.emit(' ) : this('); - for (const value of primaryFields) { - this.emit(` ${value.name} = ${value.name},`); - } - this.emit(' ) {'); - for (const value of extraFields) { - this.emit(` this.${value.name} = ${value.name}`); + const constructorExtraFieldCounts = [ + ...new Set([...(shape.legacyExtraFieldCounts ?? []), extraFields.length]), + ]; + for (const extraFieldCount of constructorExtraFieldCounts) { + if (extraFieldCount < 1 || extraFieldCount > extraFields.length) { + throw new Error(`${irObject.name} has an invalid compatibility constructor size`); + } + const constructorExtraFields = extraFields.slice(0, extraFieldCount); + const isCurrentConstructor = extraFieldCount === extraFields.length; + const hasLegacyConstructor = (shape.legacyExtraFieldCounts?.length ?? 0) > 0; + this.emit(' constructor('); + for (const value of primaryFields) { + const defaultValue = this.getObjectFieldDefault(value); + this.emit(` ${value.name}: ${this.getPropertyType(value.type)}${defaultValue},`); + } + constructorExtraFields.forEach((value, index) => { + const defaultValue = index === 0 || (isCurrentConstructor && hasLegacyConstructor) ? '' : ' = null'; + this.emit(` ${value.name}: ${this.getPropertyType(value.type)}${defaultValue},`); + }); + this.emit(' ) : this('); + for (const value of primaryFields) { + this.emit(` ${value.name} = ${value.name},`); + } + this.emit(' ) {'); + for (const value of constructorExtraFields) { + this.emit(` this.${value.name} = ${value.name}`); + } + this.emit(' }'); + this.emit(''); } - this.emit(' }'); - this.emit(''); this.emit(' companion object {'); this.emit(` fun fromJson(json: Map): ${irObject.name} {`); @@ -614,18 +631,43 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(' }'); this.emit(''); + const allFields = [...primaryFields, ...extraFields]; + const requiredFields = allFields.filter( + (value) => !value.type.nullable && !this.hasSchemaDefault(value) && value.type.kind !== 'enum', + ); + const hasRequiredFields = requiredFields.length > 0; + this.emit(' companion object {'); - this.emit(` fun fromJson(json: Map): ${irInput.name} {`); - this.emit(` return ${irInput.name}(`); - for (const value of [...primaryFields, ...extraFields]) { - const expression = this.buildFromJsonExpression( - value.type, - `json["${value.name}"]`, - false, - false, - this.buildDefaultValueExpression(value), - ); - this.emit(` ${value.name} = ${expression},`); + this.emit(` fun fromJson(json: Map): ${irInput.name}${hasRequiredFields ? '?' : ''} {`); + if (hasRequiredFields) { + for (const value of allFields) { + const expression = this.buildFromJsonExpression( + value.type, + `json["${value.name}"]`, + false, + true, + this.buildDefaultValueExpression(value), + ); + this.emit(` val ${value.name} = ${expression}`); + } + const nullChecks = requiredFields.map((value) => `${value.name} == null`).join(' || '); + this.emit(` if (${nullChecks}) return null`); + this.emit(` return ${irInput.name}(`); + for (const value of allFields) { + this.emit(` ${value.name} = ${value.name},`); + } + } else { + this.emit(` return ${irInput.name}(`); + for (const value of allFields) { + const expression = this.buildFromJsonExpression( + value.type, + `json["${value.name}"]`, + false, + false, + this.buildDefaultValueExpression(value), + ); + this.emit(` ${value.name} = ${expression},`); + } } this.emit(' )'); this.emit(' }'); diff --git a/packages/gql/src/generated-compatibility.test.ts b/packages/gql/src/generated-compatibility.test.ts index f4425d797..4d91cdaf2 100644 --- a/packages/gql/src/generated-compatibility.test.ts +++ b/packages/gql/src/generated-compatibility.test.ts @@ -475,12 +475,19 @@ export interface WrongOwner { kotlin.indexOf('public data class RequestVerifyPurchaseWithIapkitResult('), kotlin.indexOf('public data class SubscriptionCommitmentInfoIOS('), ); + const iapkitAmazonProps = kotlin.slice( + kotlin.indexOf('public data class RequestVerifyPurchaseWithIapkitAmazonProps('), + kotlin.indexOf('public data class RequestVerifyPurchaseWithIapkitAppleProps('), + ); const iapkitProps = kotlin.slice( kotlin.indexOf('public data class RequestVerifyPurchaseWithIapkitProps('), kotlin.indexOf('public data class SubscriptionProductReplacementParamsAndroid('), ); const withoutDocComments = (value: string) => value.replace(/\/\*\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' '); const iapkitResultPrimary = withoutDocComments(iapkitResult.slice(0, iapkitResult.indexOf(') {') + 3)); + const iapkitAmazonPropsPrimary = withoutDocComments( + iapkitAmazonProps.slice(0, iapkitAmazonProps.indexOf(') {') + 3), + ); const iapkitPropsPrimary = withoutDocComments(iapkitProps.slice(0, iapkitProps.indexOf(') {') + 3)); expect(userChoice).toContain('val externalTransactionToken: String,'); @@ -494,13 +501,30 @@ export interface WrongOwner { ); expect(iapkitResult).toContain('var clientPayload: IapkitProductClientPayload? = null'); expect(iapkitResult).toContain('var productId: String? = null'); + expect(iapkitResult).toContain('var environment: String? = null'); + expect(iapkitResult).toContain(` productId: String? = null, + ) : this(`); + expect(iapkitResult).toContain(` productId: String?, + environment: String?, + ) : this(`); expect(iapkitResultPrimary).not.toContain('clientPayload'); expect(iapkitResultPrimary).not.toContain('productId'); + expect(iapkitResultPrimary).not.toContain('environment'); + expect(iapkitAmazonPropsPrimary).toContain( + 'public data class RequestVerifyPurchaseWithIapkitAmazonProps( val receiptId: String, val sandbox: Boolean? = null, val userId: String? = null ) {', + ); + expect(iapkitAmazonProps).toContain('var expectedProductId: String? = null'); + expect(iapkitAmazonPropsPrimary).not.toContain('expectedProductId'); + expect(iapkitAmazonProps).toContain( + 'fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitAmazonProps?', + ); + expect(iapkitAmazonProps).toContain('if (receiptId == null) return null'); expect(iapkitPropsPrimary).toContain( 'public data class RequestVerifyPurchaseWithIapkitProps( val amazon: RequestVerifyPurchaseWithIapkitAmazonProps? = null, val apiKey: String? = null, val apple: RequestVerifyPurchaseWithIapkitAppleProps? = null, val baseUrl: String? = null, val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null ) {', ); expect(iapkitProps).toContain('var includeClientPayload: Boolean? = null'); expect(iapkitResult).toContain('private set'); + expect(iapkitAmazonProps).toContain('private set'); expect(iapkitProps).toContain('private set'); expect(iapkitPropsPrimary).not.toContain('includeClientPayload'); }); diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index 904799465..5c1f7b963 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -3461,6 +3461,13 @@ public sealed record RequestVerifyPurchaseWithIapkitResult [JsonPropertyName("clientPayload")] public IapkitProductClientPayload? ClientPayload { get; init; } /// + /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + /// Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + /// `Production` on handled Amazon verification results. + /// + [JsonPropertyName("environment")] + public string? Environment { get; init; } + /// /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. /// Callers must still match productId and use the platform plus app-owned product @@ -4252,6 +4259,12 @@ public sealed record RequestSubscriptionPropsByPlatforms public sealed record RequestVerifyPurchaseWithIapkitAmazonProps { + /// + /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + /// Optional Amazon product id that must match the product id verified by RVS. + /// + [JsonPropertyName("expectedProductId")] + public string? ExpectedProductId { get; init; } /// Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). [JsonPropertyName("userId")] public string? UserId { get; init; } diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index ac6a88039..7184add30 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -3432,6 +3432,14 @@ public data class RequestVerifyPurchaseWithIapkitResult( var productId: String? = null private set + /** + * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + * Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + * `Production` on handled Amazon verification results. + */ + var environment: String? = null + private set + constructor( isValid: Boolean, state: IapkitPurchaseState, @@ -3447,6 +3455,23 @@ public data class RequestVerifyPurchaseWithIapkitResult( this.productId = productId } + constructor( + isValid: Boolean, + state: IapkitPurchaseState, + store: IapStore, + clientPayload: IapkitProductClientPayload?, + productId: String?, + environment: String?, + ) : this( + isValid = isValid, + state = state, + store = store, + ) { + this.clientPayload = clientPayload + this.productId = productId + this.environment = environment + } + companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitResult { return RequestVerifyPurchaseWithIapkitResult( @@ -3455,6 +3480,7 @@ public data class RequestVerifyPurchaseWithIapkitResult( store = runCatching { (json["store"] as? String)?.let { IapStore.fromJson(it) } }.getOrNull() ?: IapStore.Unknown, clientPayload = (json["clientPayload"] as? Map)?.let { IapkitProductClientPayload.fromJson(it) }, productId = json["productId"] as? String, + environment = json["environment"] as? String, ) } } @@ -3462,6 +3488,7 @@ public data class RequestVerifyPurchaseWithIapkitResult( fun toJson(): Map = mapOf( "__typename" to "RequestVerifyPurchaseWithIapkitResult", "store" to store.toJson(), + "environment" to environment, "isValid" to isValid, "state" to state.toJson(), "productId" to productId, @@ -4957,24 +4984,48 @@ public data class RequestVerifyPurchaseWithIapkitAmazonProps( */ val userId: String? = null ) { + + /** + * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + * Optional Amazon product id that must match the product id verified by RVS. + */ + var expectedProductId: String? = null + private set + + constructor( + receiptId: String, + sandbox: Boolean? = null, + userId: String? = null, + expectedProductId: String?, + ) : this( + receiptId = receiptId, + sandbox = sandbox, + userId = userId, + ) { + this.expectedProductId = expectedProductId + } + companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitAmazonProps? { val receiptId = json["receiptId"] as? String val sandbox = json["sandbox"] as? Boolean val userId = json["userId"] as? String + val expectedProductId = json["expectedProductId"] as? String if (receiptId == null) return null return RequestVerifyPurchaseWithIapkitAmazonProps( receiptId = receiptId, sandbox = sandbox, userId = userId, + expectedProductId = expectedProductId, ) } } fun toJson(): Map = mapOf( + "expectedProductId" to expectedProductId, + "userId" to userId, "receiptId" to receiptId, "sandbox" to sandbox, - "userId" to userId, ) } diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index d3bfac2ef..89d702a11 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -1230,6 +1230,10 @@ public struct RequestVerifyPurchaseWithIapkitResult: Codable { /// Public product payload when includeClientPayload was requested, the /// Apple or Google receipt is valid, and a payload exists for that product. public var clientPayload: IapkitProductClientPayload? = nil + /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + /// Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + /// `Production` on handled Amazon verification results. + public var environment: String? = nil /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. /// Callers must still match productId and use the platform plus app-owned product @@ -2027,6 +2031,9 @@ public struct RequestSubscriptionPropsByPlatforms: Codable { } public struct RequestVerifyPurchaseWithIapkitAmazonProps: Codable { + /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + /// Optional Amazon product id that must match the product id verified by RVS. + public var expectedProductId: String? /// Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). public var receiptId: String /// Use Amazon RVS Cloud Sandbox for App Tester receipts. @@ -2035,10 +2042,12 @@ public struct RequestVerifyPurchaseWithIapkitAmazonProps: Codable { public var userId: String? public init( + expectedProductId: String? = nil, receiptId: String, sandbox: Bool? = nil, userId: String? = nil ) { + self.expectedProductId = expectedProductId self.receiptId = receiptId self.sandbox = sandbox self.userId = userId diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 06fd9d24a..b839a4840 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -3333,6 +3333,7 @@ class RequestPurchaseResultPurchases extends RequestPurchaseResult { class RequestVerifyPurchaseWithIapkitResult { const RequestVerifyPurchaseWithIapkitResult({ this.clientPayload, + this.environment, required this.isValid, this.productId, required this.state, @@ -3343,6 +3344,10 @@ class RequestVerifyPurchaseWithIapkitResult { /// Public product payload when includeClientPayload was requested, the /// Apple or Google receipt is valid, and a payload exists for that product. final IapkitProductClientPayload? clientPayload; + /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + /// Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + /// `Production` on handled Amazon verification results. + final String? environment; /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. /// Callers must still match productId and use the platform plus app-owned product @@ -3358,6 +3363,7 @@ class RequestVerifyPurchaseWithIapkitResult { factory RequestVerifyPurchaseWithIapkitResult.fromJson(Map json) { return RequestVerifyPurchaseWithIapkitResult( clientPayload: json['clientPayload'] != null ? IapkitProductClientPayload.fromJson(json['clientPayload'] as Map) : null, + environment: json['environment'] as String?, isValid: json['isValid'] as bool, productId: json['productId'] as String?, state: IapkitPurchaseState.fromJson(json['state'] as String), @@ -3369,6 +3375,7 @@ class RequestVerifyPurchaseWithIapkitResult { return { '__typename': 'RequestVerifyPurchaseWithIapkitResult', 'clientPayload': clientPayload?.toJson(), + 'environment': environment, 'isValid': isValid, 'productId': productId, 'state': state.toJson(), @@ -4774,11 +4781,15 @@ class RequestSubscriptionPropsByPlatforms { class RequestVerifyPurchaseWithIapkitAmazonProps { const RequestVerifyPurchaseWithIapkitAmazonProps({ + this.expectedProductId, required this.receiptId, this.sandbox, this.userId, }); + /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + /// Optional Amazon product id that must match the product id verified by RVS. + final String? expectedProductId; /// Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). final String receiptId; /// Use Amazon RVS Cloud Sandbox for App Tester receipts. @@ -4788,6 +4799,7 @@ class RequestVerifyPurchaseWithIapkitAmazonProps { factory RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(Map json) { return RequestVerifyPurchaseWithIapkitAmazonProps( + expectedProductId: json['expectedProductId'] as String?, receiptId: json['receiptId'] as String, sandbox: json['sandbox'] as bool?, userId: json['userId'] as String?, @@ -4796,6 +4808,7 @@ class RequestVerifyPurchaseWithIapkitAmazonProps { Map toJson() { return { + 'expectedProductId': expectedProductId, 'receiptId': receiptId, 'sandbox': sandbox, 'userId': userId, diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index b03a5e6b2..bb0b4a65d 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -2755,6 +2755,8 @@ class RentalDetailsAndroid: class RequestVerifyPurchaseWithIapkitResult: var store: IapStore + ## Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment selected by IAPKit. Present as `Sandbox` or `Production` on handled Amazon verification results. + var environment: Variant = null ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. var is_valid: bool = false ## The current state of the purchase. @@ -2772,6 +2774,8 @@ class RequestVerifyPurchaseWithIapkitResult: obj.store = IAP_STORE_FROM_STRING.get(enum_str, IapStore.UNKNOWN) else: obj.store = enum_str + if data.has("environment") and data["environment"] != null: + obj.environment = data["environment"] if data.has("isValid") and data["isValid"] != null: obj.is_valid = data["isValid"] if data.has("state") and data["state"] != null: @@ -2795,6 +2799,8 @@ class RequestVerifyPurchaseWithIapkitResult: dict["store"] = IAP_STORE_VALUES[store] else: dict["store"] = store + if environment != null: + dict["environment"] = environment dict["isValid"] = is_valid if IAPKIT_PURCHASE_STATE_VALUES.has(state): dict["state"] = IAPKIT_PURCHASE_STATE_VALUES[state] @@ -4400,6 +4406,8 @@ class RequestSubscriptionPropsByPlatforms: return dict class RequestVerifyPurchaseWithIapkitAmazonProps: + ## Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Optional Amazon product id that must match the product id verified by RVS. + var expected_product_id: Variant = null ## Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). var user_id: Variant = null ## Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). @@ -4409,6 +4417,8 @@ class RequestVerifyPurchaseWithIapkitAmazonProps: static func from_dict(data: Dictionary) -> RequestVerifyPurchaseWithIapkitAmazonProps: var obj = RequestVerifyPurchaseWithIapkitAmazonProps.new() + if data.has("expectedProductId") and data["expectedProductId"] != null: + obj.expected_product_id = data["expectedProductId"] if data.has("userId") and data["userId"] != null: obj.user_id = data["userId"] if data.has("receiptId") and data["receiptId"] != null: @@ -4419,6 +4429,8 @@ class RequestVerifyPurchaseWithIapkitAmazonProps: func to_dict() -> Dictionary: var dict = {} + if expected_product_id != null: + dict["expectedProductId"] = expected_product_id if user_id != null: dict["userId"] = user_id if receipt_id != null: diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 5934056f1..7e9696c79 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1791,6 +1791,11 @@ export interface RequestSubscriptionPropsByPlatforms { } export interface RequestVerifyPurchaseWithIapkitAmazonProps { + /** + * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + * Optional Amazon product id that must match the product id verified by RVS. + */ + expectedProductId?: (string | null); /** Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). */ receiptId: string; /** Use Amazon RVS Cloud Sandbox for App Tester receipts. */ @@ -1848,6 +1853,12 @@ export interface RequestVerifyPurchaseWithIapkitResult { * Apple or Google receipt is valid, and a payload exists for that product. */ clientPayload?: (IapkitProductClientPayload | null); + /** + * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + * Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + * `Production` on handled Amazon verification results. + */ + environment?: (string | null); /** * True when the purchase is valid and actionable. * Only entitled, pending-acknowledgment, or ready-to-consume return true. diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index cc626332a..1b24fc8e2 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -297,6 +297,11 @@ input RequestVerifyPurchaseWithIapkitGoogleProps { } input RequestVerifyPurchaseWithIapkitAmazonProps { + """ + Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + Optional Amazon product id that must match the product id verified by RVS. + """ + expectedProductId: String """ Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). """ @@ -416,6 +421,12 @@ type IapkitProductClientPayload { type RequestVerifyPurchaseWithIapkitResult { store: IapStore! """ + Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. + Amazon RVS environment selected by IAPKit. Present as `Sandbox` or + `Production` on handled Amazon verification results. + """ + environment: String + """ True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product diff --git a/packages/kit/CONVENTION.md b/packages/kit/CONVENTION.md index c2285f0ce..35273ebd3 100644 --- a/packages/kit/CONVENTION.md +++ b/packages/kit/CONVENTION.md @@ -196,7 +196,7 @@ action result open is bounded much more aggressively (iOS Safari aborts pending fetches when a tab backgrounds or the network flips, surfacing as `TypeError: Load failed`). Anything that walks an external catalog or fans out per-product API calls — App Store -Connect / Play Console sync, Meta Horizon reconciliation, future +Connect / Play Console sync, Amazon RVS reconciliation, future Stripe price sync — must run as a background job, not as a synchronous public action the dashboard awaits. diff --git a/packages/kit/README.md b/packages/kit/README.md index 3171f9246..bcf887d2a 100644 --- a/packages/kit/README.md +++ b/packages/kit/README.md @@ -460,6 +460,41 @@ never sees the secret. Verify calls use See [`convex/purchases/horizon.ts`](convex/purchases/horizon.ts). +### Amazon Appstore RVS + +Amazon requests use +`{ "store": "amazon", "userId": "...", "receiptId": "...", "sandbox"?: true, "expectedProductId"?: "..." }`. +Production verification requires the project's write-only RVS shared secret. +Cloud Sandbox is disabled by default because Amazon accepts any non-empty +secret there; a project operator must explicitly enable **Allow Amazon App Tester +/ RVS Cloud Sandbox** in project settings. Sandbox calls always use an IAPKit +placeholder and never place the production secret in the sandbox URL. + +Handled Amazon responses include `environment: "Sandbox" | "Production"`, and +purchase rows persist the same provenance. `expectedProductId` is a +caller-scoped guard: a mismatch returns `INAUTHENTIC`, while the purchase row +keeps the store-verified product and state. + +Valid Amazon purchase rows become due for another RVS check 48 hours after the +latest authoritative write. This is a scheduling cadence, not a completion +guarantee: the bounded worker claims at most 20 due rows per five-minute tick +(5,760/day, or 17,280 over Amazon's 72-hour window before failures), with a +12-minute crash-recovery lease. Deployments approaching that active-row ceiling +must monitor the due backlog rather than assume every row will complete within +48 or 72 hours. The worker makes one 10-second attempt per row and spaces request +starts by 200 ms (at most 5 TPS). Definitive 400/497 and 410 responses update the +row; network, timeout, throttling, secret, and response-protocol failures only +reschedule the claim and never overwrite a newer authoritative snapshot. The +compare-and-set apply mutation also prevents a slow worker from replacing a +foreground verification that completed after the claim. + +Amazon's `cancelDate` is treated as the loss-of-access signal. `renewalDate` is +the next renewal date and is not inferred as expiry when it is in the past. +This reconciler updates the unified `purchases` table only; it does not create +Amazon `subscriptions` rows or claim webhook-style subscription semantics. + +See [`convex/purchases/amazon.ts`](convex/purchases/amazon.ts). + ### Apple refund detection The Apple verify path calls `AppStoreServerAPIClient.getTransactionInfo` @@ -529,6 +564,40 @@ that URL against the committed [`production.env`](production.env) SSOT before either deployment proceeds. A development deploy key therefore cannot publish a production Fly bundle. +Self-hosted deployments that never completed the original row-wise purchase +stats backfill should run it first (already-completed deployments no-op): + +```bash +npx convex run migrations:run \ + '{"fn":"migrations:backfillPurchaseStatsFromPurchases"}' +``` + +Deployments with purchase history from before the Amazon and Horizon stats +buckets were added should also run the new resumable migration from +`packages/kit/`: + +```bash +npx convex run migrations:run \ + '{"fn":"migrations:backfillPurchaseStatsStoreBuckets"}' +``` + +It processes one purchase per mutation. An atomic per-purchase sentinel makes +partial resumes and resets safe without a project-wide receipt scan, while new +purchases are skipped because their store buckets are already counted. The +hosted IAPKit audit found no historical Amazon or Horizon purchases, so no +hosted migration run was needed or performed. + +For deployments that also need to clean up legacy duplicate Google orders, the +required order is: complete `backfillPurchaseStatsFromPurchases`, run +`collapseDuplicatePurchasesByOrderId`, then run the full-project +`recomputeAllPurchaseStats` last whenever a non-dry cleanup run reports +`rowsDeleted > 0`. The recompute is optional only if cleanup is not run or +deletes no rows. The Amazon/Horizon `backfillPurchaseStatsStoreBuckets` +migration is independent of the Google duplicate cleanup, so those two steps +may run in either order after the base backfill. Both must finish before any +required recompute. The recompute does not write per-purchase sentinels, so +running it first would let a later row backfill replay counts. + `VITE_*` values have to be passed at **build time**, not just runtime secrets — Vite inlines them into the SPA bundle at `bun run build` time. The deploy script sends the Convex-CLI-verified diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts index 398644b2e..fb828333c 100644 --- a/packages/kit/convex/_generated/api.d.ts +++ b/packages/kit/convex/_generated/api.d.ts @@ -62,8 +62,6 @@ import type * as purchases_query from "../purchases/query.js"; import type * as purchases_retry from "../purchases/retry.js"; import type * as purchases_shared from "../purchases/shared.js"; import type * as purchases_stats from "../purchases/stats.js"; -import type * as subscriptions_horizon from "../subscriptions/horizon.js"; -import type * as subscriptions_horizonInternal from "../subscriptions/horizonInternal.js"; import type * as subscriptions_internal from "../subscriptions/internal.js"; import type * as subscriptions_monthlyMicros from "../subscriptions/monthlyMicros.js"; import type * as subscriptions_mutation from "../subscriptions/mutation.js"; @@ -149,8 +147,6 @@ declare const fullApi: ApiFromModules<{ "purchases/retry": typeof purchases_retry; "purchases/shared": typeof purchases_shared; "purchases/stats": typeof purchases_stats; - "subscriptions/horizon": typeof subscriptions_horizon; - "subscriptions/horizonInternal": typeof subscriptions_horizonInternal; "subscriptions/internal": typeof subscriptions_internal; "subscriptions/monthlyMicros": typeof subscriptions_monthlyMicros; "subscriptions/mutation": typeof subscriptions_mutation; diff --git a/packages/kit/convex/crons.ts b/packages/kit/convex/crons.ts index b9077215a..dfb44cb2e 100644 --- a/packages/kit/convex/crons.ts +++ b/packages/kit/convex/crons.ts @@ -66,25 +66,23 @@ crons.interval( { olderThanMs: WEBHOOK_RETENTION_MS }, ); -// Meta Horizon Store has no webhook system — Meta only exposes a -// synchronous `verify_entitlement` Graph API. We poll every 6h to -// reconcile Active / InGracePeriod / Paused subscriptions against -// Meta's authoritative answer, feeding the deltas through the same -// state machine the Apple/Google webhook receivers use. +// Amazon recommends checking every active RVS receipt within 72 hours. +// Rows become due on a 48-hour cadence; the bounded worker processes at most +// 20 per tick, so backlog and retries can delay completion beyond that target. crons.interval( - "reconcile horizon entitlements", - { hours: 6 }, - internal.subscriptions.horizon.reconcileHorizonEntitlements, + "reconcile amazon purchases", + { minutes: 5 }, + internal.purchases.amazon.reconcileAmazonPurchases, {}, ); // Daily drift correction for the incrementally-maintained // `subscriptionStats` table. The incremental path in -// applySubscriptionEvent / recordHorizonStatus is correct in steady -// state, but a missed invocation (action timeout, manual db.patch, -// schema drift during rollout) can drift the counters. Recomputing -// the most-stale 100 projects per tick keeps the dashboard self- -// healing without operator intervention. +// applySubscriptionEvent is correct in steady state, but a missed +// invocation (action timeout, manual db.patch, schema drift during +// rollout) can drift the counters. Recomputing the most-stale 100 +// projects per tick keeps the dashboard self-healing without operator +// intervention. crons.interval( "recompute subscription stats (drift correction)", { hours: 24 }, diff --git a/packages/kit/convex/migrations.ts b/packages/kit/convex/migrations.ts index 8cf3a8ed5..14439e468 100644 --- a/packages/kit/convex/migrations.ts +++ b/packages/kit/convex/migrations.ts @@ -8,7 +8,7 @@ import { } from "./purchases/shared.js"; import { applyPurchaseStatsDelta, - deltaForInsert, + deltaForMissingPurchaseStats, recomputePurchaseStatsForProject, } from "./purchases/stats.js"; @@ -97,10 +97,13 @@ export const removePurchaseIdFromRequestData = migrations.define({ * Iterates the `purchases` table. Each `migrateOne` call runs as its own * mutation — bounded to one purchase + one stats-row upsert — so * per-project receipt volume never blows the per-transaction read/write - * budget. `statsCounted` on the purchase doc acts as a per-row sentinel - * so the migration is safe to resume after partial runs; new purchases - * from `savePurchaseInternal` are created with `statsCounted: true` so - * they're skipped here. + * budget. The base `statsCounted` and later `storeStatsCounted` sentinels + * make the migration safe to resume after partial runs and coordinate it + * with `backfillPurchaseStatsStoreBuckets` in either order. New purchases + * from `savePurchaseInternal` are created with both sentinels set. + * Complete this base backfill before running + * `collapseDuplicatePurchasesByOrderId`; the cleanup fails fast when a + * duplicate sibling has not claimed its base contribution. * * Run ONCE per dataset. Concurrent writes during the migration window * are safe because: (a) new inserts are already marked counted, and @@ -111,10 +114,9 @@ export const removePurchaseIdFromRequestData = migrations.define({ */ export const backfillPurchaseStatsFromPurchases = migrations.define({ table: "purchases", + batchSize: 1, migrateOne: async (ctx, doc) => { - if (doc.statsCounted === true) { - return doc; - } + if (doc.statsCounted === true && doc.storeStatsCounted === true) return; // Prefer the stored `orderId` column, but fall back to extracting // from `remoteResponse` so the stats backfill can run before OR @@ -134,10 +136,16 @@ export const backfillPurchaseStatsFromPurchases = migrations.define({ await applyPurchaseStatsDelta( ctx, doc.projectId, - deltaForInsert(doc.store, doc.isValid ?? false, hasOrderId), + deltaForMissingPurchaseStats( + doc.store, + doc.isValid ?? false, + hasOrderId, + doc.statsCounted === true, + doc.storeStatsCounted === true, + ), ); - return { ...doc, statsCounted: true }; + return { statsCounted: true, storeStatsCounted: true }; }, }); @@ -158,14 +166,18 @@ export const backfillPurchaseStats = migrations.define({ /** * Migration: Recompute every project's `purchaseStats` row from scratch. * - * Run this as the FINAL step of the deploy sequence, after both - * `backfillPurchaseOrderIds` and `collapseDuplicatePurchasesByOrderId`. + * Run this as the FINAL step of the deploy sequence. Complete + * `backfillPurchaseStatsFromPurchases` before + * `collapseDuplicatePurchasesByOrderId`; the independent + * `backfillPurchaseStatsStoreBuckets` migration may run before or after that + * cleanup. Finish all of them (and `backfillPurchaseOrderIds`, when needed) + * before this recompute. * The per-row `backfillPurchaseStatsFromPurchases` path can slightly * over-count `googleOrders` while duplicate-orderId rows still exist; * running this mutation last rebuilds `googleOrders` as the true - * distinct-orderId count and re-aligns `total` / `apple` / `google` / - * `valid` / `invalid` against whatever the `purchases` table actually - * contains after the collapse. + * distinct-orderId count and re-aligns the total, per-store, valid, and + * invalid counters against whatever the `purchases` table actually contains + * after the collapse. * * Runs in a single mutation per project. For every project in the * current dataset this fits inside Convex's per-transaction read @@ -177,6 +189,11 @@ export const backfillPurchaseStats = migrations.define({ * a good template. This migration will fail fast (read-bytes limit * error) rather than produce a bad stats row, so the failure mode is * safe. + * + * Do not run this before the two row migrations above: a full recompute does + * not mark per-purchase sentinels, so a later row migration would replay the + * same contribution. Run it last whenever a non-dry duplicate cleanup + * deletes rows; otherwise it remains an optional final drift-correction step. */ export const recomputeAllPurchaseStats = migrations.define({ table: "projects", @@ -185,6 +202,42 @@ export const recomputeAllPurchaseStats = migrations.define({ }, }); +/** + * Migration: Populate the Horizon and Amazon purchase-stat buckets. + * + * This intentionally has a new migration identity. Deployments that already + * completed `recomputeAllPurchaseStats` will not rerun that migration after + * its implementation changes, and their legacy `purchaseStats` rows predate + * the store-specific counters. + * + * Each mutation handles one purchase row. `storeStatsCounted` is written in + * the same transaction as the Horizon/Amazon delta, so an interrupted or reset + * run cannot double count a repaired row. New purchases are born marked after + * updating the widened stats row and are therefore skipped safely while this + * migration is in flight. + */ +export const backfillPurchaseStatsStoreBuckets = migrations.define({ + table: "purchases", + batchSize: 1, + migrateOne: async (ctx, purchase) => { + if (purchase.storeStatsCounted === true) return; + + await applyPurchaseStatsDelta( + ctx, + purchase.projectId, + deltaForMissingPurchaseStats( + purchase.store, + purchase.isValid ?? false, + false, + true, + false, + ), + ); + + return { storeStatsCounted: true }; + }, +}); + /** * Migration: Backfill the `productId` column on existing purchases. * diff --git a/packages/kit/convex/projects/mutation.ts b/packages/kit/convex/projects/mutation.ts index 8e56da154..eb7eb0bad 100644 --- a/packages/kit/convex/projects/mutation.ts +++ b/packages/kit/convex/projects/mutation.ts @@ -276,6 +276,9 @@ export const createProject = mutation({ apiKey, // Keep for backward compatibility, will be deprecated legacyApiKeyFallbackDisabledAt: now, reportingCurrency: DEFAULT_REPORTING_CURRENCY, + // Cloud Sandbox accepts any non-empty secret, so new projects must + // opt in deliberately before App Tester receipts can be verified. + amazonSandboxEnabled: false, createdAt: now, updatedAt: now, ...(args.platform ? { platform: args.platform } : {}), @@ -328,6 +331,7 @@ export const updateProject = mutation({ horizonAppId: v.optional(v.string()), horizonAppSecret: v.optional(v.string()), amazonSharedSecret: v.optional(v.union(v.string(), v.null())), + amazonSandboxEnabled: v.optional(v.boolean()), reportingCurrency: v.optional(v.string()), }, handler: async (ctx, args) => { @@ -425,6 +429,9 @@ export const updateProject = mutation({ ? null : normalizeAmazonSharedSecret(args.amazonSharedSecret); } + if (args.amazonSandboxEnabled !== undefined) { + updates.amazonSandboxEnabled = args.amazonSandboxEnabled; + } // Invariant: enabling Horizon without both credentials leaves the // project in a state where verify calls would throw diff --git a/packages/kit/convex/projects/project-child-pending-deletion.test.ts b/packages/kit/convex/projects/project-child-pending-deletion.test.ts index e208325be..dd0c300ac 100644 --- a/packages/kit/convex/projects/project-child-pending-deletion.test.ts +++ b/packages/kit/convex/projects/project-child-pending-deletion.test.ts @@ -11,7 +11,9 @@ vi.mock("../purchases/stats", () => ({ wasFirstValidTransition: false, }), deletePurchaseStatsForProject: vi.fn().mockResolvedValue(undefined), + deltaForMissingPurchaseStats: vi.fn().mockReturnValue({}), deltaForUpdate: vi.fn().mockReturnValue({}), + mergePurchaseStatsDeltas: vi.fn().mockReturnValue({}), })); import { diff --git a/packages/kit/convex/projects/setupStatus.test.ts b/packages/kit/convex/projects/setupStatus.test.ts new file mode 100644 index 000000000..d5aeb55fe --- /dev/null +++ b/packages/kit/convex/projects/setupStatus.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const projectMocks = vi.hoisted(() => ({ + byApiKey: vi.fn(), + byProjectId: vi.fn(), +})); + +vi.mock("./helpers", () => ({ + resolveProjectByApiKeyFromDb: projectMocks.byApiKey, + resolveProjectByIdForCurrentUserFromDb: projectMocks.byProjectId, +})); + +import { getSetupStatus as registeredGetSetupStatus } from "./setupStatus"; +import { testableFunction } from "../test.setup"; + +const getSetupStatus = testableFunction(registeredGetSetupStatus); + +const ctx = { + db: { + query: vi.fn(() => ({ + withIndex: vi.fn(() => ({ + collect: vi.fn().mockResolvedValue([]), + })), + })), + }, +}; + +function project(overrides: Record = {}) { + return { + _id: "projects_test", + organizationId: "organizations_test", + iosBundleId: "com.example.app", + iosAppAppleId: "123456789", + iosAppStoreIssuerId: "issuer_test", + iosAppStoreKeyId: "key_test", + androidPackageName: "com.example.app", + horizonEnabled: true, + horizonAppId: "horizon_app", + horizonAppSecret: "horizon_secret", + ...overrides, + }; +} + +describe("getSetupStatus Amazon readiness", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("treats an explicit sandbox-only project as configured", async () => { + projectMocks.byProjectId.mockResolvedValue({ + project: project({ + amazonSandboxEnabled: true, + amazonSharedSecret: undefined, + }), + }); + + const result = await getSetupStatus._handler(ctx, { + projectId: "projects_test" as never, + }); + + expect(result.amazon).toEqual({ configured: true, missing: [] }); + }); + + it("keeps Amazon unconfigured when neither readiness path is enabled", async () => { + projectMocks.byProjectId.mockResolvedValue({ + project: project({ + amazonSandboxEnabled: undefined, + amazonSharedSecret: undefined, + }), + }); + + const result = await getSetupStatus._handler(ctx, { + projectId: "projects_test" as never, + }); + + expect(result.amazon).toEqual({ + configured: false, + missing: ["amazonSharedSecret"], + }); + }); +}); diff --git a/packages/kit/convex/projects/setupStatus.ts b/packages/kit/convex/projects/setupStatus.ts index bd9bcb95a..aabd3b639 100644 --- a/packages/kit/convex/projects/setupStatus.ts +++ b/packages/kit/convex/projects/setupStatus.ts @@ -80,8 +80,12 @@ export const getSetupStatus = query({ if (!project.horizonAppId) horizonMissing.push("horizonAppId"); if (!project.horizonAppSecret) horizonMissing.push("horizonAppSecret"); + const amazonConfigured = + (typeof project.amazonSharedSecret === "string" && + project.amazonSharedSecret.trim().length > 0) || + project.amazonSandboxEnabled === true; const amazonMissing: string[] = []; - if (!project.amazonSharedSecret) { + if (!amazonConfigured) { amazonMissing.push("amazonSharedSecret"); } @@ -101,7 +105,7 @@ export const getSetupStatus = query({ missing: horizonMissing, }, amazon: { - configured: amazonMissing.length === 0, + configured: amazonConfigured, missing: amazonMissing, }, // The webhook receivers ALSO need the .p8 / service-account JSON diff --git a/packages/kit/convex/purchases/amazon-reconciliation.test.ts b/packages/kit/convex/purchases/amazon-reconciliation.test.ts new file mode 100644 index 000000000..8706ee2c6 --- /dev/null +++ b/packages/kit/convex/purchases/amazon-reconciliation.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, test } from "vitest"; + +import { testableFunction } from "../test.setup"; +import { + applyAmazonReconciliationVerdict, + claimAmazonPurchasesForReconciliation, + rescheduleAmazonPurchaseReconciliation, +} from "./internal"; +import { HarmonizedPurchaseState } from "./purchaseState"; +import { AMAZON_RECONCILE_LEASE_MS, AMAZON_RECONCILE_RETRY_MS } from "./shared"; + +type Row = Record & { _id: string }; + +class IndexBuilder { + readonly predicates: Array<(row: Row) => boolean> = []; + + eq(field: string, value: unknown): IndexBuilder { + this.predicates.push((row) => row[field] === value); + return this; + } + + lte(field: string, value: number): IndexBuilder { + this.predicates.push((row) => { + const candidate = row[field]; + // Convex indexes optional fields before defined values, so legacy rows + // without a schedule are included in a numeric upper-bound scan. + return ( + candidate === undefined || + (typeof candidate === "number" && candidate <= value) + ); + }); + return this; + } +} + +class MemQuery { + constructor(private rows: Row[]) {} + + withIndex( + _name: string, + build: (builder: IndexBuilder) => IndexBuilder, + ): MemQuery { + const builder = build(new IndexBuilder()); + return new MemQuery( + this.rows.filter((row) => + builder.predicates.every((predicate) => predicate(row)), + ), + ); + } + + order(direction: "asc" | "desc"): MemQuery { + return new MemQuery( + [...this.rows].sort((left, right) => { + const leftAt = left.nextAmazonReconcileAt; + const rightAt = right.nextAmazonReconcileAt; + const comparison = + (typeof leftAt === "number" ? leftAt : -Infinity) - + (typeof rightAt === "number" ? rightAt : -Infinity); + return direction === "asc" ? comparison : -comparison; + }), + ); + } + + async take(limit: number): Promise { + return this.rows.slice(0, limit); + } + + async first(): Promise { + return this.rows[0] ?? null; + } +} + +describe("MemQuery", () => { + test("orders optional schedule values in either direction", async () => { + const query = new MemQuery([ + { _id: "missing" }, + { _id: "earlier", nextAmazonReconcileAt: 100 }, + { _id: "later", nextAmazonReconcileAt: 200 }, + ]); + + await expect(query.order("asc").take(3)).resolves.toEqual([ + { _id: "missing" }, + { _id: "earlier", nextAmazonReconcileAt: 100 }, + { _id: "later", nextAmazonReconcileAt: 200 }, + ]); + await expect(query.order("desc").take(3)).resolves.toEqual([ + { _id: "later", nextAmazonReconcileAt: 200 }, + { _id: "earlier", nextAmazonReconcileAt: 100 }, + { _id: "missing" }, + ]); + }); +}); + +class MemDb { + readonly rows = new Map(); + private insertCounter = 0; + + query(table: string): MemQuery { + return new MemQuery( + [...this.rows.values()].filter((row) => row._table === table), + ); + } + + async get(id: string): Promise { + return this.rows.get(id) ?? null; + } + + async patch(id: string, patch: Record): Promise { + const row = this.rows.get(id); + if (!row) throw new Error(`missing row ${id}`); + Object.assign(row, patch); + } + + async insert( + table: string, + fields: Record, + ): Promise { + const id = `${table}_${++this.insertCounter}`; + this.seed(id, table, fields); + return id; + } + + seed(id: string, table: string, fields: Record): void { + this.rows.set(id, { _id: id, _table: table, ...fields }); + } +} + +function amazonRequest(sandbox = false) { + return { + store: "amazon" as const, + userId: "amzn1.account.test", + receiptId: "amzn1.receipt.test", + sandbox, + }; +} + +describe("claimAmazonPurchasesForReconciliation", () => { + test("atomically leases only due active Amazon rows, including legacy rows", async () => { + const db = new MemDb(); + db.seed("projects_1", "projects", { + amazonSandboxEnabled: true, + amazonSharedSecret: "secret", + }); + db.seed("purchases_legacy", "purchases", { + projectId: "projects_1", + store: "amazon", + applicationId: "com.example.amazon", + remoteId: "production:legacy", + requestData: amazonRequest(), + state: HarmonizedPurchaseState.ENTITLED, + isValid: true, + }); + db.seed("purchases_due", "purchases", { + projectId: "projects_1", + store: "amazon", + applicationId: "com.example.amazon", + remoteId: "sandbox:due", + requestData: amazonRequest(true), + state: HarmonizedPurchaseState.READY_TO_CONSUME, + isValid: true, + nextAmazonReconcileAt: 900, + }); + db.seed("purchases_future", "purchases", { + projectId: "projects_1", + store: "amazon", + applicationId: "com.example.amazon", + remoteId: "production:future", + requestData: amazonRequest(), + state: HarmonizedPurchaseState.ENTITLED, + isValid: true, + nextAmazonReconcileAt: 2_000, + }); + db.seed("purchases_invalid", "purchases", { + projectId: "projects_1", + store: "amazon", + applicationId: "com.example.amazon", + remoteId: "production:invalid", + requestData: amazonRequest(), + state: HarmonizedPurchaseState.CANCELED, + isValid: false, + nextAmazonReconcileAt: 800, + }); + db.seed("purchases_google", "purchases", { + projectId: "projects_1", + store: "google", + applicationId: "com.example.google", + remoteId: "google-token", + requestData: { store: "google", purchaseToken: "token" }, + state: HarmonizedPurchaseState.ENTITLED, + isValid: true, + nextAmazonReconcileAt: 700, + }); + + const handler = testableFunction( + claimAmazonPurchasesForReconciliation, + )._handler; + const first = await handler({ db }, { now: 1_000, limit: 20 }); + expect(first.map((row) => row.purchaseId)).toEqual([ + "purchases_legacy", + "purchases_due", + ]); + expect(first[1]).toMatchObject({ + amazonSandboxEnabled: true, + amazonSharedSecret: "secret", + leaseUntil: 1_000 + AMAZON_RECONCILE_LEASE_MS, + }); + expect(db.rows.get("purchases_legacy")?.nextAmazonReconcileAt).toBe( + 1_000 + AMAZON_RECONCILE_LEASE_MS, + ); + + const overlapping = await handler( + { db }, + { + now: 1_000, + limit: 20, + }, + ); + expect(overlapping).toEqual([]); + }); + + test("defers unusable due rows for the retry interval", async () => { + const db = new MemDb(); + db.seed("purchases_missing_project", "purchases", { + projectId: "projects_missing", + store: "amazon", + applicationId: "com.example.amazon", + remoteId: "production:missing-project", + requestData: amazonRequest(), + state: HarmonizedPurchaseState.ENTITLED, + isValid: true, + nextAmazonReconcileAt: 900, + }); + + const handler = testableFunction( + claimAmazonPurchasesForReconciliation, + )._handler; + await expect(handler({ db }, { now: 1_000, limit: 20 })).resolves.toEqual( + [], + ); + expect( + db.rows.get("purchases_missing_project")?.nextAmazonReconcileAt, + ).toBe(1_000 + AMAZON_RECONCILE_RETRY_MS); + }); +}); + +describe("Amazon reconciliation compare-and-set mutations", () => { + test("reschedules only the worker that still owns the lease", async () => { + const db = new MemDb(); + db.seed("purchases_due", "purchases", { + store: "amazon", + isValid: true, + nextAmazonReconcileAt: 10_000, + }); + const handler = testableFunction( + rescheduleAmazonPurchaseReconciliation, + )._handler; + + await expect( + handler( + { db }, + { + purchaseId: "purchases_due" as never, + claimedLeaseUntil: 9_000, + retryAt: 20_000, + }, + ), + ).resolves.toBe(false); + expect(db.rows.get("purchases_due")?.nextAmazonReconcileAt).toBe(10_000); + + await expect( + handler( + { db }, + { + purchaseId: "purchases_due" as never, + claimedLeaseUntil: 10_000, + retryAt: 20_000, + }, + ), + ).resolves.toBe(true); + expect(db.rows.get("purchases_due")?.nextAmazonReconcileAt).toBe(20_000); + }); + + test("does not apply a stale verdict after foreground verify or deletion", async () => { + const db = new MemDb(); + db.seed("purchases_due", "purchases", { + projectId: "projects_1", + store: "amazon", + applicationId: "com.example.amazon", + remoteId: "production:due", + requestData: amazonRequest(), + state: HarmonizedPurchaseState.ENTITLED, + isValid: true, + // Foreground verification has already replaced the worker's 10_000 + // lease with a fresh 48-hour schedule. + nextAmazonReconcileAt: 99_000, + }); + const handler = testableFunction(applyAmazonReconciliationVerdict)._handler; + const args = { + purchaseId: "purchases_due" as never, + claimedLeaseUntil: 10_000, + remoteResponse: JSON.stringify({ + productId: "premium.monthly", + productType: "SUBSCRIPTION", + }), + state: HarmonizedPurchaseState.CANCELED, + }; + + await expect(handler({ db }, args)).resolves.toBe(false); + expect(db.rows.get("purchases_due")?.state).toBe( + HarmonizedPurchaseState.ENTITLED, + ); + + db.rows.delete("purchases_due"); + await expect(handler({ db }, args)).resolves.toBe(false); + expect(db.rows.has("purchases_due")).toBe(false); + }); + + test("applies an owned deterministic verdict and flips validity atomically", async () => { + const db = new MemDb(); + db.seed("organizations_1", "organizations", { + pendingDeletion: false, + }); + db.seed("projects_1", "projects", { + organizationId: "organizations_1", + pendingDeletion: false, + }); + db.seed("purchaseStats_1", "purchaseStats", { + projectId: "projects_1", + organizationId: "organizations_1", + total: 1, + apple: 0, + google: 0, + googleOrders: 0, + valid: 1, + invalid: 0, + updatedAt: 1, + }); + db.seed("purchases_due", "purchases", { + projectId: "projects_1", + store: "amazon", + applicationId: "com.example.amazon", + remoteId: "production:due", + requestData: amazonRequest(), + remoteResponse: JSON.stringify({ + productId: "premium.monthly", + productType: "SUBSCRIPTION", + }), + state: HarmonizedPurchaseState.ENTITLED, + isValid: true, + statsCounted: true, + nextAmazonReconcileAt: 10_000, + }); + const handler = testableFunction(applyAmazonReconciliationVerdict)._handler; + + await expect( + handler( + { + db, + scheduler: { runAfter: async () => undefined }, + }, + { + purchaseId: "purchases_due" as never, + claimedLeaseUntil: 10_000, + remoteResponse: JSON.stringify({ + error: "AMAZON_RECEIPT_INVALID", + details: { status: 410 }, + }), + state: HarmonizedPurchaseState.CANCELED, + }, + ), + ).resolves.toBe(true); + + expect(db.rows.get("purchases_due")).toMatchObject({ + state: HarmonizedPurchaseState.CANCELED, + isValid: false, + environment: "Production", + }); + expect(db.rows.get("purchaseStats_1")).toMatchObject({ + valid: 0, + invalid: 1, + }); + }); +}); diff --git a/packages/kit/convex/purchases/amazon.test.ts b/packages/kit/convex/purchases/amazon.test.ts index b8081ceff..4e6e51599 100644 --- a/packages/kit/convex/purchases/amazon.test.ts +++ b/packages/kit/convex/purchases/amazon.test.ts @@ -1,12 +1,73 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { testableFunction } from "../test.setup"; import { buildAmazonRemoteId, + buildAmazonRvsUrl, mapAmazonReceiptState, parseAmazonReceiptResponse, + reconcileAmazonPurchases, + verifyAmazonReceiptInternalV1, + waitForAmazonRateSlot, + type AmazonReceiptData, } from "./amazon"; -import { AmazonReceiptVerificationError } from "./errors"; +import { + AmazonReceiptVerificationError, + AmazonSandboxNotEnabledError, + AmazonSharedSecretNotConfiguredError, +} from "./errors"; import { HarmonizedPurchaseState } from "./purchaseState"; +import { + AMAZON_RECONCILE_INTERVAL_MS, + AMAZON_RECONCILE_RETRY_MS, +} from "./shared"; + +const USER_ID = "amzn1.account.test-user"; +const RECEIPT_ID = "amzn1.receipt.test-receipt"; +const PRODUCT_ID = "premium.monthly"; + +function validReceipt( + overrides: Record = {}, +): AmazonReceiptData { + return { + productId: PRODUCT_ID, + productType: "SUBSCRIPTION", + receiptId: RECEIPT_ID, + purchaseDate: 1_700_000_000_000, + renewalDate: 1_700_100_000_000, + cancelDate: null, + ...overrides, + }; +} + +function project(overrides: Record = {}) { + return { + _id: "projects_amazon_test", + organizationId: "organizations_amazon_test", + androidPackageName: "com.example.amazon", + amazonSandboxEnabled: false, + amazonSharedSecret: "production-secret", + ...overrides, + }; +} + +function actionContext(projectRow = project()) { + const runQuery = vi.fn().mockResolvedValue(projectRow); + const runMutation = vi.fn().mockResolvedValue("purchases_amazon_test"); + return { + ctx: { runQuery, runMutation } as never, + runMutation, + }; +} + +async function runVerify(ctx: never, overrides: Record = {}) { + return await testableFunction(verifyAmazonReceiptInternalV1)._handler(ctx, { + apiKey: "iapkit_test_key", + userId: USER_ID, + receiptId: RECEIPT_ID, + ...overrides, + }); +} describe("buildAmazonRemoteId", () => { test("separates sandbox and production receipts", () => { @@ -28,60 +89,639 @@ describe("buildAmazonRemoteId", () => { }); }); +describe("buildAmazonRvsUrl", () => { + test("encodes production credentials and adds sandbox only when selected", () => { + expect( + buildAmazonRvsUrl({ + sharedSecret: "secret/with space", + userId: "user/one", + receiptId: "receipt:one", + sandbox: false, + }), + ).toBe( + "https://appstore-sdk.amazon.com/version/1.0/verifyReceiptId/developer/secret%2Fwith%20space/user/user%2Fone/receiptId/receipt%3Aone", + ); + expect( + buildAmazonRvsUrl({ + sharedSecret: "placeholder", + userId: USER_ID, + receiptId: RECEIPT_ID, + sandbox: true, + }), + ).toContain("appstore-sdk.amazon.com/sandbox/version/1.0"); + }); +}); + describe("mapAmazonReceiptState", () => { test("maps canceled receipts before product type handling", () => { expect( mapAmazonReceiptState({ + ...validReceipt({ productType: "CONSUMABLE" }), cancelDate: 1_700_000_000_000, - productType: "CONSUMABLE", }), ).toBe(HarmonizedPurchaseState.CANCELED); }); test("maps Amazon product types to harmonized states", () => { - expect(mapAmazonReceiptState({ productType: "CONSUMABLE" })).toBe( - HarmonizedPurchaseState.READY_TO_CONSUME, - ); - expect(mapAmazonReceiptState({ productType: "ENTITLED" })).toBe( - HarmonizedPurchaseState.ENTITLED, - ); - expect(mapAmazonReceiptState({ productType: "SUBSCRIPTION" })).toBe( + expect( + mapAmazonReceiptState(validReceipt({ productType: "CONSUMABLE" })), + ).toBe(HarmonizedPurchaseState.READY_TO_CONSUME); + expect( + mapAmazonReceiptState(validReceipt({ productType: "ENTITLED" })), + ).toBe(HarmonizedPurchaseState.ENTITLED); + expect(mapAmazonReceiptState(validReceipt())).toBe( HarmonizedPurchaseState.ENTITLED, ); }); test("does not treat Amazon subscription renewalDate as expiry", () => { expect( - mapAmazonReceiptState({ - productType: "SUBSCRIPTION", - renewalDate: 1_000, - }), + mapAmazonReceiptState( + validReceipt({ renewalDate: 1_000, cancelDate: null }), + ), ).toBe(HarmonizedPurchaseState.ENTITLED); }); +}); - test("falls back to unknown for unrecognized product types", () => { - expect(mapAmazonReceiptState({ productType: "FUTURE_KIND" })).toBe( - HarmonizedPurchaseState.UNKNOWN, +describe("parseAmazonReceiptResponse", () => { + test("accepts a typed RVS object while retaining future extra fields", () => { + const raw = validReceipt({ futureField: "retained" }); + expect(parseAmazonReceiptResponse(raw)).toBe(raw); + }); + + test.each([ + [null], + [[]], + ["not-json"], + [{ productType: "SUBSCRIPTION" }], + [{ productId: PRODUCT_ID, productType: "FUTURE_KIND" }], + [ + { + productId: PRODUCT_ID, + productType: "SUBSCRIPTION", + receiptId: RECEIPT_ID, + }, + ], + [ + { + productId: PRODUCT_ID, + productType: "SUBSCRIPTION", + cancelDate: null, + }, + ], + [{ ...validReceipt(), renewalDate: "tomorrow" }], + [{ ...validReceipt(), testTransaction: "yes" }], + ])("rejects malformed or unsupported RVS data %#", (raw) => { + expect(() => parseAmazonReceiptResponse(raw)).toThrow( + AmazonReceiptVerificationError, ); }); }); -describe("parseAmazonReceiptResponse", () => { - test("accepts object responses from RVS", () => { - const raw = { - productId: "dev.hyo.martie.premium", - productType: "SUBSCRIPTION", - }; +describe("verifyAmazonReceiptInternalV1", () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + }); - expect(parseAmazonReceiptResponse(raw)).toBe(raw); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + test("rejects sandbox before fetch or persistence unless the project opted in", async () => { + const { ctx, runMutation } = actionContext( + project({ amazonSandboxEnabled: false, amazonSharedSecret: null }), + ); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(runVerify(ctx, { sandbox: true })).rejects.toBeInstanceOf( + AmazonSandboxNotEnabledError, + ); + expect(fetchMock).not.toHaveBeenCalled(); + expect(runMutation).not.toHaveBeenCalled(); + }); + + test("rejects production before fetch when no shared secret is configured", async () => { + const { ctx, runMutation } = actionContext( + project({ amazonSharedSecret: null }), + ); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(runVerify(ctx)).rejects.toBeInstanceOf( + AmazonSharedSecretNotConfiguredError, + ); + expect(fetchMock).not.toHaveBeenCalled(); + expect(runMutation).not.toHaveBeenCalled(); + }); + + test("uses only the placeholder in opted-in sandbox and returns/persists its environment", async () => { + const { ctx, runMutation } = actionContext( + project({ + amazonSandboxEnabled: true, + amazonSharedSecret: "must-not-reach-sandbox", + }), + ); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify(validReceipt()))); + vi.stubGlobal("fetch", fetchMock); + + await expect( + runVerify(ctx, { + sandbox: true, + expectedProductId: PRODUCT_ID, + }), + ).resolves.toEqual({ + isValid: true, + state: HarmonizedPurchaseState.ENTITLED, + productId: PRODUCT_ID, + environment: "Sandbox", + }); + + const requestedUrl = String(fetchMock.mock.calls[0]?.[0]); + expect(requestedUrl).toContain("/sandbox/"); + expect(requestedUrl).toContain("/developer/iapkit-sandbox/"); + expect(requestedUrl).not.toContain("must-not-reach-sandbox"); + expect(runMutation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + environment: "Sandbox", + state: HarmonizedPurchaseState.ENTITLED, + isValid: true, + requestData: expect.objectContaining({ + sandbox: true, + expectedProductId: PRODUCT_ID, + }), + }), + ); + }); + + test("uses the production secret without a sandbox path", async () => { + const { ctx } = actionContext( + project({ amazonSharedSecret: "production/secret" }), + ); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify(validReceipt()))); + vi.stubGlobal("fetch", fetchMock); + + await expect(runVerify(ctx)).resolves.toMatchObject({ + environment: "Production", + }); + const requestedUrl = String(fetchMock.mock.calls[0]?.[0]); + expect(requestedUrl).toContain("/developer/production%2Fsecret/"); + expect(requestedUrl).not.toContain("/sandbox/"); }); - test("rejects non-object RVS responses", () => { - expect(() => parseAmazonReceiptResponse(null)).toThrow( + test("returns an expectedProductId mismatch without corrupting the stored verdict", async () => { + const { ctx, runMutation } = actionContext(); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify(validReceipt()))), + ); + + await expect( + runVerify(ctx, { expectedProductId: "different.product" }), + ).resolves.toEqual({ + isValid: false, + state: HarmonizedPurchaseState.INAUTHENTIC, + productId: PRODUCT_ID, + environment: "Production", + }); + expect(runMutation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + isValid: true, + state: HarmonizedPurchaseState.ENTITLED, + }), + ); + }); + + test.each([ + [400, HarmonizedPurchaseState.INAUTHENTIC], + [497, HarmonizedPurchaseState.INAUTHENTIC], + [410, HarmonizedPurchaseState.CANCELED], + ])("persists deterministic Amazon status %i", async (status, state) => { + const { ctx, runMutation } = actionContext(); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("rejected", { status })); + vi.stubGlobal("fetch", fetchMock); + + await expect(runVerify(ctx)).resolves.toEqual({ + isValid: false, + state, + environment: "Production", + }); + expect(runMutation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + state, + isValid: false, + environment: "Production", + }), + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test.each([429, 500, 503])( + "retries transient Amazon status %i and persists only the successful verdict", + async (status) => { + const { ctx, runMutation } = actionContext(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("retry", { status })) + .mockResolvedValueOnce( + new Response(JSON.stringify(validReceipt()), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(runVerify(ctx)).resolves.toMatchObject({ + isValid: true, + state: HarmonizedPurchaseState.ENTITLED, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(runMutation).toHaveBeenCalledTimes(1); + expect(runMutation.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ isValid: true }), + ); + }, + ); + + test.each([429, 500])( + "does not persist after transient Amazon status %i exhausts retries", + async (status) => { + const { ctx, runMutation } = actionContext(); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("still unavailable", { status })); + vi.stubGlobal("fetch", fetchMock); + + await expect(runVerify(ctx)).rejects.toBeInstanceOf( + AmazonReceiptVerificationError, + ); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(runMutation).not.toHaveBeenCalled(); + }, + ); + + test("fails fast on Amazon 496 without persisting", async () => { + const { ctx, runMutation } = actionContext(); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("invalid secret", { status: 496 })); + vi.stubGlobal("fetch", fetchMock); + + await expect(runVerify(ctx)).rejects.toBeInstanceOf( AmazonReceiptVerificationError, ); - expect(() => parseAmazonReceiptResponse("not-json")).toThrow( + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(runMutation).not.toHaveBeenCalled(); + }); + + test("does not persist transient or protocol failures", async () => { + for (const failure of [ + () => Promise.reject(new TypeError("network unavailable")), + () => Promise.resolve(new Response("not-json")), + () => + Promise.resolve( + new Response( + JSON.stringify(validReceipt({ receiptId: "different-receipt" })), + ), + ), + ]) { + const { ctx, runMutation } = actionContext(); + vi.stubGlobal("fetch", vi.fn().mockImplementation(failure)); + await expect(runVerify(ctx)).rejects.toBeInstanceOf( + AmazonReceiptVerificationError, + ); + expect(runMutation).not.toHaveBeenCalled(); + } + }); + + test.each([ + [ + "cancelDate", + { + productId: PRODUCT_ID, + productType: "SUBSCRIPTION", + receiptId: RECEIPT_ID, + }, + ], + [ + "receiptId", + { + productId: PRODUCT_ID, + productType: "SUBSCRIPTION", + cancelDate: null, + }, + ], + ])( + "does not persist a 200 response missing required %s", + async (_field, responseBody) => { + const { ctx, runMutation } = actionContext(); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify(responseBody))); + vi.stubGlobal("fetch", fetchMock); + + await expect(runVerify(ctx)).rejects.toBeInstanceOf( + AmazonReceiptVerificationError, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(runMutation).not.toHaveBeenCalled(); + }, + ); + + test("keeps the timeout active while the response body stalls", async () => { + vi.useFakeTimers(); + const { ctx, runMutation } = actionContext(); + const fetchMock = vi + .fn() + .mockImplementation((_url: string, init: { signal: AbortSignal }) => + Promise.resolve({ + ok: true, + status: 200, + text: () => + new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => { + reject(new DOMException("aborted", "AbortError")); + }); + }), + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const verification = runVerify(ctx); + const rejection = expect(verification).rejects.toBeInstanceOf( AmazonReceiptVerificationError, ); + await vi.runAllTimersAsync(); + await rejection; + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(runMutation).not.toHaveBeenCalled(); + }); +}); + +describe("Amazon purchase reconciler", () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + function probe(overrides: Record = {}) { + return { + purchaseId: "purchases_due", + projectId: "projects_amazon_test", + applicationId: "com.example.amazon", + remoteId: `production:${USER_ID}:${RECEIPT_ID}`, + state: HarmonizedPurchaseState.ENTITLED, + requestData: { + store: "amazon", + userId: USER_ID, + receiptId: RECEIPT_ID, + sandbox: false, + expectedProductId: PRODUCT_ID, + }, + leaseUntil: 10_000, + amazonSandboxEnabled: false, + amazonSharedSecret: "production-secret", + ...overrides, + }; + } + + function reconcileContext(probes: unknown[], mutationResult = true) { + const runMutation = vi + .fn() + .mockResolvedValueOnce(probes) + .mockResolvedValue(mutationResult); + return { ctx: { runMutation } as never, runMutation }; + } + + test("refreshes a valid purchase row and keeps Amazon out of subscriptions", async () => { + const { ctx, runMutation } = reconcileContext([probe()]); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify(validReceipt()))), + ); + + await expect( + testableFunction(reconcileAmazonPurchases)._handler(ctx, {}), + ).resolves.toEqual({ claimed: 1, checked: 1, updated: 1, failures: 0 }); + + expect(runMutation).toHaveBeenCalledTimes(2); + expect(runMutation.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + state: HarmonizedPurchaseState.ENTITLED, + purchaseId: "purchases_due", + claimedLeaseUntil: 10_000, + }), + ); + expect(JSON.stringify(runMutation.mock.calls)).not.toContain( + "subscriptions", + ); + }); + + test("authoritatively stops a 410 row but reschedules transient and protocol failures", async () => { + const canceled = reconcileContext([probe()]); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("gone", { status: 410 })), + ); + await expect( + testableFunction(reconcileAmazonPurchases)._handler(canceled.ctx, {}), + ).resolves.toEqual({ claimed: 1, checked: 1, updated: 1, failures: 0 }); + expect(canceled.runMutation.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + state: HarmonizedPurchaseState.CANCELED, + purchaseId: "purchases_due", + claimedLeaseUntil: 10_000, + }), + ); + + vi.useFakeTimers({ now: 20_000 }); + for (const response of [ + () => Promise.reject(new TypeError("network down")), + () => Promise.resolve(new Response("invalid json")), + ]) { + const failed = reconcileContext([probe()]); + vi.stubGlobal("fetch", vi.fn().mockImplementation(response)); + await expect( + testableFunction(reconcileAmazonPurchases)._handler(failed.ctx, {}), + ).resolves.toEqual({ claimed: 1, checked: 1, updated: 0, failures: 1 }); + expect(failed.runMutation.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + purchaseId: "purchases_due", + claimedLeaseUntil: 10_000, + retryAt: 20_000 + AMAZON_RECONCILE_RETRY_MS, + }), + ); + expect(failed.runMutation.mock.calls[1]?.[1]).not.toHaveProperty("state"); + } + }); + + test("defers an invalid configured secret on the normal cadence", async () => { + vi.useFakeTimers({ now: 25_000 }); + const { ctx, runMutation } = reconcileContext([probe()]); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("secret", { status: 496 })), + ); + + await expect( + testableFunction(reconcileAmazonPurchases)._handler(ctx, {}), + ).resolves.toEqual({ claimed: 1, checked: 1, updated: 0, failures: 1 }); + expect(runMutation.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + purchaseId: "purchases_due", + claimedLeaseUntil: 10_000, + retryAt: 25_000 + AMAZON_RECONCILE_INTERVAL_MS, + }), + ); + expect(runMutation.mock.calls[1]?.[1]).not.toHaveProperty("state"); + }); + + test.each([ + [ + "cancelDate", + { + productId: PRODUCT_ID, + productType: "SUBSCRIPTION", + receiptId: RECEIPT_ID, + }, + ], + [ + "receiptId", + { + productId: PRODUCT_ID, + productType: "SUBSCRIPTION", + cancelDate: null, + }, + ], + ])( + "reschedules without changing state when a 200 response omits %s", + async (_field, responseBody) => { + const { ctx, runMutation } = reconcileContext([probe()]); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify(responseBody))); + vi.stubGlobal("fetch", fetchMock); + + await expect( + testableFunction(reconcileAmazonPurchases)._handler(ctx, {}), + ).resolves.toEqual({ + claimed: 1, + checked: 1, + updated: 0, + failures: 1, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(runMutation.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + purchaseId: "purchases_due", + claimedLeaseUntil: 10_000, + retryAt: expect.any(Number), + }), + ); + expect(runMutation.mock.calls[1]?.[1]).not.toHaveProperty("state"); + }, + ); + + test("does not count a verdict whose claim lost a foreground race", async () => { + const { ctx } = reconcileContext([probe()], false); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify(validReceipt()))), + ); + + await expect( + testableFunction(reconcileAmazonPurchases)._handler(ctx, {}), + ).resolves.toEqual({ claimed: 1, checked: 1, updated: 0, failures: 0 }); + }); + + test("reschedules a disabled sandbox row without contacting Amazon", async () => { + vi.useFakeTimers({ now: 30_000 }); + const { ctx, runMutation } = reconcileContext([ + probe({ + requestData: { + store: "amazon", + userId: USER_ID, + receiptId: RECEIPT_ID, + sandbox: true, + }, + amazonSandboxEnabled: false, + amazonSharedSecret: undefined, + }), + ]); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + testableFunction(reconcileAmazonPurchases)._handler(ctx, {}), + ).resolves.toEqual({ claimed: 1, checked: 0, updated: 0, failures: 1 }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(runMutation.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + purchaseId: "purchases_due", + retryAt: 30_000 + AMAZON_RECONCILE_INTERVAL_MS, + }), + ); + }); + + test("paces request starts at no more than 5 TPS", async () => { + vi.useFakeTimers({ now: 1_000 }); + const { ctx } = reconcileContext([ + probe({ purchaseId: "purchases_1" }), + probe({ purchaseId: "purchases_2" }), + probe({ purchaseId: "purchases_3" }), + ]); + const starts: number[] = []; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => { + starts.push(Date.now()); + return Promise.resolve(new Response(JSON.stringify(validReceipt()))); + }), + ); + + const reconciliation = testableFunction(reconcileAmazonPurchases)._handler( + ctx, + {}, + ); + await vi.runAllTimersAsync(); + await expect(reconciliation).resolves.toEqual({ + claimed: 3, + checked: 3, + updated: 3, + failures: 0, + }); + expect(starts).toHaveLength(3); + expect(starts[1] - starts[0]).toBeGreaterThanOrEqual(200); + expect(starts[2] - starts[1]).toBeGreaterThanOrEqual(200); + }); +}); + +describe("waitForAmazonRateSlot", () => { + test("waits only for the remainder of the 200ms slot", async () => { + let now = 1_100; + const sleep = vi.fn(async (ms: number) => { + now += ms; + }); + await expect( + waitForAmazonRateSlot({ + lastStartedAt: 1_000, + now: () => now, + sleep, + }), + ).resolves.toBe(1_200); + expect(sleep).toHaveBeenCalledWith(100); }); }); diff --git a/packages/kit/convex/purchases/amazon.ts b/packages/kit/convex/purchases/amazon.ts index 62ed57e67..0611dfe6c 100644 --- a/packages/kit/convex/purchases/amazon.ts +++ b/packages/kit/convex/purchases/amazon.ts @@ -3,10 +3,12 @@ import { v } from "convex/values"; import { internal } from "../_generated/api"; -import { action } from "../_generated/server"; +import type { Id } from "../_generated/dataModel"; +import { action, internalAction, type ActionCtx } from "../_generated/server"; import { AmazonReceiptInvalidError, AmazonReceiptVerificationError, + AmazonSandboxNotEnabledError, AmazonSharedSecretNotConfiguredError, ReceiptVerificationError, } from "./errors"; @@ -17,6 +19,10 @@ import { retryOnTransient, } from "./retry"; import { + AMAZON_RECONCILE_BATCH_LIMIT, + AMAZON_RECONCILE_INTERVAL_MS, + AMAZON_RECONCILE_RETRY_MS, + applyExpectedProductId, getProjectByApiKey, isValidState, receiptResponseValidator, @@ -26,21 +32,45 @@ const AMAZON_RVS_BASE_URL = "https://appstore-sdk.amazon.com"; const AMAZON_RVS_VERSION = "1.0"; const AMAZON_SANDBOX_SHARED_SECRET = "iapkit-sandbox"; const AMAZON_RVS_FETCH_TIMEOUT_MS = 10_000; +const AMAZON_RECONCILE_MIN_REQUEST_INTERVAL_MS = 200; + +type AmazonEnvironment = "Sandbox" | "Production"; export interface AmazonReceiptData { autoRenewing?: boolean; - cancelDate?: number | null; + cancelDate: number | null; cancelReason?: number | null; gracePeriodEndDate?: number | null; - productId?: string; - productType?: string; + productId: string; + productType: "CONSUMABLE" | "ENTITLED" | "SUBSCRIPTION"; purchaseDate?: number; quantity?: number | null; - receiptId?: string; + receiptId: string; renewalDate?: number | null; term?: string | null; termSku?: string | null; testTransaction?: boolean; + [key: string]: unknown; +} + +interface AmazonRequestData { + store: "amazon"; + userId: string; + receiptId: string; + sandbox?: boolean; + expectedProductId?: string; +} + +interface PersistAmazonVerdictArgs { + projectId: Id<"projects">; + applicationId: string; + remoteId: string; + requestData: AmazonRequestData; + environment: AmazonEnvironment; + remoteResponse: string; + state: HarmonizedPurchaseState; + requestIp?: string; + verificationDurationMs?: number; } function describeError(error: unknown): string { @@ -60,11 +90,20 @@ function isAbortError(error: unknown): boolean { ); } +function isAmazonTransientError(error: unknown): boolean { + return ( + isAbortError(error) || + error instanceof TypeError || + extractHttpStatus(error) === 429 || + isTransientHttpError(error) + ); +} + function encodePathSegment(value: string): string { return encodeURIComponent(value); } -function buildAmazonRvsUrl(args: { +export function buildAmazonRvsUrl(args: { sharedSecret: string; userId: string; receiptId: string; @@ -94,16 +133,18 @@ export function buildAmazonRemoteId(args: { export function mapAmazonReceiptState( receipt: AmazonReceiptData, ): HarmonizedPurchaseState { - if (receipt.cancelDate !== undefined && receipt.cancelDate !== null) { + // Amazon defines cancelDate as the moment access was lost: it is set when + // a purchase is canceled or a subscription expires and stays null while a + // subscription is valid. renewalDate is only the next renewal date, so a + // past renewalDate must never be treated as an expiry signal. + if (receipt.cancelDate !== null) { return HarmonizedPurchaseState.CANCELED; } - const productType = receipt.productType?.toUpperCase(); - switch (productType) { + switch (receipt.productType.toUpperCase()) { case "CONSUMABLE": return HarmonizedPurchaseState.READY_TO_CONSUME; case "ENTITLED": - return HarmonizedPurchaseState.ENTITLED; case "SUBSCRIPTION": return HarmonizedPurchaseState.ENTITLED; default: @@ -111,14 +152,98 @@ export function mapAmazonReceiptState( } } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function assertOptionalBoolean( + value: Record, + field: string, +): void { + if (field in value && typeof value[field] !== "boolean") { + throw new AmazonReceiptVerificationError( + `Amazon RVS field ${field} must be a boolean.`, + ); + } +} + +function assertOptionalNumber( + value: Record, + field: string, + nullable: boolean, +): void { + if (!(field in value)) return; + const fieldValue = value[field]; + if (nullable && fieldValue === null) return; + if (typeof fieldValue !== "number" || !Number.isFinite(fieldValue)) { + throw new AmazonReceiptVerificationError( + `Amazon RVS field ${field} must be a finite number${nullable ? " or null" : ""}.`, + ); + } +} + +function assertOptionalString( + value: Record, + field: string, + nullable: boolean, +): void { + if (!(field in value)) return; + const fieldValue = value[field]; + if (nullable && fieldValue === null) return; + if (typeof fieldValue !== "string") { + throw new AmazonReceiptVerificationError( + `Amazon RVS field ${field} must be a string${nullable ? " or null" : ""}.`, + ); + } +} + export function parseAmazonReceiptResponse(raw: unknown): AmazonReceiptData { - if (!raw || typeof raw !== "object") { + if (!isRecord(raw)) { throw new AmazonReceiptVerificationError( "Amazon RVS returned an unparseable body.", ); } - return raw; + if (typeof raw.productId !== "string" || raw.productId.trim().length === 0) { + throw new AmazonReceiptVerificationError( + "Amazon RVS returned no usable productId.", + ); + } + if ( + raw.productType !== "CONSUMABLE" && + raw.productType !== "ENTITLED" && + raw.productType !== "SUBSCRIPTION" + ) { + throw new AmazonReceiptVerificationError( + "Amazon RVS returned an unsupported productType.", + ); + } + if ( + !("cancelDate" in raw) || + (raw.cancelDate !== null && + (typeof raw.cancelDate !== "number" || !Number.isFinite(raw.cancelDate))) + ) { + throw new AmazonReceiptVerificationError( + "Amazon RVS field cancelDate must be a finite number or null.", + ); + } + if (typeof raw.receiptId !== "string" || raw.receiptId.trim().length === 0) { + throw new AmazonReceiptVerificationError( + "Amazon RVS returned no usable receiptId.", + ); + } + + assertOptionalBoolean(raw, "autoRenewing"); + assertOptionalBoolean(raw, "testTransaction"); + assertOptionalNumber(raw, "cancelReason", true); + assertOptionalNumber(raw, "gracePeriodEndDate", true); + assertOptionalNumber(raw, "purchaseDate", false); + assertOptionalNumber(raw, "quantity", true); + assertOptionalNumber(raw, "renewalDate", true); + assertOptionalString(raw, "term", true); + assertOptionalString(raw, "termSku", true); + + return raw as AmazonReceiptData; } function parseAmazonJsonBody(bodyText: string): unknown { @@ -144,12 +269,193 @@ function parseAmazonJsonBody(bodyText: string): unknown { } } +function resolveAmazonSharedSecret(args: { + sandbox: boolean; + amazonSandboxEnabled: boolean; + amazonSharedSecret?: string | null; +}): string { + if (args.sandbox) { + if (!args.amazonSandboxEnabled) { + throw new AmazonSandboxNotEnabledError(); + } + // Cloud Sandbox ignores the value as long as it is non-empty. Never put a + // configured production credential in the sandbox URL. + return AMAZON_SANDBOX_SHARED_SECRET; + } + + const sharedSecret = args.amazonSharedSecret?.trim(); + if (!sharedSecret) { + throw new AmazonSharedSecretNotConfiguredError(); + } + return sharedSecret; +} + +async function requestAmazonReceipt(args: { + sharedSecret: string; + userId: string; + receiptId: string; + sandbox: boolean; + maxAttempts: number; +}): Promise { + const url = buildAmazonRvsUrl(args); + const parsedBody = await retryOnTransient( + async () => { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + AMAZON_RVS_FETCH_TIMEOUT_MS, + ); + try { + const response = await fetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + // Keep the same timeout through body consumption. Clearing it after + // headers would let a stalled response body pin the action forever. + const bodyText = await response.text(); + + if (response.status === 400 || response.status === 497) { + throw new AmazonReceiptInvalidError( + response.status, + bodyText.slice(0, 512) || + (response.status === 497 ? "invalid user ID" : "invalid receipt"), + ); + } + if (response.status === 410) { + throw new AmazonReceiptInvalidError( + response.status, + bodyText.slice(0, 512) || "receipt is no longer valid", + ); + } + if (response.status === 496) { + throw new AmazonReceiptVerificationError("invalid shared secret", { + status: 496, + }); + } + if (!response.ok) { + const error = new Error( + `Amazon RVS ${response.status}: ${bodyText.slice(0, 512)}`, + ); + (error as { code?: number }).code = response.status; + throw error; + } + + return parseAmazonJsonBody(bodyText); + } finally { + clearTimeout(timeout); + } + }, + { + maxAttempts: args.maxAttempts, + shouldRetry: isAmazonTransientError, + }, + ); + + const receipt = parseAmazonReceiptResponse(parsedBody); + if (receipt.receiptId !== args.receiptId) { + throw new AmazonReceiptVerificationError( + "Amazon RVS returned a receiptId that does not match the request.", + ); + } + return receipt; +} + +function environmentForSandbox(sandbox: boolean): AmazonEnvironment { + return sandbox ? "Sandbox" : "Production"; +} + +function stateForAmazonInvalidError( + error: AmazonReceiptInvalidError, +): HarmonizedPurchaseState { + return error.errorDetails?.status === 410 + ? HarmonizedPurchaseState.CANCELED + : HarmonizedPurchaseState.INAUTHENTIC; +} + +async function persistAmazonVerdict( + ctx: ActionCtx, + args: PersistAmazonVerdictArgs, +): Promise { + await ctx.runMutation(internal.purchases.internal.saveReceiptInternal, { + projectId: args.projectId, + store: "amazon", + applicationId: args.applicationId, + remoteId: args.remoteId, + requestData: args.requestData, + remoteResponse: args.remoteResponse, + state: args.state, + isValid: isValidState(args.state), + environment: args.environment, + requestIp: args.requestIp, + verificationDurationMs: args.verificationDurationMs, + }); +} + +async function rescheduleAmazonProbe( + ctx: ActionCtx, + probe: { purchaseId: Id<"purchases">; leaseUntil: number }, + delayMs = AMAZON_RECONCILE_RETRY_MS, +): Promise { + await ctx.runMutation( + internal.purchases.internal.rescheduleAmazonPurchaseReconciliation, + { + purchaseId: probe.purchaseId, + claimedLeaseUntil: probe.leaseUntil, + retryAt: Date.now() + delayMs, + }, + ); +} + +async function applyAmazonReconciliationVerdict( + ctx: ActionCtx, + probe: { purchaseId: Id<"purchases">; leaseUntil: number }, + args: { + remoteResponse: string; + state: HarmonizedPurchaseState; + verificationDurationMs: number; + }, +): Promise { + return await ctx.runMutation( + internal.purchases.internal.applyAmazonReconciliationVerdict, + { + purchaseId: probe.purchaseId, + claimedLeaseUntil: probe.leaseUntil, + remoteResponse: args.remoteResponse, + state: args.state, + verificationDurationMs: args.verificationDurationMs, + }, + ); +} + +function realSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function waitForAmazonRateSlot(args: { + lastStartedAt?: number; + now?: () => number; + sleep?: (ms: number) => Promise; +}): Promise { + const now = args.now ?? Date.now; + const sleep = args.sleep ?? realSleep; + if (args.lastStartedAt !== undefined) { + const waitMs = Math.max( + 0, + args.lastStartedAt + AMAZON_RECONCILE_MIN_REQUEST_INTERVAL_MS - now(), + ); + if (waitMs > 0) await sleep(waitMs); + } + return now(); +} + export const verifyAmazonReceiptInternalV1 = action({ args: { apiKey: v.string(), userId: v.string(), receiptId: v.string(), sandbox: v.optional(v.boolean()), + expectedProductId: v.optional(v.string()), requestIp: v.optional(v.string()), }, returns: receiptResponseValidator, @@ -157,17 +463,20 @@ export const verifyAmazonReceiptInternalV1 = action({ const verificationStart = Date.now(); const project = await getProjectByApiKey(ctx, args.apiKey); const sandbox = args.sandbox === true; - const sharedSecret = project.amazonSharedSecret?.trim(); - - if (!sandbox && !sharedSecret) { - throw new AmazonSharedSecretNotConfiguredError(); - } - - const requestData = { - store: "amazon" as const, + const environment = environmentForSandbox(sandbox); + const sharedSecret = resolveAmazonSharedSecret({ + sandbox, + amazonSandboxEnabled: project.amazonSandboxEnabled === true, + amazonSharedSecret: project.amazonSharedSecret, + }); + const requestData: AmazonRequestData = { + store: "amazon", userId: args.userId, receiptId: args.receiptId, sandbox, + ...(args.expectedProductId !== undefined + ? { expectedProductId: args.expectedProductId } + : {}), }; const applicationId = project.androidPackageName ?? `amazon:${project._id}`; const remoteId = buildAmazonRemoteId({ @@ -175,154 +484,182 @@ export const verifyAmazonReceiptInternalV1 = action({ receiptId: args.receiptId, sandbox, }); - const url = buildAmazonRvsUrl({ - sharedSecret: sharedSecret || AMAZON_SANDBOX_SHARED_SECRET, - userId: args.userId, - receiptId: args.receiptId, - sandbox, - }); - const saveFailedReceipt = async (failure: { - error: string; - message: string; - details?: unknown; - state?: HarmonizedPurchaseState; - }) => { - await ctx.runMutation(internal.purchases.internal.saveReceiptInternal, { - projectId: project._id, - store: "amazon", - applicationId, - remoteId, - requestData, - remoteResponse: JSON.stringify({ - error: failure.error, - message: failure.message, - details: failure.details ?? null, - }), - state: failure.state ?? HarmonizedPurchaseState.UNKNOWN, - isValid: false, - requestIp: args.requestIp, - verificationDurationMs: Date.now() - verificationStart, - }); - }; - - let parsedBody: unknown; + let receiptData: AmazonReceiptData; try { - parsedBody = await retryOnTransient( - async () => { - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - AMAZON_RVS_FETCH_TIMEOUT_MS, - ); - const res = await fetch(url, { - method: "GET", - headers: { Accept: "application/json" }, - signal: controller.signal, - }).finally(() => clearTimeout(timeout)); - const bodyText = await res.text().catch(() => ""); - - if (res.status === 400 || res.status === 497) { - throw new AmazonReceiptInvalidError( - res.status, - bodyText.slice(0, 512) || - (res.status === 497 ? "invalid user ID" : "invalid receipt"), - ); - } - if (res.status === 410) { - throw new AmazonReceiptInvalidError( - res.status, - bodyText.slice(0, 512) || "receipt is no longer valid", - ); - } - if (res.status === 496) { - throw new AmazonReceiptVerificationError("invalid shared secret"); - } - if (!res.ok) { - const err = new Error( - `Amazon RVS ${res.status}: ${bodyText.slice(0, 512)}`, - ); - (err as { code?: number }).code = res.status; - throw err; - } - - return parseAmazonJsonBody(bodyText); - }, - { - shouldRetry: (error) => - isAbortError(error) || - extractHttpStatus(error) === 429 || - isTransientHttpError(error), - }, - ); + receiptData = await requestAmazonReceipt({ + sharedSecret, + userId: args.userId, + receiptId: args.receiptId, + sandbox, + maxAttempts: 3, + }); } catch (error) { if (error instanceof AmazonReceiptInvalidError) { - const state = - error.errorDetails?.status === 410 - ? HarmonizedPurchaseState.CANCELED - : HarmonizedPurchaseState.INAUTHENTIC; - await saveFailedReceipt({ - error: error.errorCode, - message: error.errorMessage, - details: error.errorDetails ?? null, + const state = stateForAmazonInvalidError(error); + await persistAmazonVerdict(ctx, { + projectId: project._id, + applicationId, + remoteId, + requestData, + environment, + remoteResponse: JSON.stringify({ + error: error.errorCode, + message: error.errorMessage, + details: error.errorDetails ?? null, + }), state, + requestIp: args.requestIp, + verificationDurationMs: Date.now() - verificationStart, }); - return { isValid: false, state }; + return { isValid: false, state, environment }; } - const message = describeError(error); - await saveFailedReceipt({ - error: - error instanceof ReceiptVerificationError - ? error.errorCode - : "AMAZON_RECEIPT_VERIFICATION_ERROR", - message, - details: - error instanceof ReceiptVerificationError - ? error.errorDetails - : undefined, - }); - throw new AmazonReceiptVerificationError(message); + // Network, timeout, throttling, configuration, and protocol failures are + // not store verdicts. Never replace a previously valid snapshot with an + // UNKNOWN row just because this attempt could not reach/parse RVS. + if (error instanceof ReceiptVerificationError) throw error; + throw new AmazonReceiptVerificationError(describeError(error)); } - let receiptData: AmazonReceiptData; - let state: HarmonizedPurchaseState; - let remoteResponse: string; - try { - receiptData = parseAmazonReceiptResponse(parsedBody); - state = mapAmazonReceiptState(receiptData); - remoteResponse = JSON.stringify(receiptData); - } catch (error) { - const message = describeError(error); - await saveFailedReceipt({ - error: "AMAZON_RECEIPT_PARSE_ERROR", - message, - details: { - rawResponse: parsedBody, - stack: error instanceof Error ? error.stack : undefined, - }, - }); - throw new AmazonReceiptVerificationError(message); - } + const state = mapAmazonReceiptState(receiptData); + const storeReceiptResponse = { + isValid: isValidState(state), + state, + productId: receiptData.productId, + environment, + }; + const receiptResponse = applyExpectedProductId( + storeReceiptResponse, + args.expectedProductId, + ); - await ctx.runMutation(internal.purchases.internal.saveReceiptInternal, { + // Persist Amazon's verdict, not the caller-scoped expectedProductId check. + // This mirrors Apple/Google and keeps a typo from corrupting the row that + // the background reconciler will refresh later. + await persistAmazonVerdict(ctx, { projectId: project._id, - store: "amazon", applicationId, remoteId, requestData, - remoteResponse, + environment, + remoteResponse: JSON.stringify(receiptData), state, - isValid: isValidState(state), requestIp: args.requestIp, verificationDurationMs: Date.now() - verificationStart, }); - return { - isValid: isValidState(state), - state, - ...(receiptData.productId ? { productId: receiptData.productId } : {}), - }; + return receiptResponse; + }, +}); + +/** + * Reconcile active Amazon purchase snapshots without inventing subscription + * semantics. One attempt per claimed row plus the 10-second request timeout + * caps the 20-row worst case near 200 seconds, below the five-minute cron + * interval so independent workers do not overlap their per-worker TPS budget. + * Starts are spaced by 200ms (at most 5 TPS), reserving half of Amazon's + * documented 10 TPS ceiling for foreground verification traffic. + */ +export const reconcileAmazonPurchases = internalAction({ + args: {}, + returns: v.object({ + claimed: v.number(), + checked: v.number(), + updated: v.number(), + failures: v.number(), + }), + handler: async ( + ctx, + ): Promise<{ + claimed: number; + checked: number; + updated: number; + failures: number; + }> => { + const probes = await ctx.runMutation( + internal.purchases.internal.claimAmazonPurchasesForReconciliation, + { limit: AMAZON_RECONCILE_BATCH_LIMIT }, + ); + let checked = 0; + let updated = 0; + let failures = 0; + let lastRequestStartedAt: number | undefined; + + for (const probe of probes) { + const sandbox = probe.requestData.sandbox === true; + let sharedSecret: string; + try { + sharedSecret = resolveAmazonSharedSecret({ + sandbox, + amazonSandboxEnabled: probe.amazonSandboxEnabled, + amazonSharedSecret: probe.amazonSharedSecret, + }); + } catch (error) { + failures += 1; + // Missing credentials or a disabled sandbox cannot recover through a + // rapid retry. Put the row back on the normal cadence so one + // misconfigured project cannot monopolize the global due queue. + await rescheduleAmazonProbe(ctx, probe, AMAZON_RECONCILE_INTERVAL_MS); + console.warn("[amazon-reconciler] configuration unavailable", { + purchaseId: probe.purchaseId, + error: error instanceof Error ? error.name : typeof error, + }); + continue; + } + + lastRequestStartedAt = await waitForAmazonRateSlot({ + lastStartedAt: lastRequestStartedAt, + }); + checked += 1; + const verificationStart = Date.now(); + + try { + const receiptData = await requestAmazonReceipt({ + sharedSecret, + userId: probe.requestData.userId, + receiptId: probe.requestData.receiptId, + sandbox, + maxAttempts: 1, + }); + const state = mapAmazonReceiptState(receiptData); + const applied = await applyAmazonReconciliationVerdict(ctx, probe, { + remoteResponse: JSON.stringify(receiptData), + state, + verificationDurationMs: Date.now() - verificationStart, + }); + if (applied) updated += 1; + } catch (error) { + if (error instanceof AmazonReceiptInvalidError) { + const state = stateForAmazonInvalidError(error); + const applied = await applyAmazonReconciliationVerdict(ctx, probe, { + remoteResponse: JSON.stringify({ + error: error.errorCode, + message: error.errorMessage, + details: error.errorDetails ?? null, + }), + state, + verificationDurationMs: Date.now() - verificationStart, + }); + if (applied) updated += 1; + continue; + } + + failures += 1; + const retryDelayMs = + error instanceof AmazonReceiptVerificationError && + error.errorDetails?.status === 496 + ? AMAZON_RECONCILE_INTERVAL_MS + : AMAZON_RECONCILE_RETRY_MS; + await rescheduleAmazonProbe(ctx, probe, retryDelayMs); + console.warn("[amazon-reconciler] RVS check failed", { + purchaseId: probe.purchaseId, + error: error instanceof Error ? error.name : typeof error, + }); + } + } + + return { claimed: probes.length, checked, updated, failures }; }, }); diff --git a/packages/kit/convex/purchases/cleanup.test.ts b/packages/kit/convex/purchases/cleanup.test.ts index 68dcfea84..153120d9b 100644 --- a/packages/kit/convex/purchases/cleanup.test.ts +++ b/packages/kit/convex/purchases/cleanup.test.ts @@ -124,6 +124,14 @@ class MemDb { } seedProject(id: string, organizationId: string): string { + this.table("organizations").set(organizationId, { + _id: organizationId, + _creationTime: Date.now(), + name: "Test Organization", + slug: "test-organization", + createdAt: Date.now(), + updatedAt: Date.now(), + }); this.table("projects").set(id, { _id: id, _creationTime: Date.now(), @@ -144,6 +152,8 @@ class MemDb { orderId?: string; creationTime: number; isValid?: boolean; + statsCounted?: boolean; + storeStatsCounted?: boolean; }): void { this.table("purchases").set(attrs.id, { _id: attrs.id, @@ -154,6 +164,8 @@ class MemDb { orderId: attrs.orderId, isValid: attrs.isValid ?? true, state: "ENTITLED", + statsCounted: attrs.statsCounted ?? true, + storeStatsCounted: attrs.storeStatsCounted ?? true, }); } @@ -285,6 +297,62 @@ describe("collapseDuplicatePurchasesByOrderId — defensive store filter", () => expect(db.countPurchases()).toBe(2); }); + it("fails before deleting when a sibling has not completed the stats backfill", async () => { + db.seedPurchase({ + id: "p_google_counted_old", + projectId: PROJECT, + store: "google", + applicationId: APP, + orderId: "GPA.order-mixed", + creationTime: 100, + isValid: true, + statsCounted: true, + storeStatsCounted: true, + }); + db.seedPurchase({ + id: "p_google_uncounted_new", + projectId: PROJECT, + store: "google", + applicationId: APP, + orderId: "GPA.order-mixed", + creationTime: 200, + isValid: false, + statsCounted: false, + storeStatsCounted: false, + }); + await db.insert("purchaseStats", { + projectId: PROJECT, + total: 1, + apple: 0, + google: 1, + horizon: 0, + amazon: 0, + googleOrders: 1, + valid: 1, + invalid: 0, + updatedAt: 1, + }); + + await expect(handler(makeCtx(db), {})).rejects.toThrow( + "migrations:backfillPurchaseStatsFromPurchases", + ); + + expect(db.allPurchases()).toHaveLength(2); + expect( + db + .allPurchases() + .map((row) => row._id) + .sort(), + ).toEqual(["p_google_counted_old", "p_google_uncounted_new"]); + await expect(db.query("purchaseStats").first()).resolves.toMatchObject({ + total: 1, + google: 1, + googleOrders: 1, + valid: 1, + invalid: 0, + }); + }); + it("dryRun reports what would be deleted without mutating the table", async () => { db.seedPurchase({ id: "p_google_old", diff --git a/packages/kit/convex/purchases/cleanup.ts b/packages/kit/convex/purchases/cleanup.ts index 1bd186f24..2a2d96047 100644 --- a/packages/kit/convex/purchases/cleanup.ts +++ b/packages/kit/convex/purchases/cleanup.ts @@ -2,7 +2,12 @@ import { v } from "convex/values"; import { internalMutation, MutationCtx } from "../_generated/server"; import { Id } from "../_generated/dataModel"; -import { applyPurchaseStatsDelta, type PurchaseStatsDelta } from "./stats"; +import { + applyPurchaseStatsDelta, + deltaForCountedPurchaseRemoval, + mergePurchaseStatsDeltas, + type PurchaseStatsDelta, +} from "./stats"; export type CollapseDuplicateArgs = { cursor?: string | null; @@ -100,6 +105,12 @@ export async function collapseDuplicatePurchasesByOrderIdHandler( continue; } + if (siblings.some((sibling) => sibling.statsCounted !== true)) { + throw new Error( + "Complete migrations:backfillPurchaseStatsFromPurchases before running purchases/cleanup:collapseDuplicatePurchasesByOrderId.", + ); + } + duplicateGroupsProcessed += 1; const newest = siblings.reduce((acc, candidate) => @@ -120,13 +131,21 @@ export async function collapseDuplicatePurchasesByOrderIdHandler( const isValid = sibling.isValid ?? false; const existing = projectDeltas.get(sibling.projectId) ?? {}; - projectDeltas.set(sibling.projectId, { - total: (existing.total ?? 0) - 1, - google: (existing.google ?? 0) - 1, - // googleOrders intentionally untouched — see header comment. - valid: (existing.valid ?? 0) + (isValid ? -1 : 0), - invalid: (existing.invalid ?? 0) + (isValid ? 0 : -1), - }); + projectDeltas.set( + sibling.projectId, + mergePurchaseStatsDeltas( + existing, + deltaForCountedPurchaseRemoval( + sibling.store, + isValid, + // The surviving sibling still owns this logical order, so deleting + // a duplicate row must not decrement `googleOrders`. + false, + sibling.statsCounted === true, + sibling.storeStatsCounted === true, + ), + ), + ); } } @@ -161,14 +180,23 @@ export async function collapseDuplicatePurchasesByOrderIdHandler( * - keep the row with the greatest `_creationTime` (newest) * - delete the older rows * - accumulate a `purchaseStats` delta per project that decrements - * `total`, the store bucket, and `valid` / `invalid` per deleted - * row. We DO NOT decrement `googleOrders` — that counter + * the sentinel-owned row counters per deleted row. We DO NOT + * decrement `googleOrders` — that counter * represents the count of distinct Google orderIds, which is * unchanged by removing a duplicate (the surviving sibling still - * carries the same orderId). The recommended deploy order - * therefore puts `recomputeAllPurchaseStats` AFTER this mutation - * so any residual drift from the per-row backfill is corrected - * last. + * carries the same orderId). + * - fail before deletion if any sibling has not completed + * `backfillPurchaseStatsFromPurchases`. Convex rolls the mutation + * back if a later group fails the same check. + * + * Required migration order: complete + * `backfillPurchaseStatsFromPurchases`, run this duplicate cleanup, + * then run `recomputeAllPurchaseStats` last whenever non-dry cleanup + * deletes rows. The recompute is optional only when cleanup is not run + * or reports `rowsDeleted: 0`. `backfillPurchaseStatsStoreBuckets` is + * independent of the Google duplicate cleanup and may run before or + * after it, but it must also finish before the recompute when one is + * required. * * Rows with no `orderId` are NEVER touched — they can't be safely * correlated to any logical order and include legitimate diff --git a/packages/kit/convex/purchases/errors.ts b/packages/kit/convex/purchases/errors.ts index 11b657dcb..d52628d66 100644 --- a/packages/kit/convex/purchases/errors.ts +++ b/packages/kit/convex/purchases/errors.ts @@ -75,6 +75,15 @@ export class AmazonSharedSecretNotConfiguredError extends ReceiptVerificationErr } } +export class AmazonSandboxNotEnabledError extends ReceiptVerificationError { + constructor() { + super( + "AMAZON_SANDBOX_NOT_ENABLED", + "Amazon RVS Cloud Sandbox is not enabled for this project. Enable the explicit App Tester sandbox opt-in in project settings before sending sandbox receipts.", + ); + } +} + export class AmazonReceiptInvalidError extends ReceiptVerificationError { constructor(status: number, detail: string) { super( diff --git a/packages/kit/convex/purchases/horizon.test.ts b/packages/kit/convex/purchases/horizon.test.ts index 073869e91..12632392a 100644 --- a/packages/kit/convex/purchases/horizon.test.ts +++ b/packages/kit/convex/purchases/horizon.test.ts @@ -1,6 +1,401 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { buildHorizonRemoteId } from "./horizon"; +import { + buildHorizonRemoteId, + parseHorizonResponse, + ReceiptVerificationError, + verifyMetaHorizonReceiptInternalV1 as registeredVerifyMetaHorizonReceipt, +} from "./horizon"; +import { testableFunction } from "../test.setup"; + +const verifyMetaHorizonReceipt = testableFunction( + registeredVerifyMetaHorizonReceipt, +); + +const PROJECT = { + _id: "project_horizon", + horizonEnabled: true, + horizonAppId: "1234567890123456", + horizonAppSecret: "secret_value", +}; + +const VERIFY_ARGS = { + apiKey: "openiap-kit_pk_test", + userId: "meta-user-1", + sku: "premium_monthly", + requestIp: "203.0.113.1", +}; + +function makeContext(project: typeof PROJECT | null = PROJECT) { + return { + runQuery: vi.fn(async (_function: unknown, _args: unknown) => project), + runMutation: vi.fn( + async (_function: unknown, _args: Record) => null, + ), + }; +} + +async function capture( + promise: Promise, +): Promise<{ value: T; error?: never } | { value?: never; error: unknown }> { + try { + return { value: await promise }; + } catch (error) { + return { error }; + } +} + +function expectHorizonError(error: unknown): ReceiptVerificationError { + expect(error).toBeInstanceOf(ReceiptVerificationError); + const receiptError = error as ReceiptVerificationError; + expect(receiptError.errorCode).toBe("META_HORIZON_VERIFICATION_ERROR"); + return receiptError; +} + +describe("verifyMetaHorizonReceiptInternalV1", () => { + let fetchMock: ReturnType>; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + test("persists and returns a confirmed success=true response", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ success: true, grant_time: 1_744_148_687 }), + { status: 200 }, + ), + ); + const ctx = makeContext(); + + await expect( + verifyMetaHorizonReceipt._handler(ctx, VERIFY_ARGS), + ).resolves.toEqual({ + isValid: true, + state: "ENTITLED", + productId: "premium_monthly", + }); + + expect(ctx.runQuery).toHaveBeenCalledWith(expect.anything(), { + apiKey: VERIFY_ARGS.apiKey, + requiredAccess: "client", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] ?? []; + expect(url).toBe( + "https://graph.oculus.com/1234567890123456/verify_entitlement", + ); + expect(init).toMatchObject({ + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + expect(typeof init?.body).toBe("string"); + const form = new URLSearchParams(init?.body as string); + expect(Object.fromEntries(form.entries())).toEqual({ + access_token: "OC|1234567890123456|secret_value", + user_id: "meta-user-1", + sku: "premium_monthly", + }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + + expect(ctx.runMutation).toHaveBeenCalledTimes(1); + const saved = ctx.runMutation.mock.calls[0]?.[1]; + expect(saved).toMatchObject({ + projectId: "project_horizon", + store: "horizon", + applicationId: "1234567890123456", + remoteId: "meta-user-1:premium_monthly", + requestData: { + store: "horizon", + userId: "meta-user-1", + sku: "premium_monthly", + }, + state: "ENTITLED", + isValid: true, + requestIp: "203.0.113.1", + verificationDurationMs: expect.any(Number), + }); + expect(JSON.parse(saved?.remoteResponse as string)).toEqual({ + success: true, + grantTimeMs: 1_744_148_687_000, + sku: "premium_monthly", + }); + }); + + test("persists a confirmed success=false response as INAUTHENTIC", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ success: false }), { status: 200 }), + ); + const ctx = makeContext(); + + await expect( + verifyMetaHorizonReceipt._handler(ctx, VERIFY_ARGS), + ).resolves.toEqual({ + isValid: false, + state: "INAUTHENTIC", + productId: "premium_monthly", + }); + + expect(ctx.runMutation).toHaveBeenCalledTimes(1); + const saved = ctx.runMutation.mock.calls[0]?.[1]; + expect(saved).toMatchObject({ state: "INAUTHENTIC", isValid: false }); + expect(JSON.parse(saved?.remoteResponse as string)).toEqual({ + success: false, + sku: "premium_monthly", + }); + }); + + test.each([ + [{}, "missing a boolean success field"], + [{ success: "true" }, "missing a boolean success field"], + [null, "unparseable body"], + ])( + "rejects an ambiguous 2xx body without persisting it: %j", + async (body, message) => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify(body), { status: 200 }), + ); + const ctx = makeContext(); + + const result = await capture( + verifyMetaHorizonReceipt._handler(ctx, VERIFY_ARGS), + ); + + const error = expectHorizonError(result.error); + expect(error.errorMessage).toContain(message); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(ctx.runMutation).not.toHaveBeenCalled(); + }, + ); + + test("rejects invalid JSON without persisting it", async () => { + fetchMock.mockResolvedValue(new Response("{", { status: 200 })); + const ctx = makeContext(); + + const result = await capture( + verifyMetaHorizonReceipt._handler(ctx, VERIFY_ARGS), + ); + + const error = expectHorizonError(result.error); + expect(error.errorMessage).toContain("returned invalid JSON"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(ctx.runMutation).not.toHaveBeenCalled(); + }); + + test("does not retry or persist a deterministic HTTP 4xx failure", async () => { + fetchMock.mockResolvedValue(new Response("denied", { status: 400 })); + const ctx = makeContext(); + + const result = await capture( + verifyMetaHorizonReceipt._handler(ctx, VERIFY_ARGS), + ); + + const error = expectHorizonError(result.error); + expect(error.errorMessage).toContain("Error 400"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(ctx.runMutation).not.toHaveBeenCalled(); + }); + + test("retries HTTP 429 and 5xx before persisting a confirmed result", async () => { + vi.useFakeTimers(); + fetchMock + .mockResolvedValueOnce(new Response("limited", { status: 429 })) + .mockResolvedValueOnce(new Response("unavailable", { status: 503 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + const ctx = makeContext(); + + const resultPromise = capture( + verifyMetaHorizonReceipt._handler(ctx, VERIFY_ARGS), + ); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ isValid: true, state: "ENTITLED" }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(ctx.runMutation).toHaveBeenCalledTimes(1); + }); + + test("cancels an HTTP error body without masking its retryable status", async () => { + vi.useFakeTimers(); + const cancel = vi.fn().mockRejectedValue(new Error("already closed")); + fetchMock + .mockResolvedValueOnce({ + ok: false, + status: 503, + body: { cancel }, + } as unknown as Response) + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + const ctx = makeContext(); + + const resultPromise = capture( + verifyMetaHorizonReceipt._handler(ctx, VERIFY_ARGS), + ); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ isValid: true, state: "ENTITLED" }); + expect(cancel).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(ctx.runMutation).toHaveBeenCalledTimes(1); + }); + + test("retries a fetch network failure before persisting a confirmed result", async () => { + vi.useFakeTimers(); + fetchMock + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: false }), { status: 200 }), + ); + const ctx = makeContext(); + + const resultPromise = capture( + verifyMetaHorizonReceipt._handler(ctx, VERIFY_ARGS), + ); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + isValid: false, + state: "INAUTHENTIC", + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(ctx.runMutation).toHaveBeenCalledTimes(1); + }); + + test("retries a response-body network failure before persisting a confirmed result", async () => { + vi.useFakeTimers(); + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: vi.fn().mockRejectedValue(new TypeError("terminated")), + } as unknown as Response) + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + const ctx = makeContext(); + + const resultPromise = capture( + verifyMetaHorizonReceipt._handler(ctx, VERIFY_ARGS), + ); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ isValid: true, state: "ENTITLED" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(ctx.runMutation).toHaveBeenCalledTimes(1); + }); + + test("retries each timed-out request and never persists an inferred verdict", async () => { + vi.useFakeTimers(); + fetchMock.mockImplementation( + async (_input: URL | RequestInfo, init?: RequestInit) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => { + const error = new Error("request timed out"); + error.name = "AbortError"; + reject(error); + }, + { once: true }, + ); + }), + ); + const ctx = makeContext(); + + const resultPromise = capture( + verifyMetaHorizonReceipt._handler(ctx, VERIFY_ARGS), + ); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + const error = expectHorizonError(result.error); + expect(error.errorMessage).toContain("AbortError"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(ctx.runMutation).not.toHaveBeenCalled(); + }); + + test("retries each timed-out response body without persisting a verdict", async () => { + vi.useFakeTimers(); + fetchMock.mockImplementation( + async (_input: URL | RequestInfo, init?: RequestInit) => + ({ + ok: true, + status: 200, + json: async () => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => { + const error = new Error("response body timed out"); + error.name = "AbortError"; + reject(error); + }, + { once: true }, + ); + }), + }) as unknown as Response, + ); + const ctx = makeContext(); + + const resultPromise = capture( + verifyMetaHorizonReceipt._handler(ctx, VERIFY_ARGS), + ); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + const error = expectHorizonError(result.error); + expect(error.errorMessage).toContain("AbortError"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(ctx.runMutation).not.toHaveBeenCalled(); + }); + + test("exhausted 5xx retries do not overwrite the last confirmed receipt", async () => { + vi.useFakeTimers(); + fetchMock.mockResolvedValue(new Response("unavailable", { status: 503 })); + const ctx = makeContext(); + + const resultPromise = capture( + verifyMetaHorizonReceipt._handler(ctx, VERIFY_ARGS), + ); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + const error = expectHorizonError(result.error); + expect(error.errorMessage).toContain("Error 503"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(ctx.runMutation).not.toHaveBeenCalled(); + }); +}); + +describe("parseHorizonResponse", () => { + test("converts a finite grant_time from seconds to milliseconds", () => { + expect( + parseHorizonResponse({ success: true, grant_time: 1_744_148_687 }), + ).toEqual({ success: true, grantTime: 1_744_148_687_000 }); + }); + + test("ignores a non-finite grant_time after accepting the boolean verdict", () => { + expect( + parseHorizonResponse({ success: false, grant_time: Infinity }), + ).toEqual({ success: false, grantTime: undefined }); + }); +}); describe("buildHorizonRemoteId", () => { test("produces a colon-joined pair of URL-encoded parts", () => { diff --git a/packages/kit/convex/purchases/horizon.ts b/packages/kit/convex/purchases/horizon.ts index 5471eec41..e1dcab59f 100644 --- a/packages/kit/convex/purchases/horizon.ts +++ b/packages/kit/convex/purchases/horizon.ts @@ -16,7 +16,11 @@ import { isValidState, receiptResponseValidator, } from "./shared"; -import { retryOnTransient } from "./retry"; +import { + extractHttpStatus, + isTransientHttpError, + retryOnTransient, +} from "./retry"; // Meta's S2S entitlement endpoint. Follows the exact shape the // client SDK uses for its own direct-to-Meta fallback — IAPKit just @@ -30,15 +34,127 @@ import { retryOnTransient } from "./retry"; // sku = add-on SKU configured in Meta Developer Dashboard // Response JSON: { success: boolean, grant_time?: number } // -// Docs: https://developers.meta.com/horizon/documentation/native/ps-iap +// Docs: https://developers.meta.com/horizon/documentation/native/ps-iap-s2s/ const META_GRAPH_BASE = "https://graph.oculus.com"; +const META_REQUEST_TIMEOUT_MS = 10_000; + +class InvalidHorizonResponseError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidHorizonResponseError"; + } +} function describeError(error: unknown): string { + if (error instanceof InvalidHorizonResponseError) { + return error.message; + } const status = (error as { code?: unknown })?.code; const type = error instanceof Error ? error.name : typeof error; return typeof status === "number" ? `${type} ${status}` : type; } +function isAbortError(error: unknown): boolean { + return ( + error !== null && + typeof error === "object" && + (error as { name?: unknown }).name === "AbortError" + ); +} + +function hasTransientCause(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + + const seen = new Set([error]); + let cause = (error as { cause?: unknown }).cause; + while (cause && typeof cause === "object" && !seen.has(cause)) { + if ( + isAbortError(cause) || + isTransientHttpError(cause) || + cause instanceof TypeError + ) { + return true; + } + seen.add(cause); + cause = (cause as { cause?: unknown }).cause; + } + return false; +} + +function shouldRetryHorizonError(error: unknown): boolean { + const status = extractHttpStatus(error); + if (status === 429) return true; + if (isAbortError(error) || isTransientHttpError(error)) return true; + + // Node's fetch reports transport failures as `TypeError: fetch failed`, + // often with the useful network code nested under `cause`. All TypeErrors + // raised in this block originate at the fetch boundary, so they are safe to + // retry; JSON parsing failures are converted to InvalidHorizonResponseError. + return error instanceof TypeError || hasTransientCause(error); +} + +async function requestHorizonVerification( + url: string, + body: string, +): Promise { + return await retryOnTransient( + async () => { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + META_REQUEST_TIMEOUT_MS, + ); + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + signal: controller.signal, + }); + + if (!response.ok) { + // We intentionally do not read error bodies because they can contain + // upstream details or stall indefinitely. Dispose the stream before + // retrying so undici can release the connection; cancellation is + // best-effort and must not replace the authoritative HTTP status. + try { + await response.body?.cancel(); + } catch { + // The status below still determines retryability. + } + // Keep upstream response bodies out of logs and Convex errors. The + // status is enough to classify retryability and diagnose the call. + const error = new Error( + `Meta Graph API returned HTTP ${response.status}`, + ); + (error as { code?: number }).code = response.status; + throw error; + } + + let responseBody: unknown; + try { + responseBody = (await response.json()) as unknown; + } catch (error) { + // `Response.json()` can fail for the same transient reasons as the + // initial fetch (for example, the peer disconnects or the body + // stalls until our AbortController fires). Preserve those errors so + // the shared retry policy can recover; only deterministic JSON + // syntax failures become a protocol error. + if (shouldRetryHorizonError(error)) throw error; + throw new InvalidHorizonResponseError( + "Meta Graph API returned invalid JSON.", + ); + } + return parseHorizonResponse(responseBody); + } finally { + clearTimeout(timeout); + } + }, + { shouldRetry: shouldRetryHorizonError }, + ); +} + export const verifyMetaHorizonReceiptInternalV1 = action({ args: { apiKey: v.string(), @@ -73,58 +189,23 @@ export const verifyMetaHorizonReceiptInternalV1 = action({ const appAccessToken = `OC|${project.horizonAppId}|${project.horizonAppSecret}`; const url = `${META_GRAPH_BASE}/${encodeURIComponent(project.horizonAppId)}/verify_entitlement`; - let parsedBody: unknown; + let verified: HorizonVerifyResult; try { - parsedBody = await retryOnTransient(async () => { - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - access_token: appAccessToken, - user_id: args.userId, - sku: args.sku, - }).toString(), - }); - - if (!res.ok) { - // Attach the status as `code` so retryOnTransient can - // decide whether to retry: 5xx yes, 4xx no. This matches - // the gaxios / googleapis error shape the retry helper - // already understands. - const text = await res.text().catch(() => ""); - const err = new Error( - `Meta Graph API ${res.status}: ${text.slice(0, 512)}`, - ); - (err as { code?: number }).code = res.status; - throw err; - } - - return (await res.json()) as unknown; - }); - } catch (error) { - const message = describeError(error); - // Persist the failure so it shows up in the dashboard, mirroring - // Apple / Google paths. - await ctx.runMutation(internal.purchases.internal.saveReceiptInternal, { - projectId: project._id, - store: "horizon", - applicationId: project.horizonAppId, - remoteId: buildHorizonRemoteId(args.userId, args.sku), - requestData, - remoteResponse: JSON.stringify({ - error: "META_HORIZON_VERIFICATION_ERROR", - message, + verified = await requestHorizonVerification( + url, + new URLSearchParams({ + access_token: appAccessToken, + user_id: args.userId, sku: args.sku, - }), - state: HarmonizedPurchaseState.INAUTHENTIC, - isValid: false, - requestIp: args.requestIp, - verificationDurationMs: Date.now() - verificationStart, - }); - throw new MetaHorizonVerificationError(message); + }).toString(), + ); + } catch (error) { + // An HTTP error, timeout, network failure, or malformed body is not a + // negative entitlement verdict. Preserve the last confirmed result + // instead of replacing it with INAUTHENTIC. + throw new MetaHorizonVerificationError(describeError(error)); } - const verified = parseHorizonResponse(parsedBody); const state = verified.success ? HarmonizedPurchaseState.ENTITLED : HarmonizedPurchaseState.INAUTHENTIC; @@ -179,14 +260,19 @@ export function buildHorizonRemoteId(userId: string, sku: string): string { return `${encodeURIComponent(userId)}:${encodeURIComponent(sku)}`; } -function parseHorizonResponse(raw: unknown): HorizonVerifyResult { +export function parseHorizonResponse(raw: unknown): HorizonVerifyResult { if (!raw || typeof raw !== "object") { - throw new MetaHorizonVerificationError( + throw new InvalidHorizonResponseError( "Meta Graph API returned an unparseable body.", ); } const record = raw as Record; - const success = record.success === true; + if (typeof record.success !== "boolean") { + throw new InvalidHorizonResponseError( + "Meta Graph API response is missing a boolean success field.", + ); + } + const success = record.success; const grantTimeRaw = record.grant_time; // Meta's `grant_time` is a Unix timestamp in **seconds**. The rest // of IAPKit (persisted purchase rows, dashboards, anything that diff --git a/packages/kit/convex/purchases/internal.ts b/packages/kit/convex/purchases/internal.ts index d99057453..71ad4ed8c 100644 --- a/packages/kit/convex/purchases/internal.ts +++ b/packages/kit/convex/purchases/internal.ts @@ -5,6 +5,7 @@ import { internal } from "../_generated/api"; import { purchaseRequestDataValidator, purchaseStoreValidator, + receiptEnvironmentValidator, } from "../schema"; import { harmonizedPurchaseStateValidator, @@ -13,17 +14,26 @@ import { import { recordVerificationUsageForOrganization } from "../organizations/internal"; import { applyPurchaseStatsDelta, + deltaForCountedPurchaseRemoval, deltaForInsert, + deltaForMissingPurchaseStats, deltaForUpdate, + mergePurchaseStatsDeltas, type PurchaseStatsDelta, } from "./stats"; import { + AMAZON_RECONCILE_BATCH_LIMIT, + AMAZON_RECONCILE_INTERVAL_MS, + AMAZON_RECONCILE_LEASE_MS, + AMAZON_RECONCILE_RETRY_MS, extractOrderIdFromRemoteResponse, extractProductIdFromRemoteResponse, + isValidState, } from "./shared"; type PurchaseStore = Infer; type PurchaseRequestData = Infer; +type ReceiptEnvironment = Infer; export type SavePurchaseArgs = { ctx: MutationCtx; @@ -35,6 +45,7 @@ export type SavePurchaseArgs = { remoteResponse?: string; state: HarmonizedPurchaseState; isValid: boolean; + environment?: ReceiptEnvironment; requestIp?: string; verificationDurationMs?: number; }; @@ -49,6 +60,7 @@ export async function savePurchaseInternal({ remoteResponse, state, isValid, + environment, requestIp, verificationDurationMs, }: SavePurchaseArgs) { @@ -66,6 +78,10 @@ export async function savePurchaseInternal({ } const now = Date.now(); + const nextAmazonReconcileAt = + store === "amazon" && isValid + ? now + AMAZON_RECONCILE_INTERVAL_MS + : undefined; const expectedProductId = requestData.store === "google" ? requestData.expectedProductId : undefined; const productId = extractProductIdFromRemoteResponse( @@ -141,6 +157,8 @@ export async function savePurchaseInternal({ state, isValid, updatedAt: now, + environment, + nextAmazonReconcileAt, productId, orderId, verificationDurationMs, @@ -197,6 +215,8 @@ export async function savePurchaseInternal({ state, isValid, updatedAt: now, + environment, + nextAmazonReconcileAt, productId, orderId, verificationDurationMs, @@ -219,9 +239,14 @@ export async function savePurchaseInternal({ remoteResponse, state, isValid, + ...(environment !== undefined ? { environment } : {}), + ...(nextAmazonReconcileAt !== undefined ? { nextAmazonReconcileAt } : {}), // Mark as already counted so the `backfillPurchaseStatsFromPurchases` // migration skips rows inserted after the counter table went live. statsCounted: true, + // The hot path below updates every store bucket, so the later bounded + // Horizon/Amazon backfill must never replay this row. + storeStatsCounted: true, ...(productId !== null ? { productId } : {}), ...(orderId !== null ? { orderId } : {}), ...(verificationDurationMs !== undefined ? { verificationDurationMs } : {}), @@ -253,6 +278,8 @@ type PurchasePatchArgs = { state: HarmonizedPurchaseState; isValid: boolean; updatedAt: number; + environment?: ReceiptEnvironment; + nextAmazonReconcileAt?: number; productId: string | null; orderId: string | null; verificationDurationMs?: number; @@ -277,14 +304,13 @@ function deltaForConflictingRowRemoval( // `markReceiptInvalid`: an empty-string `orderId` never represented // a real Google order and must not count toward `googleOrders`. const hadOrderId = typeof row.orderId === "string" && row.orderId.length > 0; - return { - total: -1, - apple: row.store === "apple" ? -1 : 0, - google: row.store === "google" ? -1 : 0, - googleOrders: row.store === "google" && hadOrderId ? -1 : 0, - valid: isValid ? -1 : 0, - invalid: isValid ? 0 : -1, - }; + return deltaForCountedPurchaseRemoval( + row.store, + isValid, + hadOrderId, + row.statsCounted === true, + row.storeStatsCounted === true, + ); } /** @@ -307,29 +333,6 @@ async function collapseConflictingOrderIdRow( return deltaForConflictingRowRemoval(row); } -function mergeStatsDeltas( - a: PurchaseStatsDelta, - b: PurchaseStatsDelta, -): PurchaseStatsDelta { - const keys: (keyof PurchaseStatsDelta)[] = [ - "total", - "apple", - "google", - "googleOrders", - "valid", - "invalid", - ]; - const out: PurchaseStatsDelta = {}; - for (const k of keys) { - const av = a[k] ?? 0; - const bv = b[k] ?? 0; - if (av + bv !== 0) { - out[k] = av + bv; - } - } - return out; -} - async function patchExistingPurchase( ctx: MutationCtx, projectId: Id<"projects">, @@ -338,6 +341,8 @@ async function patchExistingPurchase( store: PurchaseStore; isValid?: boolean; orderId?: string; + statsCounted?: boolean; + storeStatsCounted?: boolean; }, args: PurchasePatchArgs, extraDelta: PurchaseStatsDelta = {}, @@ -359,6 +364,17 @@ async function patchExistingPurchase( // `deltaForUpdate` doesn't decrement `googleOrders` for a row whose // orderId hasn't actually gone away. const nextHasOrderId = prevHasOrderId || args.orderId !== null; + // A legacy row may be reverified while the store-bucket migration is in + // flight. Claim its original contribution in this same transaction before + // applying a possible store transition. Convex retries either this mutation + // or the migration on conflict, and the sentinel prevents a second claim. + const legacyStatsDelta = deltaForMissingPurchaseStats( + prevStore, + prevIsValid, + prevHasOrderId, + existing.statsCounted === true, + existing.storeStatsCounted === true, + ); await ctx.db.patch(existing._id, { store: args.store, applicationId: args.applicationId, @@ -367,7 +383,15 @@ async function patchExistingPurchase( remoteResponse: args.remoteResponse, state: args.state, isValid: args.isValid, + statsCounted: true, + storeStatsCounted: true, updatedAt: args.updatedAt, + ...(args.environment !== undefined + ? { environment: args.environment } + : {}), + ...(args.nextAmazonReconcileAt !== undefined + ? { nextAmazonReconcileAt: args.nextAmazonReconcileAt } + : {}), ...(args.productId !== null ? { productId: args.productId } : {}), ...(args.orderId !== null ? { orderId: args.orderId } : {}), ...(args.verificationDurationMs !== undefined @@ -390,7 +414,7 @@ async function patchExistingPurchase( return await applyPurchaseStatsDelta( ctx, projectId, - mergeStatsDeltas(patchDelta, extraDelta), + mergePurchaseStatsDeltas(legacyStatsDelta, patchDelta, extraDelta), ); } @@ -445,6 +469,7 @@ export const saveReceiptInternal = internalMutation({ remoteResponse: v.optional(v.string()), state: harmonizedPurchaseStateValidator, isValid: v.boolean(), + environment: v.optional(receiptEnvironmentValidator), requestIp: v.optional(v.string()), verificationDurationMs: v.optional(v.number()), }, @@ -461,8 +486,153 @@ export const saveReceiptInternal = internalMutation({ remoteResponse: args.remoteResponse, state: args.state, isValid: args.isValid, + environment: args.environment, requestIp: args.requestIp, verificationDurationMs: args.verificationDurationMs, }); }, }); + +/** + * Atomically claim a bounded page of due Amazon purchase snapshots. + * + * `nextAmazonReconcileAt` doubles as the lease deadline. Convex mutations are + * serializable, so advancing it before returning prevents overlapping cron + * actions from receiving the same row. If the worker crashes, the row becomes + * due again when the lease expires. + */ +export const claimAmazonPurchasesForReconciliation = internalMutation({ + args: { + limit: v.optional(v.number()), + now: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const now = args.now ?? Date.now(); + const requestedLimit = Math.trunc( + args.limit ?? AMAZON_RECONCILE_BATCH_LIMIT, + ); + const limit = Math.min( + Math.max(requestedLimit, 1), + AMAZON_RECONCILE_BATCH_LIMIT, + ); + const leaseUntil = now + AMAZON_RECONCILE_LEASE_MS; + const due = await ctx.db + .query("purchases") + .withIndex("by_store_isValid_nextAmazonReconcileAt", (q) => + q + .eq("store", "amazon") + .eq("isValid", true) + .lte("nextAmazonReconcileAt", now), + ) + .order("asc") + .take(limit); + + const claimed = []; + for (const purchase of due) { + const requestData = purchase.requestData; + const project = await ctx.db.get(purchase.projectId); + if ( + requestData.store !== "amazon" || + !purchase.remoteId || + !project || + project.pendingDeletion + ) { + // Keep a malformed legacy row from monopolizing the front of the due + // index while project deletion or an operator repair catches up. + await ctx.db.patch(purchase._id, { + nextAmazonReconcileAt: now + AMAZON_RECONCILE_RETRY_MS, + }); + continue; + } + + await ctx.db.patch(purchase._id, { nextAmazonReconcileAt: leaseUntil }); + claimed.push({ + purchaseId: purchase._id, + requestData, + leaseUntil, + amazonSandboxEnabled: project.amazonSandboxEnabled === true, + ...(typeof project.amazonSharedSecret === "string" + ? { amazonSharedSecret: project.amazonSharedSecret } + : {}), + }); + } + + return claimed; + }, +}); + +/** + * Move a failed claim to its retry slot without racing a newer foreground + * verification. A public verify or another authoritative write changes the + * schedule, making this compare-and-set a no-op. + */ +export const rescheduleAmazonPurchaseReconciliation = internalMutation({ + args: { + purchaseId: v.id("purchases"), + claimedLeaseUntil: v.number(), + retryAt: v.number(), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const purchase = await ctx.db.get(args.purchaseId); + if ( + !purchase || + purchase.store !== "amazon" || + purchase.isValid !== true || + purchase.nextAmazonReconcileAt !== args.claimedLeaseUntil + ) { + return false; + } + + await ctx.db.patch(purchase._id, { + nextAmazonReconcileAt: args.retryAt, + }); + return true; + }, +}); + +/** + * Apply an RVS verdict only while this worker still owns the claimed row. + * The lease comparison and purchase upsert run in one serializable mutation, + * so a newer foreground verification wins instead of being overwritten by a + * slower background response. A deleted row also stays deleted. + */ +export const applyAmazonReconciliationVerdict = internalMutation({ + args: { + purchaseId: v.id("purchases"), + claimedLeaseUntil: v.number(), + remoteResponse: v.string(), + state: harmonizedPurchaseStateValidator, + verificationDurationMs: v.optional(v.number()), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const purchase = await ctx.db.get(args.purchaseId); + if ( + !purchase || + purchase.store !== "amazon" || + purchase.isValid !== true || + purchase.nextAmazonReconcileAt !== args.claimedLeaseUntil || + purchase.requestData.store !== "amazon" || + !purchase.remoteId + ) { + return false; + } + + await savePurchaseInternal({ + ctx, + projectId: purchase.projectId, + store: "amazon", + applicationId: purchase.applicationId, + remoteId: purchase.remoteId, + requestData: purchase.requestData, + remoteResponse: args.remoteResponse, + state: args.state, + isValid: isValidState(args.state), + environment: + purchase.requestData.sandbox === true ? "Sandbox" : "Production", + verificationDurationMs: args.verificationDurationMs, + }); + return true; + }, +}); diff --git a/packages/kit/convex/purchases/mutation.ts b/packages/kit/convex/purchases/mutation.ts index 05885c775..47ea59353 100644 --- a/packages/kit/convex/purchases/mutation.ts +++ b/packages/kit/convex/purchases/mutation.ts @@ -2,7 +2,12 @@ import { internalMutation } from "../_generated/server"; import { v } from "convex/values"; import { createError, ErrorCode } from "../utils/errors"; import { HarmonizedPurchaseState } from "./purchaseState"; -import { applyPurchaseStatsDelta, deltaForUpdate } from "./stats"; +import { + applyPurchaseStatsDelta, + deltaForMissingPurchaseStats, + deltaForUpdate, + mergePurchaseStatsDeltas, +} from "./stats"; import { getProjectById } from "../projects/helpers"; // Mark purchase as inauthentic. @@ -42,6 +47,8 @@ export const markReceiptInvalid = internalMutation({ await ctx.db.patch(args.purchaseId, { state: HarmonizedPurchaseState.INAUTHENTIC, isValid: false, + statsCounted: true, + storeStatsCounted: true, updatedAt: Date.now(), }); @@ -51,13 +58,22 @@ export const markReceiptInvalid = internalMutation({ await applyPurchaseStatsDelta( ctx, purchase.projectId, - deltaForUpdate( - purchase.store, - prevIsValid, - purchase.store, - false, - hasOrderId, - hasOrderId, + mergePurchaseStatsDeltas( + deltaForMissingPurchaseStats( + purchase.store, + prevIsValid, + hasOrderId, + purchase.statsCounted === true, + purchase.storeStatsCounted === true, + ), + deltaForUpdate( + purchase.store, + prevIsValid, + purchase.store, + false, + hasOrderId, + hasOrderId, + ), ), ); diff --git a/packages/kit/convex/purchases/query.ts b/packages/kit/convex/purchases/query.ts index 0da96c8cb..4b6bd5629 100644 --- a/packages/kit/convex/purchases/query.ts +++ b/packages/kit/convex/purchases/query.ts @@ -180,7 +180,8 @@ export const getReceiptsByProject = query({ // Read the maintained per-project counters instead of iterating every // receipt. Counters are kept in sync by `savePurchaseInternal`, // `markReceiptInvalid`, and `deleteProjectWithData`; existing rows are - // seeded by the `backfillPurchaseStats` migration. + // seeded by the row-bounded `backfillPurchaseStatsFromPurchases` + // migration. const stats = await readPurchaseStats(ctx, args.projectId); return { diff --git a/packages/kit/convex/purchases/save-purchase-idempotency.test.ts b/packages/kit/convex/purchases/save-purchase-idempotency.test.ts index d9f631a0e..587120dff 100644 --- a/packages/kit/convex/purchases/save-purchase-idempotency.test.ts +++ b/packages/kit/convex/purchases/save-purchase-idempotency.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { savePurchaseInternal } from "./internal"; import { HarmonizedPurchaseState } from "./purchaseState"; import { readPurchaseStats } from "./stats"; +import { AMAZON_RECONCILE_INTERVAL_MS } from "./shared"; /** * Regression guard for the dedup behavior that keeps IAPKit's @@ -205,8 +206,9 @@ function buildArgs(overrides: { state?: HarmonizedPurchaseState; isValid?: boolean; remoteResponse?: string; - store?: "apple" | "google" | "horizon"; + store?: "apple" | "google" | "horizon" | "amazon"; applicationId?: string; + environment?: "Sandbox" | "Production"; requestData?: | { store: "google"; @@ -214,7 +216,14 @@ function buildArgs(overrides: { expectedProductId?: string; } | { store: "apple"; jws: string } - | { store: "horizon"; userId: string; sku: string }; + | { store: "horizon"; userId: string; sku: string } + | { + store: "amazon"; + userId: string; + receiptId: string; + sandbox?: boolean; + expectedProductId?: string; + }; }) { return { projectId: PROJECT_ID as never, @@ -233,6 +242,7 @@ function buildArgs(overrides: { }), state: overrides.state ?? HarmonizedPurchaseState.ENTITLED, isValid: overrides.isValid ?? true, + environment: overrides.environment, }; } @@ -252,6 +262,96 @@ describe("savePurchaseInternal — idempotency regression guard", () => { await savePurchaseInternal({ ctx, ...buildArgs({ remoteId: TOKEN }) }); expect(db.purchaseCount()).toBe(1); + const rows = await db.query("purchases").collect(); + expect(rows[0]).toMatchObject({ + statsCounted: true, + storeStatsCounted: true, + }); + }); + + it("bootstraps both sentinels before transitioning an uncounted legacy row", async () => { + await db.insert("purchases", { + projectId: PROJECT_ID, + store: "amazon", + applicationId: "com.test.app", + remoteId: "legacy-shared-id", + requestData: { + store: "amazon", + userId: "amazon-user", + receiptId: "legacy-shared-id", + }, + state: HarmonizedPurchaseState.ENTITLED, + isValid: true, + }); + + await savePurchaseInternal({ + ctx, + ...buildArgs({ + store: "horizon", + remoteId: "legacy-shared-id", + requestData: { + store: "horizon", + userId: "horizon-user", + sku: "premium_monthly", + }, + remoteResponse: JSON.stringify({ sku: "premium_monthly" }), + state: HarmonizedPurchaseState.INAUTHENTIC, + isValid: false, + }), + }); + + const rows = await db.query("purchases").collect(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + store: "horizon", + isValid: false, + statsCounted: true, + storeStatsCounted: true, + }); + await expect(readPurchaseStats(ctx, PROJECT_ID as never)).resolves.toEqual({ + total: 1, + apple: 0, + google: 0, + horizon: 1, + amazon: 0, + googleOrders: 0, + valid: 0, + invalid: 1, + }); + }); + + it("persists Amazon environment and schedules valid upserts on the 48-hour due cadence", async () => { + const before = Date.now(); + const args = buildArgs({ + store: "amazon", + remoteId: "production:amazon-user:amazon-receipt", + requestData: { + store: "amazon", + userId: "amazon-user", + receiptId: "amazon-receipt", + sandbox: false, + expectedProductId: "premium_monthly", + }, + remoteResponse: JSON.stringify({ + productId: "premium_monthly", + productType: "SUBSCRIPTION", + }), + environment: "Production", + }); + + await savePurchaseInternal({ ctx, ...args }); + await savePurchaseInternal({ ctx, ...args }); + + const rows = await db.query("purchases").collect(); + expect(rows).toHaveLength(1); + expect(rows[0]?.environment).toBe("Production"); + expect(rows[0]?.productId).toBe("premium_monthly"); + expect(rows[0]?.nextAmazonReconcileAt).toBeGreaterThanOrEqual( + before + AMAZON_RECONCILE_INTERVAL_MS, + ); + expect(rows[0]?.nextAmazonReconcileAt).toBeLessThanOrEqual( + Date.now() + AMAZON_RECONCILE_INTERVAL_MS, + ); }); it("persists the verified expected item from a multi-item token", async () => { @@ -594,6 +694,22 @@ describe("savePurchaseInternal — idempotency regression guard", () => { productLineItem: [{ productId: "premium_monthly" }], }); + // Keep an unrelated counted row in the same project. This pins the + // conflict delta to exactly one removed sibling; counter clamping cannot + // hide an accidental double subtraction. + await savePurchaseInternal({ + ctx, + ...buildArgs({ + remoteId: "token_baseline", + remoteResponse: JSON.stringify({ + kind: "androidpublisher#productPurchase", + orderId: "GPA.unrelated-baseline-order", + acknowledgementState: "ACKNOWLEDGMENT_STATE_ACKNOWLEDGED", + productLineItem: [{ productId: "premium_monthly" }], + }), + }), + }); + // Step 1: pre-ack row on token_initial (no orderId) await savePurchaseInternal({ ctx, @@ -607,7 +723,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { ctx, ...buildArgs({ remoteId: "token_reissue", remoteResponse: ackResponse }), }); - expect(db.purchaseCount()).toBe(2); + expect(db.purchaseCount()).toBe(3); // Step 3: delayed replay with token_initial returns orderId=O1 await savePurchaseInternal({ @@ -616,17 +732,19 @@ describe("savePurchaseInternal — idempotency regression guard", () => { }); // The pre-existing ack row under token_reissue was collapsed into - // the primary-dedup survivor (token_initial) — one row, one - // orderId, googleOrders stays at 1 instead of drifting to 2. - expect(db.purchaseCount()).toBe(1); + // the primary-dedup survivor (token_initial). Together with the unrelated + // baseline there are two rows and two orderIds; only one duplicate row's + // contribution may be reversed. + expect(db.purchaseCount()).toBe(2); const rows = await db.query("purchases").collect(); - expect(rows[0]?.remoteId).toBe("token_initial"); - expect(rows[0]?.orderId).toBe("GPA.only-one-logical-order"); + const survivor = rows.find((row) => row.remoteId === "token_initial"); + expect(survivor?.orderId).toBe("GPA.only-one-logical-order"); const stats = await readPurchaseStats(ctx, PROJECT_ID as never); - expect(stats.google).toBe(1); - expect(stats.googleOrders).toBe(1); - expect(stats.total).toBe(1); + expect(stats.google).toBe(2); + expect(stats.googleOrders).toBe(2); + expect(stats.total).toBe(2); + expect(stats.valid).toBe(2); }); it("orderId dedup scopes by applicationId — same orderId under different apps do not collide", async () => { diff --git a/packages/kit/convex/purchases/shared.ts b/packages/kit/convex/purchases/shared.ts index f28aa51c0..1a0a1e52c 100644 --- a/packages/kit/convex/purchases/shared.ts +++ b/packages/kit/convex/purchases/shared.ts @@ -2,6 +2,7 @@ import { v, Infer } from "convex/values"; import { internal } from "../_generated/api"; import { ActionCtx } from "../_generated/server"; import { InvalidApiKeyError } from "./errors"; +import { receiptEnvironmentValidator } from "../schema"; import { harmonizedPurchaseStateValidator, HarmonizedPurchaseState, @@ -100,6 +101,7 @@ export const receiptResponseValidator = v.object({ isValid: v.boolean(), state: harmonizedPurchaseStateValidator, productId: v.optional(v.string()), + environment: v.optional(receiptEnvironmentValidator), // Internal edge hint for ambiguous states. For example, Google maps // an explicit 410 revoked-token verdict to UNKNOWN, but a successfully // fetched future Play state can also map to UNKNOWN and must stay @@ -107,6 +109,14 @@ export const receiptResponseValidator = v.object({ stableRejection: v.optional(v.boolean()), }); +// Amazon asks developers to revisit every active receipt within 72 hours. +// Rows become due at 48 hours; this is a scheduling cadence, not a completion +// guarantee, because the bounded worker's backlog and retries add delay. +export const AMAZON_RECONCILE_INTERVAL_MS = 48 * 60 * 60 * 1_000; +export const AMAZON_RECONCILE_BATCH_LIMIT = 20; +export const AMAZON_RECONCILE_LEASE_MS = 12 * 60 * 1_000; +export const AMAZON_RECONCILE_RETRY_MS = 60 * 60 * 1_000; + export async function getProjectByApiKey( ctx: ActionCtx, apiKey: string, diff --git a/packages/kit/convex/purchases/stats-integration.test.ts b/packages/kit/convex/purchases/stats-integration.test.ts index 73c8a1a29..e97d48d30 100644 --- a/packages/kit/convex/purchases/stats-integration.test.ts +++ b/packages/kit/convex/purchases/stats-integration.test.ts @@ -3,11 +3,25 @@ import { beforeEach, describe, expect, it } from "vitest"; import { applyPurchaseStatsDelta, deletePurchaseStatsForProject, + deltaForMissingPurchaseStats, deltaForInsert, deltaForUpdate, + mergePurchaseStatsDeltas, readPurchaseStats, recomputePurchaseStatsForProject, } from "./stats"; +import { + backfillPurchaseStatsFromPurchases, + backfillPurchaseStatsStoreBuckets, +} from "../migrations"; +import { testableFunction } from "../test.setup"; + +const runStoreBucketBackfill = testableFunction( + backfillPurchaseStatsStoreBuckets, +); +const runBaseStatsBackfill = testableFunction( + backfillPurchaseStatsFromPurchases, +); /** * Minimal in-memory stand-in for the slice of `ctx.db` the stats helpers @@ -47,6 +61,20 @@ class MemQuery { return [...this.rows]; } + async paginate(args: { cursor: string | null; numItems: number }): Promise<{ + continueCursor: string; + isDone: boolean; + page: Row[]; + }> { + const start = args.cursor === null ? 0 : Number(args.cursor); + const end = Math.min(start + args.numItems, this.rows.length); + return { + continueCursor: String(end), + isDone: end >= this.rows.length, + page: this.rows.slice(start, end), + }; + } + async *[Symbol.asyncIterator](): AsyncIterator { for (const row of this.rows) { yield row; @@ -101,7 +129,21 @@ class MemDb { return null; } - async patch(id: string, patch: Record): Promise { + async patch( + tableOrId: string, + idOrPatch: string | Record, + migrationPatch?: Record, + ): Promise { + // Convex supports both db.patch(id, value) and the table-explicit form + // used by @convex-dev/migrations: db.patch(table, id, value). + const id = migrationPatch + ? typeof idOrPatch === "string" + ? idOrPatch + : (() => { + throw new Error("patch: migration id must be a string"); + })() + : tableOrId; + const patch = migrationPatch ?? (idOrPatch as Record); for (const table of this.tables.values()) { const row = table.get(id); if (row) { @@ -145,12 +187,38 @@ describe("stats helpers — round-trip integration", () => { total: 0, apple: 0, google: 0, + horizon: 0, + amazon: 0, googleOrders: 0, valid: 0, invalid: 0, }); }); + it("treats missing widened store counters on a legacy row as zero", async () => { + await db.insert("purchaseStats", { + projectId: PROJECT_ID, + total: 7, + apple: 3, + google: 4, + googleOrders: 2, + valid: 5, + invalid: 2, + updatedAt: 1, + }); + + await expect(readPurchaseStats(ctx, PROJECT_ID as never)).resolves.toEqual({ + total: 7, + apple: 3, + google: 4, + horizon: 0, + amazon: 0, + googleOrders: 2, + valid: 5, + invalid: 2, + }); + }); + it("does not recreate stats after project deletion starts", async () => { await db.patch(PROJECT_ID, { pendingDeletion: true }); @@ -165,6 +233,8 @@ describe("stats helpers — round-trip integration", () => { total: 0, apple: 0, google: 0, + horizon: 0, + amazon: 0, googleOrders: 0, valid: 0, invalid: 0, @@ -183,6 +253,8 @@ describe("stats helpers — round-trip integration", () => { total: 1, apple: 1, google: 0, + horizon: 0, + amazon: 0, googleOrders: 0, valid: 1, invalid: 0, @@ -212,16 +284,28 @@ describe("stats helpers — round-trip integration", () => { PROJECT_ID as never, deltaForInsert("apple", false), ); + await applyPurchaseStatsDelta( + ctx, + PROJECT_ID as never, + deltaForInsert("horizon", true), + ); + await applyPurchaseStatsDelta( + ctx, + PROJECT_ID as never, + deltaForInsert("amazon", false), + ); const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats).toEqual({ - total: 4, + total: 6, apple: 2, google: 2, + horizon: 1, + amazon: 1, // only the second google insert had an orderId googleOrders: 1, - valid: 2, - invalid: 2, + valid: 3, + invalid: 3, }); }); @@ -247,10 +331,40 @@ describe("stats helpers — round-trip integration", () => { total: 1, apple: 1, google: 0, + horizon: 0, + amazon: 0, + googleOrders: 0, + valid: 0, + invalid: 1, + }); + }); + + it("markReceiptInvalid claims an uncounted legacy row before invalidating it", async () => { + await applyPurchaseStatsDelta( + ctx, + PROJECT_ID as never, + mergePurchaseStatsDeltas( + deltaForMissingPurchaseStats("amazon", true, false, false, false), + deltaForUpdate("amazon", true, "amazon", false), + ), + ); + + await expect(readPurchaseStats(ctx, PROJECT_ID as never)).resolves.toEqual({ + total: 1, + apple: 0, + google: 0, + horizon: 0, + amazon: 1, googleOrders: 0, valid: 0, invalid: 1, }); + + // Both sentinels are claimed by markReceiptInvalid, so either later + // migration order contributes nothing for the now-invalid row. + expect( + deltaForMissingPurchaseStats("amazon", false, false, true, true), + ).toEqual({}); }); describe("wasFirstValidTransition", () => { @@ -374,6 +488,8 @@ describe("stats helpers — round-trip integration", () => { total: 0, apple: 0, google: 0, + horizon: 0, + amazon: 0, googleOrders: 0, valid: 0, invalid: 0, @@ -427,6 +543,18 @@ describe("stats helpers — round-trip integration", () => { state: "ENTITLED", orderId: "GPA.order-1", }); + await db.insert("purchases", { + projectId: PROJECT_ID, + store: "horizon", + isValid: true, + state: "ENTITLED", + }); + await db.insert("purchases", { + projectId: PROJECT_ID, + store: "amazon", + isValid: false, + state: "CANCELED", + }); // Different project — must not bleed into this project's stats. await db.insert("purchases", { projectId: "projects_other", @@ -441,14 +569,16 @@ describe("stats helpers — round-trip integration", () => { PROJECT_ID as never, ); expect(totals).toEqual({ - total: 5, + total: 7, apple: 2, google: 3, + horizon: 1, + amazon: 1, // GPA.order-1 counted once despite two rows; pending-ack row // doesn't contribute. googleOrders: 1, - valid: 4, - invalid: 1, + valid: 5, + invalid: 2, }); // Persisted to the stats table so subsequent reads are O(1). @@ -481,4 +611,185 @@ describe("stats helpers — round-trip integration", () => { ); expect(second).toEqual(first); }); + + it("repairs legacy store buckets one row at a time without double counting on resume", async () => { + // This row represents a deployment that completed the original stats + // migration before Horizon/Amazon buckets existed. The purchase sentinels + // are already true, so replaying the old row-by-row migration cannot repair + // it; the new uniquely named store-bucket migration must do so. + await db.insert("purchaseStats", { + projectId: PROJECT_ID, + total: 4, + apple: 1, + google: 1, + googleOrders: 1, + valid: 3, + invalid: 1, + updatedAt: 1, + }); + const legacyPurchaseIds: string[] = []; + for (const [store, isValid] of [ + ["apple", true], + ["google", true], + ["horizon", true], + ["amazon", false], + ] as const) { + legacyPurchaseIds.push( + await db.insert("purchases", { + projectId: PROJECT_ID, + store, + isValid, + state: isValid ? "ENTITLED" : "CANCELED", + statsCounted: true, + ...(store === "google" ? { orderId: "GPA.legacy-order" } : {}), + }), + ); + } + + const runBatch = async (cursor: string | null) => + await runStoreBucketBackfill._handler(ctx, { + cursor, + dryRun: false, + oneBatchOnly: true, + }); + + // Stop after two rows to model an interrupted deployment. Each batch is + // hard-bounded to one purchase and atomically marks the row it handled. + await expect(runBatch(null)).resolves.toEqual({ + continueCursor: "1", + isDone: false, + processed: 1, + }); + await expect(runBatch("1")).resolves.toEqual({ + continueCursor: "2", + isDone: false, + processed: 1, + }); + expect((await db.get(legacyPurchaseIds[0]))?.storeStatsCounted).toBe(true); + expect((await db.get(legacyPurchaseIds[1]))?.storeStatsCounted).toBe(true); + await expect( + readPurchaseStats(ctx, PROJECT_ID as never), + ).resolves.toMatchObject({ horizon: 0, amazon: 0 }); + + // A purchase arriving between migration batches has already updated the + // widened stats row and is born marked. The resumed migration must skip it. + await db.insert("purchases", { + projectId: PROJECT_ID, + store: "amazon", + isValid: true, + state: "ENTITLED", + statsCounted: true, + storeStatsCounted: true, + }); + await applyPurchaseStatsDelta( + ctx, + PROJECT_ID as never, + deltaForInsert("amazon", true), + ); + + // Resume at the saved cursor: legacy Horizon and Amazon each contribute + // once, then the already-counted new Amazon row is skipped. + await expect(runBatch("2")).resolves.toMatchObject({ + continueCursor: "3", + processed: 1, + }); + await expect(runBatch("3")).resolves.toMatchObject({ + continueCursor: "4", + processed: 1, + }); + await expect(runBatch("4")).resolves.toEqual({ + continueCursor: "5", + isDone: true, + processed: 1, + }); + + await expect(readPurchaseStats(ctx, PROJECT_ID as never)).resolves.toEqual({ + total: 5, + apple: 1, + google: 1, + horizon: 1, + amazon: 2, + googleOrders: 1, + valid: 4, + invalid: 1, + }); + + // A reset starts from the first row again. Every sentinel makes it a no-op, + // proving partial retries and deliberate reruns cannot double count. + let resetCursor: string | null = null; + let isDone = false; + while (!isDone) { + const result = await runBatch(resetCursor); + resetCursor = result.continueCursor; + isDone = result.isDone; + } + await expect( + readPurchaseStats(ctx, PROJECT_ID as never), + ).resolves.toMatchObject({ horizon: 1, amazon: 2 }); + }); + + it.each(["base-first", "store-first"] as const)( + "coordinates the base and store migrations in %s order", + async (order) => { + for (const [store, requestData] of [ + ["horizon", { store: "horizon", userId: "user-1", sku: "coins" }], + [ + "amazon", + { store: "amazon", userId: "user-2", receiptId: "receipt-2" }, + ], + ] as const) { + await db.insert("purchases", { + projectId: PROJECT_ID, + store, + applicationId: "dev.hyo.martie", + requestData, + isValid: true, + state: "ENTITLED", + }); + } + + const drain = async (handler: typeof runBaseStatsBackfill) => { + let cursor: string | null = null; + let isDone = false; + while (!isDone) { + const result = await handler._handler(ctx, { + cursor, + dryRun: false, + oneBatchOnly: true, + batchSize: 1, + }); + if (!result) throw new Error("migration batch returned no cursor"); + cursor = result.continueCursor; + isDone = result.isDone; + } + }; + + if (order === "base-first") { + await drain(runBaseStatsBackfill); + await drain(runStoreBucketBackfill); + } else { + await drain(runStoreBucketBackfill); + await drain(runBaseStatsBackfill); + } + + await expect( + readPurchaseStats(ctx, PROJECT_ID as never), + ).resolves.toEqual({ + total: 2, + apple: 0, + google: 0, + horizon: 1, + amazon: 1, + googleOrders: 0, + valid: 2, + invalid: 0, + }); + for (const purchase of await db.query("purchases").collect()) { + expect(purchase).toMatchObject({ + statsCounted: true, + storeStatsCounted: true, + }); + } + }, + ); }); diff --git a/packages/kit/convex/purchases/stats.test.ts b/packages/kit/convex/purchases/stats.test.ts index cb9e797db..9eb19f75f 100644 --- a/packages/kit/convex/purchases/stats.test.ts +++ b/packages/kit/convex/purchases/stats.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { deltaForInsert, deltaForUpdate } from "./stats"; +import { + deltaForCountedPurchaseRemoval, + deltaForInsert, + deltaForMissingPurchaseStats, + deltaForUpdate, + mergePurchaseStatsDeltas, +} from "./stats"; describe("deltaForInsert", () => { it("counts an apple valid insert", () => { @@ -7,6 +13,8 @@ describe("deltaForInsert", () => { total: 1, apple: 1, google: 0, + horizon: 0, + amazon: 0, googleOrders: 0, valid: 1, invalid: 0, @@ -18,6 +26,8 @@ describe("deltaForInsert", () => { total: 1, apple: 1, google: 0, + horizon: 0, + amazon: 0, googleOrders: 0, valid: 0, invalid: 1, @@ -29,6 +39,8 @@ describe("deltaForInsert", () => { total: 1, apple: 0, google: 1, + horizon: 0, + amazon: 0, googleOrders: 0, valid: 1, invalid: 0, @@ -40,6 +52,8 @@ describe("deltaForInsert", () => { total: 1, apple: 0, google: 1, + horizon: 0, + amazon: 0, googleOrders: 1, valid: 1, invalid: 0, @@ -51,6 +65,8 @@ describe("deltaForInsert", () => { total: 1, apple: 0, google: 1, + horizon: 0, + amazon: 0, googleOrders: 1, valid: 0, invalid: 1, @@ -64,11 +80,36 @@ describe("deltaForInsert", () => { total: 1, apple: 1, google: 0, + horizon: 0, + amazon: 0, googleOrders: 0, valid: 1, invalid: 0, }); }); + + it("counts Horizon and Amazon in their own store buckets", () => { + expect(deltaForInsert("horizon", true)).toEqual({ + total: 1, + apple: 0, + google: 0, + horizon: 1, + amazon: 0, + googleOrders: 0, + valid: 1, + invalid: 0, + }); + expect(deltaForInsert("amazon", false)).toEqual({ + total: 1, + apple: 0, + google: 0, + horizon: 0, + amazon: 1, + googleOrders: 0, + valid: 0, + invalid: 1, + }); + }); }); describe("deltaForUpdate", () => { @@ -118,6 +159,13 @@ describe("deltaForUpdate", () => { }); }); + it("moves between Amazon and Horizon store buckets", () => { + expect(deltaForUpdate("amazon", true, "horizon", true)).toEqual({ + horizon: 1, + amazon: -1, + }); + }); + it("never touches the total counter (update preserves count)", () => { const delta = deltaForUpdate("apple", true, "google", false); expect(delta.total).toBeUndefined(); @@ -159,3 +207,75 @@ describe("deltaForUpdate", () => { }); }); }); + +describe("legacy stats sentinels", () => { + it("lets the base and store migrations run in either order", () => { + const baseFirst = deltaForMissingPurchaseStats( + "amazon", + true, + false, + false, + false, + ); + const storeAfterBase = deltaForMissingPurchaseStats( + "amazon", + true, + false, + true, + true, + ); + expect(mergePurchaseStatsDeltas(baseFirst, storeAfterBase)).toEqual({ + total: 1, + amazon: 1, + valid: 1, + }); + + const storeFirst = deltaForMissingPurchaseStats( + "amazon", + true, + false, + true, + false, + ); + const baseAfterStore = deltaForMissingPurchaseStats( + "amazon", + true, + false, + false, + true, + ); + expect(mergePurchaseStatsDeltas(storeFirst, baseAfterStore)).toEqual({ + total: 1, + amazon: 1, + valid: 1, + }); + }); + + it("bootstraps a legacy row before applying its store transition", () => { + expect( + mergePurchaseStatsDeltas( + deltaForMissingPurchaseStats("amazon", true, false, false, false), + deltaForUpdate("amazon", true, "horizon", false), + ), + ).toEqual({ + total: 1, + horizon: 1, + invalid: 1, + }); + }); + + it("removes only contributions whose sentinels were committed", () => { + expect( + deltaForCountedPurchaseRemoval("amazon", true, false, false, false), + ).toEqual({}); + expect( + deltaForCountedPurchaseRemoval("amazon", true, false, false, true), + ).toEqual({ amazon: -1 }); + expect( + deltaForCountedPurchaseRemoval("amazon", true, false, true, false), + ).toEqual({ total: -1, valid: -1 }); + expect( + deltaForCountedPurchaseRemoval("amazon", true, false, true, true), + ).toEqual({ total: -1, amazon: -1, valid: -1 }); + }); +}); diff --git a/packages/kit/convex/purchases/stats.ts b/packages/kit/convex/purchases/stats.ts index f5271f58b..e5d3fa8e2 100644 --- a/packages/kit/convex/purchases/stats.ts +++ b/packages/kit/convex/purchases/stats.ts @@ -10,6 +10,8 @@ export type PurchaseStats = { total: number; apple: number; google: number; + horizon: number; + amazon: number; /** * Count of distinct Google `orderId`s across this project's purchase * rows. On post-fix data this equals the number of `google` rows that @@ -27,6 +29,8 @@ const ZERO_STATS: PurchaseStats = { total: 0, apple: 0, google: 0, + horizon: 0, + amazon: 0, googleOrders: 0, valid: 0, invalid: 0, @@ -49,6 +53,8 @@ export async function readPurchaseStats( total: row.total, apple: row.apple, google: row.google, + horizon: row.horizon ?? 0, + amazon: row.amazon ?? 0, googleOrders: row.googleOrders ?? 0, valid: row.valid, invalid: row.invalid, @@ -57,6 +63,29 @@ export async function readPurchaseStats( export type PurchaseStatsDelta = Partial; +const PURCHASE_STATS_KEYS: (keyof PurchaseStatsDelta)[] = [ + "total", + "apple", + "google", + "horizon", + "amazon", + "googleOrders", + "valid", + "invalid", +]; + +/** Merge several counter transitions while dropping zero-value fields. */ +export function mergePurchaseStatsDeltas( + ...deltas: PurchaseStatsDelta[] +): PurchaseStatsDelta { + const merged: PurchaseStatsDelta = {}; + for (const key of PURCHASE_STATS_KEYS) { + const value = deltas.reduce((sum, delta) => sum + (delta[key] ?? 0), 0); + if (value !== 0) merged[key] = value; + } + return merged; +} + /** * Result of applying a stats delta. `wasFirstValidTransition` lets * callers detect the "project just booked its first valid receipt" @@ -89,6 +118,8 @@ export async function applyPurchaseStatsDelta( !delta.total && !delta.apple && !delta.google && + !delta.horizon && + !delta.amazon && !delta.googleOrders && !delta.valid && !delta.invalid @@ -113,6 +144,8 @@ export async function applyPurchaseStatsDelta( total: Math.max(delta.total ?? 0, 0), apple: Math.max(delta.apple ?? 0, 0), google: Math.max(delta.google ?? 0, 0), + horizon: Math.max(delta.horizon ?? 0, 0), + amazon: Math.max(delta.amazon ?? 0, 0), googleOrders: Math.max(delta.googleOrders ?? 0, 0), valid: nextValid, invalid: Math.max(delta.invalid ?? 0, 0), @@ -134,6 +167,8 @@ export async function applyPurchaseStatsDelta( total: Math.max(row.total + (delta.total ?? 0), 0), apple: Math.max(row.apple + (delta.apple ?? 0), 0), google: Math.max(row.google + (delta.google ?? 0), 0), + horizon: Math.max((row.horizon ?? 0) + (delta.horizon ?? 0), 0), + amazon: Math.max((row.amazon ?? 0) + (delta.amazon ?? 0), 0), googleOrders: Math.max( (row.googleOrders ?? 0) + (delta.googleOrders ?? 0), 0, @@ -154,8 +189,8 @@ export async function applyPurchaseStatsDelta( * error body) still counts toward total / google / valid / invalid — * nothing about the existing call-count semantics changes — but it * doesn't increment `googleOrders`, because it doesn't represent a - * logical Play Console order yet. Apple and Horizon always contribute - * to their respective counters; they don't have an orderId concept. + * logical Play Console order yet. Every store also contributes to its + * own row-count bucket. */ export function deltaForInsert( store: PurchaseStore, @@ -166,12 +201,75 @@ export function deltaForInsert( total: 1, apple: store === "apple" ? 1 : 0, google: store === "google" ? 1 : 0, + horizon: store === "horizon" ? 1 : 0, + amazon: store === "amazon" ? 1 : 0, googleOrders: store === "google" && hasOrderId ? 1 : 0, valid: isValid ? 1 : 0, invalid: isValid ? 0 : 1, }; } +/** + * Contributions still missing for one persisted purchase. + * + * `statsCounted` owns the original total/Apple/Google/order/validity buckets; + * `storeStatsCounted` separately owns the later Horizon/Amazon buckets. Keeping + * the two lanes explicit lets either migration run first without double + * counting and lets a live update claim both atomically before transitioning. + */ +export function deltaForMissingPurchaseStats( + store: PurchaseStore, + isValid: boolean, + hasOrderId: boolean, + statsCounted: boolean, + storeStatsCounted: boolean, +): PurchaseStatsDelta { + const delta: PurchaseStatsDelta = {}; + + if (!statsCounted) { + delta.total = 1; + if (store === "apple") delta.apple = 1; + if (store === "google") delta.google = 1; + if (store === "google" && hasOrderId) delta.googleOrders = 1; + if (isValid) delta.valid = 1; + else delta.invalid = 1; + } + + if (!storeStatsCounted) { + if (store === "horizon") delta.horizon = 1; + if (store === "amazon") delta.amazon = 1; + } + + return delta; +} + +/** Reverse only the contributions whose per-row sentinels were committed. */ +export function deltaForCountedPurchaseRemoval( + store: PurchaseStore, + isValid: boolean, + hasOrderId: boolean, + statsCounted: boolean, + storeStatsCounted: boolean, +): PurchaseStatsDelta { + const delta: PurchaseStatsDelta = {}; + + if (statsCounted) { + delta.total = -1; + if (store === "apple") delta.apple = -1; + if (store === "google") delta.google = -1; + if (store === "google" && hasOrderId) delta.googleOrders = -1; + if (isValid) delta.valid = -1; + else delta.invalid = -1; + } + + if (storeStatsCounted) { + if (store === "horizon") delta.horizon = -1; + if (store === "amazon") delta.amazon = -1; + } + + return delta; +} + /** * Delta for updating an existing purchase row. * @@ -194,8 +292,12 @@ export function deltaForUpdate( if (prevStore !== nextStore) { if (prevStore === "apple") delta.apple = (delta.apple ?? 0) - 1; if (prevStore === "google") delta.google = (delta.google ?? 0) - 1; + if (prevStore === "horizon") delta.horizon = (delta.horizon ?? 0) - 1; + if (prevStore === "amazon") delta.amazon = (delta.amazon ?? 0) - 1; if (nextStore === "apple") delta.apple = (delta.apple ?? 0) + 1; if (nextStore === "google") delta.google = (delta.google ?? 0) + 1; + if (nextStore === "horizon") delta.horizon = (delta.horizon ?? 0) + 1; + if (nextStore === "amazon") delta.amazon = (delta.amazon ?? 0) + 1; } if (prevIsValid !== nextIsValid) { @@ -260,7 +362,8 @@ export async function recomputePurchaseStatsForProject( if (purchase.orderId) { distinctGoogleOrders.add(purchase.orderId); } - } + } else if (purchase.store === "horizon") totals.horizon += 1; + else if (purchase.store === "amazon") totals.amazon += 1; if (purchase.isValid) totals.valid += 1; else totals.invalid += 1; } diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts index 733388c5a..a8f3e7c4f 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -26,6 +26,11 @@ export const purchaseStoreValidator = v.union( v.literal("amazon"), ); +export const receiptEnvironmentValidator = v.union( + v.literal("Sandbox"), + v.literal("Production"), +); + export const purchaseRequestDataValidator = v.union( v.object({ store: v.literal("apple"), @@ -41,7 +46,7 @@ export const purchaseRequestDataValidator = v.union( // or Apple-style opaque receipt; instead Meta's Graph API verifies // an entitlement by (userId, sku) with a server-side App Access // Token the IAPKit server holds. See - // https://developers.meta.com/horizon/documentation/native/ps-iap + // https://developers.meta.com/horizon/documentation/native/ps-iap-s2s/ v.object({ store: v.literal("horizon"), userId: v.string(), @@ -56,6 +61,7 @@ export const purchaseRequestDataValidator = v.union( userId: v.string(), receiptId: v.string(), sandbox: v.optional(v.boolean()), + expectedProductId: v.optional(v.string()), }), ); @@ -242,9 +248,10 @@ const schema = defineSchema({ // Amazon Appstore Receipt Verification Service (RVS). Production // calls require the developer shared secret; Cloud Sandbox accepts - // any non-empty shared secret but we keep one project-level field - // so clients don't ever ship the production secret. + // any non-empty shared secret. Sandbox access is an explicit project + // opt-in because App Tester responses are not production evidence. amazonSharedSecret: v.optional(v.union(v.string(), v.null())), + amazonSandboxEnabled: v.optional(v.boolean()), // Stable presentation currency for dashboard analytics. Raw // purchases/subscriptions keep their original store currency; @@ -278,11 +285,7 @@ const schema = defineSchema({ .index("by_organization", ["organizationId"]) .index("by_api_key", ["apiKey"]) .index("by_org_and_slug", ["organizationId", "slug"]) - .index("by_pending_deletion", ["pendingDeletion"]) - // Horizon polling reconciler iterates only the projects that - // opted into Meta Horizon billing — without this index the cron - // would full-scan every project on each tick. - .index("by_horizon_enabled", ["horizonEnabled"]), + .index("by_pending_deletion", ["pendingDeletion"]), // API Keys table - Multiple API keys per project apiKeys: defineTable({ @@ -424,6 +427,14 @@ const schema = defineSchema({ state: harmonizedPurchaseStateValidator, isValid: v.optional(v.boolean()), // computed from state at time of verification verificationDurationMs: v.optional(v.number()), + // Amazon is the only request-selected verification environment. Keep + // it first-class so operators never have to infer provenance from a + // stored request JSON blob. + environment: v.optional(receiptEnvironmentValidator), + // Active Amazon receipt snapshots are rechecked through RVS. Claiming + // a row temporarily advances this timestamp as a lease, which keeps + // overlapping five-minute cron ticks from probing the same receipt. + nextAmazonReconcileAt: v.optional(v.number()), // Extracted on write so the list query doesn't re-parse // `remoteResponse` for every page item. productId: v.optional(v.string()), @@ -443,6 +454,10 @@ const schema = defineSchema({ // migration flips it true for legacy rows after applying the // delta to `purchaseStats`. statsCounted: v.optional(v.boolean()), + // Sentinel for the later Horizon/Amazon store-bucket backfill. New rows + // already update those buckets and are born marked; the bounded migration + // claims each legacy row atomically with its one-bucket delta. + storeStatsCounted: v.optional(v.boolean()), updatedAt: v.optional(v.number()), }) .index("by_project", ["projectId"]) @@ -457,6 +472,11 @@ const schema = defineSchema({ .index("by_application", ["applicationId"]) .index("by_project_and_remote", ["projectId", "remoteId"]) .index("by_project_app_orderId", ["projectId", "applicationId", "orderId"]) + .index("by_store_isValid_nextAmazonReconcileAt", [ + "store", + "isValid", + "nextAmazonReconcileAt", + ]) .searchIndex("search_request_ip_by_project", { searchField: "requestIp", filterFields: ["projectId"], @@ -474,6 +494,10 @@ const schema = defineSchema({ total: v.number(), apple: v.number(), google: v.number(), + // Widen-safe store buckets. Existing rows predate these counters and + // readers treat an absent value as zero; every new write populates both. + horizon: v.optional(v.number()), + amazon: v.optional(v.number()), // Count of distinct Google `orderId`s present on this project's // `purchases` rows. Diverges from `google` when the table carries // rows without an `orderId` (e.g. pending-acknowledgement responses @@ -562,8 +586,8 @@ const schema = defineSchema({ source: v.union( v.literal("AppleAppStoreServerNotificationsV2"), v.literal("GooglePlayRealTimeDeveloperNotifications"), - // Synthetic source for Meta Horizon Store entitlement - // transitions discovered by the polling reconciler. + // Legacy source retained until pre-removal synthetic rows age out. + // Current ingestion and analytics never create or count these rows. v.literal("MetaHorizonReconciler"), ), platform: v.union(v.literal("IOS"), v.literal("Android")), @@ -746,14 +770,9 @@ const schema = defineSchema({ "state", "productId", ]) - // Composite (projectId, state, updatedAt) for the Horizon - // reconciler's per-state, oldest-first pagination. With this index - // we walk the staleest subs in each mutable state per cron tick; - // after Meta verify_entitlement writes the fresh `updatedAt`, the - // row moves to the back of the queue automatically. That makes - // the reconciler self-paginating across ticks — no separate - // continuation cursor needed (PR #124 - // (https://github.com/hyodotdev/openiap/pull/124) review). + // Composite (projectId, state, updatedAt) lets revenue rollups scan + // each counted subscription state in update order without walking + // unrelated states for the project. .index("by_project_and_state_and_updated", [ "projectId", "state", diff --git a/packages/kit/convex/subscriptions/horizon.ts b/packages/kit/convex/subscriptions/horizon.ts deleted file mode 100644 index 3703d2e96..000000000 --- a/packages/kit/convex/subscriptions/horizon.ts +++ /dev/null @@ -1,308 +0,0 @@ -"use node"; -import { createHash } from "node:crypto"; -import { v } from "convex/values"; - -import { action, internalAction } from "../_generated/server"; -import { internal } from "../_generated/api"; -import type { Id } from "../_generated/dataModel"; -import { mapWithConcurrency } from "../utils/concurrency"; - -// Horizon polling reconciler. -// -// Meta Horizon Store has no webhook / push notification system — -// `developers.meta.com/horizon/documentation/native/ps-iap` only -// exposes the synchronous `verify_entitlement` Graph API. So unlike -// Apple ASN v2 / Google RTDN, kit cannot ingest "subscription -// renewed" or "refunded" events the moment they happen on Meta's -// side; we have to re-check entitlement on a schedule. -// -// This cron action walks every Horizon `subscriptions` row that -// might have changed (state in {Active, InGracePeriod, Paused}), -// hits Meta Graph for each (userId, sku), and feeds the result -// through the same `applySubscriptionEvent` pipeline Apple/Google -// use. Net effect: subscriptions table converges to Meta's -// authoritative answer within one cron tick. -// -// Cadence: 6h (registered in `crons.ts`). Every project's Horizon -// subs run in one tick because the population is small per project. -// If a single project grows past ~1000 active Horizon subs we'll -// want to paginate. - -const META_GRAPH_BASE = "https://graph.oculus.com"; - -function describeErrorForLog(error: unknown): string { - return error instanceof Error ? error.name : typeof error; -} - -type HorizonProbe = { - userId: string; - sku: string; - purchaseToken: string; - state: string; -}; - -export const reconcileHorizonEntitlements = internalAction({ - args: {}, - returns: v.object({ - checked: v.number(), - transitioned: v.number(), - failures: v.number(), - }), - handler: async ( - ctx, - ): Promise<{ - checked: number; - transitioned: number; - failures: number; - }> => { - const projects = await ctx.runQuery( - internal.subscriptions.horizonInternal.listHorizonProjects, - {}, - ); - let checked = 0; - let transitioned = 0; - let failures = 0; - - for (const project of projects) { - if ( - !project.horizonEnabled || - !project.horizonAppId || - !project.horizonAppSecret - ) { - continue; - } - const probes = await ctx.runQuery( - internal.subscriptions.horizonInternal.listHorizonSubscriptions, - { projectId: project._id }, - ); - const appAccessToken = `OC|${project.horizonAppId}|${project.horizonAppSecret}`; - - // Parallelize Meta Graph API checks per project. Meta's - // verify_entitlement endpoint isn't tightly throttled — App - // Access Tokens get the standard Graph rate limit (~200 calls - // per app per hour per user, but our user is the App ID - // itself), so concurrency=8 keeps the cron tick fast for - // projects with many subs without tripping 429s. The runMutation - // calls inside still serialize per probe to keep the - // recordHorizonStatus state-transitions atomic. - const HORIZON_PROBE_CONCURRENCY = 8; - checked += probes.length; - const probeResults = await mapWithConcurrency( - probes, - HORIZON_PROBE_CONCURRENCY, - async (probe) => { - try { - const granted = await checkHorizonEntitlement({ - appId: project.horizonAppId!, - appAccessToken, - userId: probe.userId, - sku: probe.sku, - }); - return { probe, granted, error: null as unknown }; - } catch (error) { - return { probe, granted: null as boolean | null, error }; - } - }, - ); - for (const result of probeResults) { - const { probe, granted, error } = result; - if (error) { - failures += 1; - // Don't log the raw probe.userId / probe.sku — those are - // user-linked identifiers and end up in stdout / log - // aggregators long-term. The purchaseToken hash is enough - // to correlate this entry to the row in `subscriptions` - // when an operator needs to investigate. - console.warn("[horizon-reconciler] check failed", project._id, { - tokenHash: hashForLog(probe.purchaseToken), - error: describeErrorForLog(error), - }); - continue; - } - // Meta's response is binary: `granted: true` means the user - // currently holds the entitlement. Map to the same event - // types Apple/Google emit so the state machine / entitlements - // query don't need a Horizon-specific branch. - // - // Increment `transitioned` only when recordHorizonStatus - // returns a non-null subscription id — it returns null when - // there's no matching subscription row to transition (e.g. - // the kit-side row was never created), in which case we - // didn't actually mutate anything. - if (granted && probe.state !== "Active") { - const updated = await ctx.runMutation( - internal.subscriptions.horizonInternal.recordHorizonStatus, - { - projectId: project._id, - purchaseToken: probe.purchaseToken, - userId: probe.userId, - productId: probe.sku, - eventType: "SubscriptionRenewed", - }, - ); - if (updated) transitioned += 1; - } else if (!granted && probe.state === "Active") { - const updated = await ctx.runMutation( - internal.subscriptions.horizonInternal.recordHorizonStatus, - { - projectId: project._id, - purchaseToken: probe.purchaseToken, - userId: probe.userId, - productId: probe.sku, - eventType: "SubscriptionExpired", - }, - ); - if (updated) transitioned += 1; - } - } - } - - return { checked, transitioned, failures }; - }, -}); - -// Manual one-off run trigger from the dashboard "Reconcile now" button -// or the MCP `openiap_troubleshoot` tool. Same handler as the cron -// path; just exposed under a public action for convenience. -export const reconcileHorizonNow = action({ - args: { apiKey: v.string() }, - returns: v.object({ - checked: v.number(), - transitioned: v.number(), - failures: v.number(), - }), - handler: async ( - ctx, - args, - ): Promise<{ - checked: number; - transitioned: number; - failures: number; - }> => { - const project = await ctx.runQuery( - internal.subscriptions.horizonInternal.getProjectByApiKey, - { apiKey: args.apiKey }, - ); - if (!project) throw new Error("Invalid API key"); - if ( - !project.horizonEnabled || - !project.horizonAppId || - !project.horizonAppSecret - ) { - throw new Error( - "Horizon is not configured for this project (set horizonEnabled + horizonAppId + horizonAppSecret in Settings).", - ); - } - const probes = await ctx.runQuery( - internal.subscriptions.horizonInternal.listHorizonSubscriptions, - { projectId: project._id }, - ); - const appAccessToken = `OC|${project.horizonAppId}|${project.horizonAppSecret}`; - - let checked = 0; - let transitioned = 0; - let failures = 0; - for (const probe of probes) { - checked += 1; - try { - const granted = await checkHorizonEntitlement({ - appId: project.horizonAppId, - appAccessToken, - userId: probe.userId, - sku: probe.sku, - }); - // See the matching note in reconcileHorizonEntitlements: only - // increment when recordHorizonStatus actually returned a - // subscription id (null = no matching row, no transition). - if (granted && probe.state !== "Active") { - const updated = await ctx.runMutation( - internal.subscriptions.horizonInternal.recordHorizonStatus, - { - projectId: project._id, - purchaseToken: probe.purchaseToken, - userId: probe.userId, - productId: probe.sku, - eventType: "SubscriptionRenewed", - }, - ); - if (updated) transitioned += 1; - } else if (!granted && probe.state === "Active") { - const updated = await ctx.runMutation( - internal.subscriptions.horizonInternal.recordHorizonStatus, - { - projectId: project._id, - purchaseToken: probe.purchaseToken, - userId: probe.userId, - productId: probe.sku, - eventType: "SubscriptionExpired", - }, - ); - if (updated) transitioned += 1; - } - } catch (error) { - failures += 1; - console.warn("[horizon-reconciler] check failed", { - tokenHash: hashForLog(probe.purchaseToken), - error: describeErrorForLog(error), - }); - } - } - return { checked, transitioned, failures }; - }, -}); - -// Per-request timeout for the Meta Graph call. Without this, a hung -// upstream stalls the cron action indefinitely; the action's outer -// 10-min ceiling would still fire, but the tick would burn most of -// that budget on a single dead probe instead of moving on. 10s is -// generous for a single Graph endpoint while still letting a stalled -// project's cron tick complete in a reasonable wall time. -const HORIZON_FETCH_TIMEOUT_MS = 10_000; - -async function checkHorizonEntitlement(args: { - appId: string; - appAccessToken: string; - userId: string; - sku: string; -}): Promise { - const url = `${META_GRAPH_BASE}/${encodeURIComponent(args.appId)}/verify_entitlement`; - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - HORIZON_FETCH_TIMEOUT_MS, - ); - let res: Response; - try { - res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - signal: controller.signal, - body: new URLSearchParams({ - access_token: args.appAccessToken, - user_id: args.userId, - sku: args.sku, - }).toString(), - }); - } finally { - clearTimeout(timeout); - } - if (!res.ok) { - throw new Error(`Meta Graph API ${res.status}`); - } - const body = (await res.json()) as { success?: boolean }; - return body.success === true; -} - -// Privacy-safe one-way fingerprint of a purchase token for log lines. -// We only need enough entropy to disambiguate "the same row keeps -// failing" vs "every probe is failing"; truncating SHA-1 to 12 hex -// chars (~48 bits) is collision-resistant enough to identify a row -// without surfacing the original identifier in stdout. -function hashForLog(input: string): string { - return createHash("sha1").update(input).digest("hex").slice(0, 12); -} - -// Re-export with proper Id type usage so consumers in the same module -// graph compile cleanly even though we pass `Id<"projects">` around. -export type HorizonProjectId = Id<"projects">; -export type HorizonProbeRow = HorizonProbe; diff --git a/packages/kit/convex/subscriptions/horizonInternal.test.ts b/packages/kit/convex/subscriptions/horizonInternal.test.ts deleted file mode 100644 index edd03f0c3..000000000 --- a/packages/kit/convex/subscriptions/horizonInternal.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { recordHorizonStatus as registeredRecordHorizonStatus } from "./horizonInternal"; -import { testableFunction } from "../test.setup"; - -const recordHorizonStatus = testableFunction(registeredRecordHorizonStatus); - -interface TestRow { - _id: string; - [field: string]: unknown; -} - -class EqualityBuilder { - readonly filters: Array<[field: string, value: unknown]> = []; - - eq(field: string, value: unknown): this { - this.filters.push([field, value]); - return this; - } -} - -function indexedUniqueQuery(rows: TestRow[]) { - return { - withIndex( - _indexName: string, - configure: (builder: EqualityBuilder) => unknown, - ) { - const builder = new EqualityBuilder(); - configure(builder); - const matches = rows.filter((row) => - builder.filters.every(([field, value]) => row[field] === value), - ); - return { - unique: async (): Promise => { - if (matches.length > 1) { - throw new Error( - `Expected at most one row, received ${matches.length}`, - ); - } - return matches[0] ?? null; - }, - }; - }, - }; -} - -function createHorizonHarness(eventRows: TestRow[], lastEventId: string) { - const organization: TestRow = { _id: "organization_a" }; - const project: TestRow = { - _id: "project_a", - organizationId: organization._id, - }; - const subscription: TestRow = { - _id: "subscription_a", - projectId: project._id, - purchaseToken: "purchase_token", - productId: "premium_monthly", - platform: "Android", - state: "Expired", - lastEventId, - }; - const events = eventRows.map((row) => ({ ...row })); - const insert = vi.fn( - async (table: string, value: Record): Promise => { - if (table !== "webhookEvents") { - throw new Error(`Unexpected insert table: ${table}`); - } - const id = "event_meta_new"; - events.push({ _id: id, ...value }); - return id; - }, - ); - const patch = vi.fn( - async (id: string, value: Record): Promise => { - if (id !== subscription._id) { - throw new Error(`Unexpected patch row: ${id}`); - } - Object.assign(subscription, value); - }, - ); - const get = vi.fn(async (id: string): Promise => { - if (id === organization._id) return organization; - if (id === project._id) return project; - if (id === subscription._id) return subscription; - return events.find((event) => event._id === id) ?? null; - }); - const query = vi.fn((table: string) => { - if (table === "subscriptions") return indexedUniqueQuery([subscription]); - if (table === "webhookEvents") return indexedUniqueQuery(events); - throw new Error(`Unexpected query table: ${table}`); - }); - - return { - db: { get, insert, patch, query }, - events, - insert, - subscription, - }; -} - -const horizonArgs = { - projectId: "project_a" as never, - purchaseToken: "purchase_token", - userId: "user_a", - productId: "premium_monthly", - eventType: "SubscriptionExpired" as const, -}; -const horizonNotificationId = - "meta-horizon-SubscriptionExpired-purchase_token-premium_monthly"; - -describe("recordHorizonStatus source-aware dedup", () => { - it("does not reuse another source's event with the same notification id", async () => { - const harness = createHorizonHarness( - [ - { - _id: "event_apple", - projectId: "project_a", - source: "AppleAppStoreServerNotificationsV2", - sourceNotificationId: horizonNotificationId, - }, - ], - "event_apple", - ); - - await expect( - recordHorizonStatus._handler({ db: harness.db }, horizonArgs), - ).resolves.toBe("subscription_a"); - - expect(harness.insert).toHaveBeenCalledTimes(1); - expect(harness.events).toEqual([ - expect.objectContaining({ _id: "event_apple" }), - expect.objectContaining({ - _id: "event_meta_new", - projectId: "project_a", - source: "MetaHorizonReconciler", - sourceNotificationId: horizonNotificationId, - }), - ]); - expect(harness.subscription.lastEventId).toBe("event_meta_new"); - }); - - it("reuses the Meta event when another source has the same notification id", async () => { - const harness = createHorizonHarness( - [ - { - _id: "event_apple", - projectId: "project_a", - source: "AppleAppStoreServerNotificationsV2", - sourceNotificationId: horizonNotificationId, - }, - { - _id: "event_meta", - projectId: "project_a", - source: "MetaHorizonReconciler", - sourceNotificationId: horizonNotificationId, - }, - ], - "event_meta", - ); - - await expect( - recordHorizonStatus._handler({ db: harness.db }, horizonArgs), - ).resolves.toBe("subscription_a"); - - expect(harness.insert).not.toHaveBeenCalled(); - expect(harness.events).toHaveLength(2); - expect(harness.subscription.lastEventId).toBe("event_meta"); - }); -}); diff --git a/packages/kit/convex/subscriptions/horizonInternal.ts b/packages/kit/convex/subscriptions/horizonInternal.ts deleted file mode 100644 index d72c689ec..000000000 --- a/packages/kit/convex/subscriptions/horizonInternal.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { internalMutation, internalQuery } from "../_generated/server"; -import { v } from "convex/values"; -import type { Doc } from "../_generated/dataModel"; - -import { resolveProjectByApiKeyFromDb } from "../projects/helpers"; -import { - applySubscriptionTransition, - type CurrentSubscription, -} from "./stateMachine"; -import { applyStatsTransition, statsContributionFor } from "./stats"; -import { - assertProjectWritable, - getWritableProject, -} from "../projects/writable"; - -// Convex-runtime helpers used by the Horizon polling reconciler in -// `horizon.ts`. Kept separate so the action's "use node" boundary -// doesn't drag node-only imports into the regular Convex bundle. - -export const listHorizonProjects = internalQuery({ - args: {}, - returns: v.array( - v.object({ - _id: v.id("projects"), - horizonEnabled: v.optional(v.boolean()), - horizonAppId: v.optional(v.union(v.string(), v.null())), - horizonAppSecret: v.optional(v.union(v.string(), v.null())), - }), - ), - handler: async (ctx) => { - // Use the by_horizon_enabled index instead of a full-table scan. - // Most projects don't opt into Meta Horizon, so this skips the - // bulk of the table on every cron tick. - const enabled = await ctx.db - .query("projects") - .withIndex("by_horizon_enabled", (q) => q.eq("horizonEnabled", true)) - .collect(); - const writable = await Promise.all( - enabled.map((project) => getWritableProject(ctx, project._id)), - ); - return writable - .filter((project): project is NonNullable => !!project) - .map((project) => ({ - _id: project._id, - horizonEnabled: project.horizonEnabled, - horizonAppId: project.horizonAppId, - horizonAppSecret: project.horizonAppSecret, - })); - }, -}); - -export const getProjectByApiKey = internalQuery({ - args: { apiKey: v.string() }, - returns: v.union( - v.null(), - v.object({ - _id: v.id("projects"), - horizonEnabled: v.optional(v.boolean()), - horizonAppId: v.optional(v.union(v.string(), v.null())), - horizonAppSecret: v.optional(v.union(v.string(), v.null())), - }), - ), - handler: async (ctx, args) => { - const resolved = await resolveProjectByApiKeyFromDb( - ctx, - args.apiKey, - "admin", - ); - const project = resolved?.project ?? null; - if (!project) return null; - return { - _id: project._id, - horizonEnabled: project.horizonEnabled, - horizonAppId: project.horizonAppId, - horizonAppSecret: project.horizonAppSecret, - }; - }, -}); - -// All subscriptions for a Horizon project that might still mutate. -// Refunded/Revoked/Expired-with-no-renewal rows are excluded so the -// cron stays cheap as the historical archive grows. -export const listHorizonSubscriptions = internalQuery({ - args: { projectId: v.id("projects") }, - returns: v.array( - v.object({ - userId: v.string(), - sku: v.string(), - purchaseToken: v.string(), - state: v.string(), - }), - ), - handler: async (ctx, args) => { - // Hit by_project_and_state for each mutable state in parallel - // instead of full-scanning the project via by_project_and_updated - // and filtering in memory. The Refunded / Revoked / Expired - // historical archive is the bulk of any long-lived project — the - // index path skips it entirely. - // All states that can still mutate via Meta's verify_entitlement - // result. The historical archive (Refunded / Revoked / Expired - // with no auto-renew) is excluded so the cron stays cheap as the - // archive grows, but every live + transient state is included - // so a recovery (InBillingRetry → Active) or a Paused → expiry - // doesn't get stuck. - const STATES = [ - "Active", - "InGracePeriod", - "InBillingRetry", - "Paused", - "Unknown", - ] as const; - // Per-state cap with self-paginating, oldest-first ordering. - // - // Bounded for two reasons: (1) Convex's 40k document-read limit - // per query — 5 states × 6_000 = 30k reads, leaving ~10k for - // downstream filtering; (2) the action that consumes this list - // calls Meta `verify_entitlement` once per row, which has its - // own per-cron-tick budget. - // - // Pagination strategy: order by `updatedAt` ASC via the - // `by_project_and_state_and_updated` composite index. The - // staleest subs per state surface first; once - // `recordHorizonStatus` runs and writes a fresh `updatedAt`, - // those rows move to the back of the queue so the next tick - // picks up the never-reconciled tail. Time-to-fully-reconcile - // for population N is ~ceil(N / PER_STATE_CAP) ticks. A - // pathological 100k-sub project converges in ~17 ticks instead - // of "tail forever stale" (PR #124 - // (https://github.com/hyodotdev/openiap/pull/124) review). - // - // No external continuation cursor is needed because the cursor is - // implicit in `updatedAt` itself. - const PER_STATE_CAP = 6_000; - const perState = await Promise.all( - STATES.map((state) => - ctx.db - .query("subscriptions") - .withIndex("by_project_and_state_and_updated", (q) => - q.eq("projectId", args.projectId).eq("state", state), - ) - .order("asc") - .take(PER_STATE_CAP), - ), - ); - // Operator visibility: log when a state bucket fully fills the - // per-tick cap. The reconciler still completes correctly because - // the tail surfaces next tick, but a sustained cap-hit signals - // that the cron interval may be too sparse for the population. - STATES.forEach((state, i) => { - if (perState[i].length === PER_STATE_CAP) { - console.info( - `[horizon-reconciler] project=${args.projectId} state=${state} filled PER_STATE_CAP=${PER_STATE_CAP}; remaining tail will reconcile on subsequent ticks via updatedAt cursor.`, - ); - } - }); - return perState - .flat() - .filter((sub) => sub.platform === "Android") - .filter((sub) => !!sub.userId) - .map((sub) => ({ - userId: sub.userId!, - sku: sub.productId, - purchaseToken: sub.purchaseToken, - state: sub.state, - })); - }, -}); - -// The reconciler hands us a synthetic "event" describing what Meta -// just told us. We funnel it through the same state-machine the -// webhook receivers use so transition semantics stay consistent. -export const recordHorizonStatus = internalMutation({ - args: { - projectId: v.id("projects"), - purchaseToken: v.string(), - userId: v.string(), - productId: v.string(), - eventType: v.union( - v.literal("SubscriptionRenewed"), - v.literal("SubscriptionExpired"), - ), - }, - returns: v.union(v.null(), v.id("subscriptions")), - handler: async (ctx, args) => { - await assertProjectWritable(ctx, args.projectId); - const existing: Doc<"subscriptions"> | null = await ctx.db - .query("subscriptions") - .withIndex("by_project_and_token", (q) => - q - .eq("projectId", args.projectId) - .eq("purchaseToken", args.purchaseToken), - ) - .unique(); - if (!existing) return null; - - const current: CurrentSubscription = { - state: existing.state, - productId: existing.productId, - expiresAt: existing.expiresAt, - renewsAt: existing.renewsAt, - willRenew: existing.willRenew, - cancellationReason: existing.cancellationReason, - currency: existing.currency, - priceAmountMicros: existing.priceAmountMicros, - }; - const transition = applySubscriptionTransition(current, { - type: args.eventType, - productId: args.productId, - }); - if (!transition.next) return existing._id; - const now = Date.now(); - - // Record a synthetic webhookEvents row for operator history, metrics, and - // deduplication. Horizon has no upstream webhook. The deterministic - // sourceNotificationId prevents cron retries from duplicating the event. - const sourceNotificationId = `meta-horizon-${args.eventType}-${args.purchaseToken}-${args.productId}`; - - // Dedup by (projectId, source, sourceNotificationId) — re-running - // the same Horizon poll result (cron retries, manual reconcile) - // would otherwise insert another webhookEvents row. Reuse the existing - // event when one is already on file. - const existingEvent = await ctx.db - .query("webhookEvents") - .withIndex("by_project_and_source_and_notification_id", (q) => - q - .eq("projectId", args.projectId) - .eq("source", "MetaHorizonReconciler") - .eq("sourceNotificationId", sourceNotificationId), - ) - .unique(); - const eventId = existingEvent - ? existingEvent._id - : await ctx.db.insert("webhookEvents", { - projectId: args.projectId, - type: args.eventType, - source: "MetaHorizonReconciler", - platform: "Android", - environment: "Production", - purchaseToken: args.purchaseToken, - sourceNotificationId, - productId: args.productId, - subscriptionState: transition.next.state, - occurredAt: now, - receivedAt: now, - }); - // If we found an existing event AND the existing subscription row - // already references it, the rest of this mutation is a no-op — - // the prior cron tick already applied this transition. Bump - // `updatedAt` so the row moves to the back of the - // `by_project_and_state_and_updated` queue used by - // `listHorizonSubscriptions` for paginated reconciliation; - // otherwise steady-state rows whose deterministic event id - // doesn't change would stay pinned at the front of the cursor and - // anything past PER_STATE_CAP would never be revisited (PR #124 - // (https://github.com/hyodotdev/openiap/pull/124) review). - if (existing.lastEventId === eventId) { - await ctx.db.patch(existing._id, { updatedAt: now }); - return existing._id; - } - - // Capture stats contribution before patching so the delta below - // subtracts what the row used to count for and adds the new state. - // Horizon doesn't track billingPeriod (Meta doesn't expose one in - // verify_entitlement), so MRR contribution is 0 — matches the - // existing read-path semantics for Horizon-backed subs. - const beforeContribution = statsContributionFor(existing, undefined, now); - - // Horizon-specific expiresAt handling. Meta's verify_entitlement - // is binary (granted / not granted) — there's no upstream expiry - // we can copy onto the row. The state machine's CurrentSubscription - // path carries the OLD expiresAt forward, which means a renewed- - // upstream sub whose previous expiresAt is now in the past would - // be patched back to "Active" with a stale (already-expired) - // timestamp; the entitlement read path's `isActive` check then - // immediately treats it as inactive again. Set a forward-looking - // expiry that comfortably outlasts the next poll cycle (cron runs - // every 6h) so an `Active` Horizon row stays entitled until either - // the next reconcile flips it or the operator pauses the cron for - // an extended outage. - // - // For SubscriptionExpired we let the state-machine's transition - // handle the timestamp; the row is moving to a non-active state - // so the stale expiresAt is irrelevant. - const HORIZON_RENEWAL_VALIDITY_MS = 7 * 24 * 60 * 60 * 1000; - const horizonExpiresAt = - args.eventType === "SubscriptionRenewed" - ? now + HORIZON_RENEWAL_VALIDITY_MS - : transition.next.expiresAt; - - await ctx.db.patch(existing._id, { - state: transition.next.state, - willRenew: transition.next.willRenew, - cancellationReason: transition.next.cancellationReason, - expiresAt: horizonExpiresAt, - updatedAt: now, - lastEventId: eventId, - }); - - const updatedRow = (await ctx.db.get(existing._id))!; - const afterContribution = statsContributionFor(updatedRow, undefined, now); - await applyStatsTransition( - ctx, - args.projectId, - beforeContribution, - afterContribution, - ); - - return existing._id; - }, -}); diff --git a/packages/kit/convex/subscriptions/revenueMetrics.test.ts b/packages/kit/convex/subscriptions/revenueMetrics.test.ts index 039d453ae..03c9fc2e7 100644 --- a/packages/kit/convex/subscriptions/revenueMetrics.test.ts +++ b/packages/kit/convex/subscriptions/revenueMetrics.test.ts @@ -697,6 +697,17 @@ describe("pickRevenueMetricsProjects", () => { projects.filter((projectId) => projectId === PROJECT_ID), ).toHaveLength(1); }); + + it("does not seed work from legacy Meta Horizon reconciler events", async () => { + await seedEvent(db, { + type: "SubscriptionRenewed", + source: "MetaHorizonReconciler", + projectId: PROJECT_ID, + receivedAt: NOW, + }); + + expect(await pickRevenueMetricsProjects(ctx, 10)).toEqual([]); + }); }); describe("runRecompute — round-trip integration", () => { @@ -749,6 +760,31 @@ describe("runRecompute — round-trip integration", () => { }); }); + it("excludes legacy Meta Horizon reconciler events from rollups", async () => { + await seedEvent(db, { + type: "SubscriptionStarted", + priceAmountMicros: 99_000_000, + source: "MetaHorizonReconciler", + platform: "Android", + receivedAt: Date.parse(`${TODAY}T09:00:00Z`), + }); + await seedEvent(db, { + type: "SubscriptionStarted", + priceAmountMicros: 9_990_000, + receivedAt: Date.parse(`${TODAY}T10:00:00Z`), + }); + + await runRecompute(ctx, PROJECT_ID, NOW); + + const rows = await rollupRows(db); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + platform: "IOS", + newSubs: 1, + revenueMicros: 9_990_000, + }); + }); + it("renewals are counted (the v2-deferred-then-fixed regression test)", async () => { // The whole reason renewals matter: a sub started months ago, // renewed today. The `subscriptions` table only knows the diff --git a/packages/kit/convex/subscriptions/revenueMetrics.ts b/packages/kit/convex/subscriptions/revenueMetrics.ts index 10de36a86..f031941d7 100644 --- a/packages/kit/convex/subscriptions/revenueMetrics.ts +++ b/packages/kit/convex/subscriptions/revenueMetrics.ts @@ -1,8 +1,8 @@ // Daily revenue rollup populator. Reads `webhookEvents` (the canonical -// store-side event log — Apple ASN v2 / Google RTDN / Meta Horizon -// reconciler all converge here) over a trailing window and writes -// per-(project, day, productId, currency) rollups to -// `revenueMetricsDaily`. +// store-notification event log for Apple ASN v2 and Google RTDN) over a +// trailing window and writes per-(project, day, productId, currency) rollups +// to `revenueMetricsDaily`. Legacy synthetic Horizon reconciler rows are +// retained for schema compatibility but explicitly excluded below. // // Using the event log instead of walking `subscriptions` is what lets // us count renewals correctly: the `subscriptions` table holds the @@ -217,6 +217,10 @@ export async function pickRevenueMetricsProjects( .order("desc") .take(scanCap); for (const row of recentEvents) { + // Quarantine events synthesized by the removed experimental Horizon + // reconciler. They were not store notifications and had no authoritative + // product-type, term, or price data, so they must not seed revenue work. + if (row.source === "MetaHorizonReconciler") continue; await addUnseededProjectOnce( ctx, projects, @@ -475,6 +479,10 @@ async function processEventsPage( .paginate({ numItems: EVENTS_PAGE_SIZE, cursor: args.paginationCursor }); for (const event of result.page) { + // Legacy events from the removed Horizon reconciler were synthetic guesses + // rather than store notifications. Keep the schema literal so existing + // rows remain readable, but never fold those rows into revenue metrics. + if (event.source === "MetaHorizonReconciler") continue; if (!event.productId) continue; const day = utcDayKey(event.occurredAt); // Skip events whose store-side day falls outside the bucket @@ -485,10 +493,7 @@ async function processEventsPage( // a rounding error — Apple/Google both quarantine those. if (day < firstDay || day > lastDay) continue; const currency = event.currency ?? ""; - // The webhookEvents schema only allows `IOS` / `Android` for - // `platform`; the Meta Horizon reconciler synthesizes events - // under `platform: "Android"` because Quest devices map to the - // Play store's commerce model. No third value to handle here. + // The webhookEvents schema only allows `IOS` / `Android` for platform. const platform = event.platform; const key = bucketKey(day, event.productId, currency, platform); const bucket = getOrCreateBucket( diff --git a/packages/kit/convex/subscriptions/stats.ts b/packages/kit/convex/subscriptions/stats.ts index faff50d9f..269fb6a93 100644 --- a/packages/kit/convex/subscriptions/stats.ts +++ b/packages/kit/convex/subscriptions/stats.ts @@ -216,12 +216,11 @@ async function touchStatsRow( // share its budget with N project recomputes, which exceeds the // 40k cap once batchSize × per-project-reads > 40k. // -// Why: the incremental path in `applySubscriptionEvent` / -// `recordHorizonStatus` is correct in steady state, but a missed -// invocation (action timeout, schema drift during rollout, manual -// db.patch) can drift the counters. Running a full recompute daily -// keeps the dashboard self-healing without needing operator -// intervention. +// Why: the incremental path in `applySubscriptionEvent` is correct in +// steady state, but a missed invocation (action timeout, schema drift +// during rollout, manual db.patch) can drift the counters. Running a +// full recompute daily keeps the dashboard self-healing without needing +// operator intervention. export const recomputeAllSubscriptionStats = internalMutation({ args: { // Per-tick cap on how many projects to schedule. Each project @@ -437,13 +436,12 @@ async function runRecomputePageInline( } // Concurrent-write detection. If any subscription row was updated - // since the recompute started, the incremental path - // (applySubscriptionEvent / recordHorizonStatus) has already - // applied that delta to subscriptionStats — our paged snapshot is - // stale and must NOT overwrite it. Abort the commit; the next - // cron tick will pick this project back up. Convex mutations are - // transactional, so this read + the delete/insert below run in a - // single serialized txn — no further race window. + // since the recompute started, `applySubscriptionEvent` has already + // applied that delta to subscriptionStats — our paged snapshot is stale + // and must NOT overwrite it. Abort the commit; the next cron tick will + // pick this project back up. Convex mutations are transactional, so this + // read + the delete/insert below run in a single serialized txn — no + // further race window. const concurrentWrite = await ctx.db .query("subscriptions") .withIndex("by_project_and_updated", (q) => diff --git a/packages/kit/convex/webhooks/internal.ts b/packages/kit/convex/webhooks/internal.ts index 2fafa1b4b..68ca9859c 100644 --- a/packages/kit/convex/webhooks/internal.ts +++ b/packages/kit/convex/webhooks/internal.ts @@ -182,7 +182,6 @@ export const recordWebhookEvent = internalMutation({ sourceFull: v.union( v.literal("AppleAppStoreServerNotificationsV2"), v.literal("GooglePlayRealTimeDeveloperNotifications"), - v.literal("MetaHorizonReconciler"), ), platform: v.union(v.literal("IOS"), v.literal("Android")), environment: v.union( diff --git a/packages/kit/package.json b/packages/kit/package.json index b31236997..48ee4f884 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -19,7 +19,7 @@ "lint:convex": "convex typecheck", "lint:eslint": "env NODE_OPTIONS=--max-old-space-size=4096 eslint ./src ./server ./convex --ext ts,tsx --report-unused-disable-directives --no-warn-ignored", "test": "vitest run", - "test:coverage": "vitest run --coverage --coverage.include='server/**/*.ts' --coverage.exclude='server/**/*.test.ts' --coverage.reporter=text --coverage.reporter=lcov", + "test:coverage": "vitest run --coverage --coverage.include='server/**/*.ts' --coverage.include='convex/**/*.ts' --coverage.exclude='**/*.test.ts' --coverage.exclude='convex/_generated/**' --coverage.exclude='convex/test.setup.ts' --coverage.reporter=text --coverage.reporter=lcov", "test:watch": "vitest", "clean": "rm -rf node_modules bun.lock bun.lockb dist openiap-kit-server", "clean:cache": "rm -rf node_modules/.vite", diff --git a/packages/kit/public/llms-full.txt b/packages/kit/public/llms-full.txt index 9407cf8d7..1edd19dfa 100644 --- a/packages/kit/public/llms-full.txt +++ b/packages/kit/public/llms-full.txt @@ -49,7 +49,7 @@ Mounted at both `/v1/*` (canonical) and `/api/v1/*` (alias). Verify an in-app purchase. The body is a tagged union discriminated on `store`. Response always has shape -`{ store: "apple" | "google" | "horizon" | "amazon", isValid: boolean, state: , productId?: string }`. +`{ store: "apple" | "google" | "horizon" | "amazon", isValid: boolean, state: , productId?: string, environment?: "Sandbox" | "Production" }`. Request (Apple): @@ -81,10 +81,28 @@ Request (Amazon Appstore): "store": "amazon", "userId": "amzn1.account...", "receiptId": "amzn1.receipt...", - "sandbox": true + "sandbox": true, + "expectedProductId": "premium_monthly" } ``` +Amazon Cloud Sandbox is disabled per project by default. Enable **Allow Amazon +App Tester / RVS Cloud Sandbox** in project settings before sending +`sandbox: true`; IAPKit then uses a placeholder secret and never sends the +production shared secret to the sandbox endpoint. Handled Amazon results add +`environment: "Sandbox" | "Production"`. An `expectedProductId` mismatch is a +caller-scoped rejection and does not overwrite the store-verified purchase row. + +Active Amazon purchase rows become due for rechecking after 48 hours. That is a +scheduling cadence, not a completion guarantee: the bounded worker handles at +most 20 rows per five-minute tick (5,760/day, or 17,280 over 72 hours before +failures), and backlog or retries add delay. It is leased and paced below +Amazon's 10 TPS ceiling. Deterministic 400/497 and 410 verdicts update the row; +transient, configuration, and protocol failures only reschedule it. Amazon +`cancelDate` is the loss-of-access signal; a past `renewalDate` is not inferred +as expiry. This refreshes purchase snapshots and does not create Amazon +subscription rows. + For Apple and Google, `includeClientPayload: true` opts into a top-level `clientPayload`. IAPKit includes it only when verification is valid, the store returns a verified productId, and the exact platform/product has a payload: @@ -265,7 +283,7 @@ guidance, webhook simulation, and project inspection. Setup guides: | ------------------------ | :-------: | ------------------------------------------------------ | | `ENTITLED` | true | Paid, not refunded, entitlement active | | `PENDING_ACKNOWLEDGMENT` | true | Google Play: awaiting acknowledgement or consumption | -| `READY_TO_CONSUME` | true | Apple/Amazon: consumable ready for durable fulfillment | +| `READY_TO_CONSUME` | true | Apple, Amazon, or catalog-known Google consumable ready for durable fulfillment | | `PENDING` | false | In progress or awaiting confirmation | | `CONSUMED` | false | Google Play: consumable already fulfilled | | `CANCELED` | false | Refunded, revoked, or canceled | @@ -275,18 +293,18 @@ guidance, webhook simulation, and project inspection. Setup guides: ## Status codes -| Code | Body | When | -| ---- | ------------------------------------------------------- | ----------------------------------------------- | -| 200 | `{ store, isValid, state, productId?, clientPayload? }` | Verification ran | -| 400 | `INVALID_INPUT` | Malformed body, unknown store, oversized field | -| 400 | `INVALID_API_KEY` | Well-formed key that fails project lookup | -| 413 | `PAYLOAD_TOO_LARGE` | Request body exceeds the 32 KB edge cap | -| 401 | `MISSING_API_KEY` | No `Authorization` header | -| 403 | `INSUFFICIENT_SCOPE` | Publishable key used for an admin operation | -| 403 | `INVALID_API_KEY` | Wrong scheme or malformed key (format only) | -| 429 | `RATE_LIMITED` | Key, IP, or process bucket empty; inspect `X-RateLimit-Scope` and honor `Retry-After` | -| 503 | `SERVICE_BUSY` | Verification concurrency is full; retry later | -| 500 | `UNKNOWN_ERROR` | Server-side failure; include `X-Correlation-Id` | +| Code | Body | When | +| ---- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| 200 | `{ store, isValid, state, productId?, environment?, clientPayload? }` | Verification ran | +| 400 | `INVALID_INPUT` | Malformed body, unknown store, oversized field | +| 400 | `INVALID_API_KEY` | Well-formed key that fails project lookup | +| 413 | `PAYLOAD_TOO_LARGE` | Request body exceeds the 32 KB edge cap | +| 401 | `MISSING_API_KEY` | No `Authorization` header | +| 403 | `INSUFFICIENT_SCOPE` | Publishable key used for an admin operation | +| 403 | `INVALID_API_KEY` | Wrong scheme or malformed key (format only) | +| 429 | `RATE_LIMITED` | Key, IP, or process bucket empty; inspect `X-RateLimit-Scope` and honor `Retry-After` | +| 503 | `SERVICE_BUSY` | Verification concurrency is full; retry later | +| 500 | `UNKNOWN_ERROR` | Server-side failure; include `X-Correlation-Id` | Error body shape: @@ -427,7 +445,7 @@ Domains under `convex/` follow a CQRS layout — each has `query.ts`, - `projects/` — a single app; holds store credentials (Apple / Google / Horizon), owns API keys, owns verify events - `apiKeys/` — per-project bearer tokens, stored hashed -- `purchases/` — receipt validation history (apple, google, horizon) +- `purchases/` — receipt validation history (apple, google, horizon, amazon) - `certificates/` — uploaded `.p8` / service-account JSON blobs - `files/` — generic file store used by certificates - `migrations/` — one-off schema migrations diff --git a/packages/kit/public/llms.txt b/packages/kit/public/llms.txt index f436b7b09..cee32f5dc 100644 --- a/packages/kit/public/llms.txt +++ b/packages/kit/public/llms.txt @@ -2,7 +2,7 @@ > Receipt-validation SaaS managed by OpenIAP. Hosted at https://kit.openiap.dev. > One Bearer-authed endpoint for Apple / Google / Horizon / Amazon; -> harmonized response shape with `{ store, isValid, state, productId? }` so your backend has a single code path for +> harmonized response shape with `{ store, isValid, state, productId?, environment? }` so your backend has a single code path for > entitlement + refund detection. IAPKit lives in the OpenIAP monorepo as a Bun + Hono server, Convex backend, @@ -59,15 +59,26 @@ body-only reads; use raw HTTP or an app wrapper to retain response headers. - Horizon — `{ store: "horizon", userId, sku }` (≤ 256 chars each). IAPKit holds the App ID + App Secret server-side and composes the `OC|APP_ID|APP_SECRET` access token per-request. -- Amazon — `{ store: "amazon", userId, receiptId, sandbox? }` where - `userId` and `receiptId` come from Amazon Appstore RVS. +- Amazon — `{ store: "amazon", userId, receiptId, sandbox?, expectedProductId? }` + where `userId` and `receiptId` come from Amazon Appstore RVS. Production uses + the project-held RVS shared secret; sandbox requires the project's explicit + App Tester / Cloud Sandbox opt-in and never sends that production secret. ## Success response ```json -{ "store": "amazon", "isValid": true, "state": "ENTITLED" } +{ + "store": "amazon", + "isValid": true, + "state": "ENTITLED", + "productId": "premium_monthly", + "environment": "Sandbox" +} ``` +Handled Amazon results identify the selected `Sandbox` or `Production` +environment. Match the store-verified `productId` before fulfillment. + For Apple/Google only, `includeClientPayload: true` may add a top-level `clientPayload` when verification is valid, the store supplies a verified productId, and that exact platform/product has a payload: diff --git a/packages/kit/server/api/v1/replay-guard.test.ts b/packages/kit/server/api/v1/replay-guard.test.ts index c4faba60c..491660e1c 100644 --- a/packages/kit/server/api/v1/replay-guard.test.ts +++ b/packages/kit/server/api/v1/replay-guard.test.ts @@ -78,9 +78,17 @@ describe("hashPayload", () => { receiptId: "c", sandbox: false, }); + const differentExpectedProduct = hashPayload({ + store: "amazon", + userId: "ab", + receiptId: "c", + sandbox: true, + expectedProductId: "different.product", + }); expect(tupleLeft).not.toBe(tupleRight); expect(tupleLeft).not.toBe(production); + expect(tupleLeft).not.toBe(differentExpectedProduct); }); }); @@ -262,7 +270,7 @@ describe("isStableRejection", () => { it("does not arm the cooldown for a state a retry can change", () => { // PENDING resolves when the user finishes a deferred payment. UNKNOWN - // can be a successfully fetched future Play state or Amazon product type. + // can be a successfully fetched future Play state. for (const state of ["PENDING", "UNKNOWN", "FUTURE_STORE_STATE"]) { expect(isStableRejection(state)).toBe(false); } @@ -289,6 +297,9 @@ describe("replayGuardMiddleware cooldown wiring", () => { function runMiddleware(options: { store: Map; + body?: + | { store: "google"; purchaseToken: string } + | { store: "horizon"; userId: string; sku: string }; outcome?: { isValid: boolean; state: string; @@ -305,9 +316,13 @@ describe("replayGuardMiddleware cooldown wiring", () => { now: () => options.now, }); const vars: Record = { apiKeyHash: "hash" }; - const body = { store: "google" as const, purchaseToken: "tok" }; + const body = options.body ?? { + store: "google" as const, + purchaseToken: "tok", + }; let status = 200; let payload: unknown; + let reachedUpstream = false; const ctx = { var: vars, get: (k: string) => vars[k], @@ -323,11 +338,13 @@ describe("replayGuardMiddleware cooldown wiring", () => { }, }; const next = async () => { + reachedUpstream = true; if (options.outcome) vars.verifyOutcome = options.outcome; }; return middleware(ctx as never, next as never).then(() => ({ status, payload, + reachedUpstream, })); } @@ -360,6 +377,25 @@ describe("replayGuardMiddleware cooldown wiring", () => { } }); + it("lets Horizon recheck ownership immediately after success=false", async () => { + const store = new Map(); + const body = { + store: "horizon" as const, + userId: "meta-user-123", + sku: "premium:SUBSCRIPTION__MONTHLY", + }; + await runMiddleware({ + store, + body, + outcome: { isValid: false, state: "INAUTHENTIC" }, + now: 1_000, + }); + + const second = await runMiddleware({ store, body, now: 2_000 }); + expect(second.status).toBe(200); + expect(second.reachedUpstream).toBe(true); + }); + it("arms UNKNOWN when the verifier reports a revoked token", async () => { const store = new Map(); await runMiddleware({ diff --git a/packages/kit/server/api/v1/replay-guard.ts b/packages/kit/server/api/v1/replay-guard.ts index 5a3b8c696..47d954c34 100644 --- a/packages/kit/server/api/v1/replay-guard.ts +++ b/packages/kit/server/api/v1/replay-guard.ts @@ -35,8 +35,8 @@ export interface ReplayBucket { // returned a stable rejection. Subsequent // requests for the exact same payload are short-circuited with // `REPEATED_FAILURE` until the cooldown expires — re-asking - // Apple / Google / Horizon / Amazon about a receipt they already - // rejected, or retrying the same failed product-match guard, has + // Apple / Google / Amazon about a receipt they already rejected, + // or retrying the same failed product-match guard, has // no chance of changing the answer in seconds. An attacker // replaying a captured-then-revoked receipt should hit a hard wall // instead of being able to rotate timing under the per-request @@ -62,8 +62,8 @@ export type ReplayRejectReason = "burst" | "repeated_failure"; // retryable unless the verifier supplies explicit stable provenance. // This matters for UNKNOWN: Google uses it both for a successfully // fetched future/unrecognized state and for the explicit 410 revoked-token -// response. Amazon can likewise return a future product type that maps to -// UNKNOWN. Only the 410 path should arm the five-minute cooldown. +// response. Only a verdict with stable provenance should arm the five-minute +// cooldown. const STABLE_REJECTION_STATES = new Set([ "INAUTHENTIC", "CANCELED", @@ -104,7 +104,13 @@ export function hashPayload( | { store: "apple"; jws: string; expectedProductId?: string } | { store: "google"; purchaseToken: string; expectedProductId?: string } | { store: "horizon"; userId: string; sku: string } - | { store: "amazon"; userId: string; receiptId: string; sandbox?: boolean }, + | { + store: "amazon"; + userId: string; + receiptId: string; + sandbox?: boolean; + expectedProductId?: string; + }, ): string { const hasher = crypto.createHash("sha256"); hasher.update(body.store); @@ -135,6 +141,10 @@ export function hashPayload( hasher.update(body.receiptId); hasher.update("\0"); hasher.update(body.sandbox === true ? "sandbox" : "production"); + if (body.expectedProductId !== undefined) { + hasher.update("\0"); + hasher.update(body.expectedProductId); + } break; } return hasher.digest("hex").slice(0, 16); @@ -352,6 +362,7 @@ export function replayGuardMiddleware( userId: string; receiptId: string; sandbox?: boolean; + expectedProductId?: string; }; const bucketKey = `${apiKeyHash}:${hashPayload(body)}`; @@ -393,13 +404,15 @@ export function replayGuardMiddleware( refundCapacityRejectedAttempt(bucketKey); } else { // After the handler completes, mark the bucket if the upstream - // verification returned invalid. Lives in `finally` so an exception - // bubbling out of the handler doesn't skip the marking step — - // we only mark on the explicit `isValid: false` signal so - // configuration / network errors aren't conflated with stable - // receipt or product-match failures. + // verification returned a stable invalid verdict. Horizon is current + // ownership keyed by (userId, sku), not an immutable receipt: a user + // can buy the same SKU immediately after `success: false`, so its + // negative result must remain retryable. The normal token bucket still + // limits Horizon bursts. Lives in `finally` so an exception bubbling + // out of the handler doesn't skip marking stable receipt failures. const outcome = c.get("verifyOutcome"); if ( + body.store !== "horizon" && outcome && outcome.isValid === false && isStableRejection(outcome.state, outcome.stableRejection === true) diff --git a/packages/kit/server/api/v1/route-input-schemas.test.ts b/packages/kit/server/api/v1/route-input-schemas.test.ts index a3d593f12..56528c5cb 100644 --- a/packages/kit/server/api/v1/route-input-schemas.test.ts +++ b/packages/kit/server/api/v1/route-input-schemas.test.ts @@ -109,16 +109,37 @@ describe("verifyPurchaseInputSchema", () => { expect(result.success).toBe(true); }); + test("accepts a Horizon subscription-term SKU", () => { + const result = parse({ + store: "horizon", + userId: "1234567890", + sku: "subs-bronze:SUBSCRIPTION__MONTHLY", + }); + expect(result.success).toBe(true); + }); + test("accepts a well-formed Amazon payload", () => { const result = parse({ store: "amazon", userId: VALID_AMAZON_USER_ID, receiptId: VALID_AMAZON_RECEIPT_ID, sandbox: true, + expectedProductId: "amazon.premium.monthly", }); expect(result.success).toBe(true); }); + test("rejects a malformed Amazon expectedProductId", () => { + expect( + parse({ + store: "amazon", + userId: VALID_AMAZON_USER_ID, + receiptId: VALID_AMAZON_RECEIPT_ID, + expectedProductId: "premium/monthly", + }).success, + ).toBe(false); + }); + test("rejects empty Amazon userId / receiptId", () => { expect( parse({ diff --git a/packages/kit/server/api/v1/route-input-schemas.ts b/packages/kit/server/api/v1/route-input-schemas.ts index c28276dfd..48674e6bc 100644 --- a/packages/kit/server/api/v1/route-input-schemas.ts +++ b/packages/kit/server/api/v1/route-input-schemas.ts @@ -39,9 +39,9 @@ const AMAZON_RECEIPT_ID_MIN_LENGTH = 10; // token is an opaque URL-safe string; Meta's userId in practice is a // numeric string but the pattern below stays URL-safe-ish so a future // non-numeric format from Meta (or our own dev fixtures) doesn't -// regress; Meta's sku is app-defined but restricted to a URL-safe -// subset by Meta's dashboard. Anything failing these is definitionally -// not a real verification request — 400 INVALID_INPUT and move on. +// regress; Meta's sku is app-defined and subscription-term SKUs use +// `{SKU}:SUBSCRIPTION__{TERM}`. Anything failing these is definitionally not +// a real verification request — 400 INVALID_INPUT and move on. // // IMPORTANT: these patterns are intentionally lax enough to match // every legitimate shape we've seen. Tightening them further has a @@ -50,7 +50,7 @@ export const APPLE_JWS_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; const GOOGLE_PURCHASE_TOKEN_PATTERN = /^[A-Za-z0-9._~-]+$/; const HORIZON_USER_ID_PATTERN = /^[A-Za-z0-9_-]+$/; -const HORIZON_SKU_PATTERN = /^[A-Za-z0-9._-]+$/; +const HORIZON_SKU_PATTERN = /^[A-Za-z0-9._:-]+$/; const EXPECTED_PRODUCT_ID_PATTERN = /^[A-Za-z0-9._-]+$/; const AMAZON_USER_ID_PATTERN = /^[A-Za-z0-9._~=-]+$/; const AMAZON_RECEIPT_ID_PATTERN = /^[A-Za-z0-9._~:=/+-]+$/; @@ -164,7 +164,7 @@ export const verifyPurchaseInputSchema = v.variant("store", [ ), v.regex( HORIZON_SKU_PATTERN, - "sku must contain only letters, digits, '.', '_' or '-'.", + "sku must contain only letters, digits, '.', '_', ':' or '-'.", ), v.description( "Add-on SKU as configured in the Meta Developer Dashboard.", @@ -219,10 +219,11 @@ export const verifyPurchaseInputSchema = v.variant("store", [ v.pipe( v.boolean(), v.description( - "Use Amazon RVS Cloud Sandbox for App Tester receipts.", + "Use Amazon RVS Cloud Sandbox for App Tester receipts. The project must explicitly enable Amazon sandbox verification first.", ), ), ), + expectedProductId: expectedProductIdSchema, includeClientPayload: includeClientPayloadSchema, }), v.title("Amazon Appstore"), diff --git a/packages/kit/server/api/v1/route-response-schemas.test.ts b/packages/kit/server/api/v1/route-response-schemas.test.ts index e5276f76a..7da93df3e 100644 --- a/packages/kit/server/api/v1/route-response-schemas.test.ts +++ b/packages/kit/server/api/v1/route-response-schemas.test.ts @@ -37,6 +37,27 @@ describe("verifyPurchaseSuccessResponseSchema", () => { expect(result.success).toBe(false); }); + test("accepts Amazon environments and rejects unknown values", () => { + for (const environment of ["Sandbox", "Production"]) { + expect( + parse({ + store: "amazon", + isValid: true, + state: "ENTITLED", + environment, + }).success, + ).toBe(true); + } + expect( + parse({ + store: "amazon", + isValid: true, + state: "ENTITLED", + environment: "AppTester", + }).success, + ).toBe(false); + }); + test("accepts the complete optional client payload", () => { const result = parse({ store: "google", diff --git a/packages/kit/server/api/v1/route-response-schemas.ts b/packages/kit/server/api/v1/route-response-schemas.ts index c332b25ac..b1f1de9c7 100644 --- a/packages/kit/server/api/v1/route-response-schemas.ts +++ b/packages/kit/server/api/v1/route-response-schemas.ts @@ -83,6 +83,14 @@ const baseReceiptResponseSchema = v.object({ ), ), ), + environment: v.optional( + v.pipe( + v.union([v.literal("Sandbox"), v.literal("Production")]), + v.description( + "Amazon RVS environment selected by IAPKit. Present on handled Amazon verification results.", + ), + ), + ), clientPayload: v.optional( v.pipe( clientPayloadSchema, diff --git a/packages/kit/server/api/v1/routes.test.ts b/packages/kit/server/api/v1/routes.test.ts index 6ea376d3a..7801b7976 100644 --- a/packages/kit/server/api/v1/routes.test.ts +++ b/packages/kit/server/api/v1/routes.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getFunctionName } from "convex/server"; vi.mock("hono/bun", () => ({ getConnInfo: () => ({ remote: { address: "127.0.0.1" } }), @@ -96,6 +97,112 @@ describe("apiRoutes", () => { expect(convexClientMock.query).not.toHaveBeenCalled(); }); + it("forwards Amazon sandbox and expected product checks and exposes the RVS environment", async () => { + convexClientMock.action.mockResolvedValueOnce({ + isValid: true, + state: "ENTITLED", + productId: "amazon.premium.monthly", + environment: "Sandbox", + }); + + const response = await apiRoutes.request("/purchase/verify", { + method: "POST", + headers: { + Authorization: "Bearer route-test-amazon-forwarding", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "amazon", + userId: "amzn1.account.ABC123", + receiptId: "amzn1.receipt.ABC123456789=:1", + sandbox: true, + expectedProductId: "amazon.premium.monthly", + }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + store: "amazon", + isValid: true, + state: "ENTITLED", + productId: "amazon.premium.monthly", + environment: "Sandbox", + }); + expect(convexClientMock.action).toHaveBeenCalledOnce(); + const [functionReference, args] = convexClientMock.action.mock.calls[0]; + expect(getFunctionName(functionReference)).toBe( + "purchases/amazon:verifyAmazonReceiptInternalV1", + ); + expect(args).toEqual({ + apiKey: "route-test-amazon-forwarding", + userId: "amzn1.account.ABC123", + receiptId: "amzn1.receipt.ABC123456789=:1", + sandbox: true, + expectedProductId: "amazon.premium.monthly", + requestIp: undefined, + }); + }); + + it.each([ + { + label: "Apple", + apiKey: "route-test-apple-forwarding", + body: { + store: "apple", + jws: `${"a".repeat(40)}.${"b".repeat(40)}.${"c".repeat(40)}`, + expectedProductId: "apple.premium.monthly", + }, + functionName: "purchases/ios:verifyAppStoreReceiptInternalV1", + expectedArgs: { + apiKey: "route-test-apple-forwarding", + jws: `${"a".repeat(40)}.${"b".repeat(40)}.${"c".repeat(40)}`, + expectedProductId: "apple.premium.monthly", + requestIp: undefined, + }, + }, + { + label: "Horizon", + apiKey: "route-test-horizon-forwarding", + body: { + store: "horizon", + userId: "123456789", + sku: "horizon.premium.monthly", + }, + functionName: "purchases/horizon:verifyMetaHorizonReceiptInternalV1", + expectedArgs: { + apiKey: "route-test-horizon-forwarding", + userId: "123456789", + sku: "horizon.premium.monthly", + requestIp: undefined, + }, + }, + ])( + "forwards $label verification to the store-specific action", + async ({ apiKey, body, functionName, expectedArgs }) => { + convexClientMock.action.mockResolvedValueOnce({ + isValid: true, + state: "ENTITLED", + productId: + "expectedProductId" in body ? body.expectedProductId : body.sku, + }); + + const response = await apiRoutes.request("/purchase/verify", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + + expect(response.status).toBe(200); + expect(convexClientMock.action).toHaveBeenCalledOnce(); + const [functionReference, args] = convexClientMock.action.mock.calls[0]; + expect(getFunctionName(functionReference)).toBe(functionName); + expect(args).toEqual(expectedArgs); + }, + ); + it("keeps fetched UNKNOWN outcomes retryable without exposing internal hints", async () => { convexClientMock.action.mockResolvedValue({ isValid: false, diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index 34542fce2..b95c5812c 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -180,12 +180,14 @@ const verifyPurchaseRouteDescription = describeRoute({ ' • Horizon — `{ store: "horizon", userId, sku }` (Meta Quest;' + " IAPKit holds the App ID + App Secret and composes" + " `OC|APP_ID|APP_SECRET` server-side)\n" + - ' • Amazon — `{ store: "amazon", userId, receiptId, sandbox? }`' + - " (Amazon Appstore SDK RVS; IAPKit holds the shared secret)\n\n" + - "`expectedProductId` is optional for Apple / Google. When present, " + + ' • Amazon — `{ store: "amazon", userId, receiptId, sandbox?, expectedProductId? }`' + + " (Amazon Appstore SDK RVS; IAPKit holds the shared secret; sandbox " + + "requires an explicit project opt-in)\n\n" + + "`expectedProductId` is optional for Apple / Google / Amazon. When present, " + "IAPKit compares it against the product id verified by the upstream " + 'store and returns `isValid: false`, `state: "INAUTHENTIC"` on ' + - "mismatch. Successful responses include `productId` when the store " + + "mismatch without changing the persisted store verdict. Successful " + + "responses include `productId` when the store " + "response exposes one; for Horizon this is the checked `sku`.\n\n" + "Set `includeClientPayload: true` on Apple or Google requests to " + "attach the matching public product payload when the receipt is valid " + @@ -366,6 +368,7 @@ type VerifyPurchaseJson = userId: string; receiptId: string; sandbox?: boolean; + expectedProductId?: string; includeClientPayload?: boolean; }; @@ -402,6 +405,7 @@ const verifyPurchaseHandler = async ( isValid: boolean; state: string; productId?: string; + environment?: "Sandbox" | "Production"; stableRejection?: boolean; }, ) => { @@ -504,6 +508,7 @@ const verifyPurchaseHandler = async ( userId: json.userId, receiptId: json.receiptId, sandbox: json.sandbox, + expectedProductId: json.expectedProductId, requestIp, }, ); diff --git a/packages/kit/src/pages/auth/organization/Sidebar/Tablet.tsx b/packages/kit/src/pages/auth/organization/Sidebar/Tablet.tsx index c6bb75a1d..431d8b3e0 100644 --- a/packages/kit/src/pages/auth/organization/Sidebar/Tablet.tsx +++ b/packages/kit/src/pages/auth/organization/Sidebar/Tablet.tsx @@ -157,7 +157,7 @@ export function TabletSidebar({ // exactly viewport height, so no actual scroll happens, but the // browser stops propagating to main. `no-scrollbar` keeps the // visual unchanged. - className={`hidden md:flex flex-col bg-card border-r-thin transition-all duration-300 overflow-y-auto overscroll-contain no-scrollbar ${ + className={`hidden md:flex shrink-0 flex-col bg-card border-r-thin transition-all duration-300 overflow-y-auto overscroll-contain no-scrollbar ${ isSidebarOpen ? "w-64" : "w-16" }`} > diff --git a/packages/kit/src/pages/auth/organization/index.test.tsx b/packages/kit/src/pages/auth/organization/index.test.tsx new file mode 100644 index 000000000..0256c0396 --- /dev/null +++ b/packages/kit/src/pages/auth/organization/index.test.tsx @@ -0,0 +1,88 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + switchOrganization: vi.fn(), + organization: { + _id: "organizations_test", + name: "Hyo Dev", + slug: "hyo-dev", + }, +})); + +vi.mock("react-router-dom", () => ({ + Outlet: () =>
, + useLocation: () => ({ pathname: "/hyo-dev/project/martie/purchases" }), + useNavigate: () => mocks.navigate, + useParams: () => ({ orgSlug: "hyo-dev" }), +})); + +vi.mock("convex/react", () => ({ + useMutation: () => mocks.switchOrganization, + useQuery: (reference: string) => { + if (reference === "auth.loggedInUser") { + return { name: "Hyo", email: "hyo@example.test" }; + } + if (reference === "organizations.list") return [mocks.organization]; + return mocks.organization; + }, +})); + +vi.mock("@/convex", () => ({ + api: { + auth: { loggedInUser: "auth.loggedInUser" }, + organizations: { + mutation: { switchOrganization: "organizations.switch" }, + query: { + getOrganizationBySlug: "organizations.getBySlug", + getUserOrganizations: "organizations.list", + }, + }, + }, +})); + +vi.mock("../../../hooks/useUserProfile", () => ({ + useUserProfile: () => ({ + profile: { + currentOrganizationId: "organizations_test", + displayName: "Hyo", + }, + }), +})); + +vi.mock("../../../components/ThemeDropdown", () => ({ + ThemeDropdown: () => , +})); + +vi.mock("../../../components/SignOutButton", () => ({ + SignOutButton: () => , +})); + +vi.mock("../../../components/FreeTransitionNotice", () => ({ + FreeTransitionNotice: () => null, +})); + +import OrganizationLayout from "./index"; + +describe("OrganizationLayout responsive sizing", () => { + afterEach(() => { + cleanup(); + mocks.navigate.mockReset(); + mocks.switchOrganization.mockReset(); + }); + + it("contains horizontal overflow inside the content column", () => { + const { container } = render(); + + const sidebar = container.querySelector("aside"); + const main = container.querySelector("main"); + const contentColumn = main?.parentElement; + + expect(sidebar?.classList.contains("shrink-0")).toBe(true); + expect(contentColumn?.classList.contains("min-w-0")).toBe(true); + expect(main?.classList.contains("overflow-y-auto")).toBe(true); + expect(main?.classList.contains("overflow-x-hidden")).toBe(true); + }); +}); diff --git a/packages/kit/src/pages/auth/organization/index.tsx b/packages/kit/src/pages/auth/organization/index.tsx index 868f9704d..19c20baed 100644 --- a/packages/kit/src/pages/auth/organization/index.tsx +++ b/packages/kit/src/pages/auth/organization/index.tsx @@ -156,7 +156,7 @@ export default function OrganizationLayout() { /> {/* Main Content Area */} -
+
{/* Top Header */}
@@ -199,8 +199,10 @@ export default function OrganizationLayout() { Child pages should NOT add their own `overflow-y-auto` — nested scrolls previously caused the inner container to scroll past the visible content while the outer still had - room to move. */} -
+ room to move. Horizontal overflow belongs to page-local + table / tab scrollers; letting this shell scroll sideways + moves the entire page underneath the pinned sidebar. */} +
({ + navigate: vi.fn(), + pathname: "/hyo-dev/project/martie/purchases", + organization: { + _id: "organizations_test", + name: "Hyo Dev", + slug: "hyo-dev", + }, + project: { + _id: "projects_test", + organizationId: "organizations_test", + name: "Martie", + slug: "martie", + }, +})); + +vi.mock("react-router-dom", () => ({ + Outlet: () =>
, + useLocation: () => ({ pathname: mocks.pathname }), + useNavigate: () => mocks.navigate, + useParams: () => ({ orgSlug: "hyo-dev", projectSlug: "martie" }), +})); + +vi.mock("convex/react", () => ({ + useQuery: (reference: string) => + reference === "organizations.getBySlug" + ? mocks.organization + : mocks.project, +})); + +vi.mock("@/convex", () => ({ + api: { + organizations: { + query: { getOrganizationBySlug: "organizations.getBySlug" }, + }, + projects: { query: { getProject: "projects.getProject" } }, + }, +})); + +import ProjectIndex from "./index"; + +describe("ProjectIndex responsive tabs", () => { + afterEach(() => { + cleanup(); + mocks.navigate.mockReset(); + mocks.pathname = "/hyo-dev/project/martie/purchases"; + mocks.project.name = "Martie"; + mocks.project.slug = "martie"; + delete (mocks.project as typeof mocks.project & { platform?: string }) + .platform; + vi.restoreAllMocks(); + }); + + it("contains long project identity text and exposes its full value", () => { + const longProjectName = "Martie".repeat(40); + const longProjectSlug = "martie-".repeat(40); + mocks.project.name = longProjectName; + mocks.project.slug = longProjectSlug; + Object.assign(mocks.project, { platform: "react-native" }); + + render(); + + const heading = screen.getByRole("heading", { name: longProjectName }); + expect(heading.classList.contains("min-w-0")).toBe(true); + expect(heading.classList.contains("flex-1")).toBe(true); + expect(heading.classList.contains("truncate")).toBe(true); + expect(heading.getAttribute("title")).toBe(longProjectName); + + const identityPath = screen.getByTitle(`hyo-dev/${longProjectSlug}`); + expect(identityPath.classList.contains("truncate")).toBe(true); + expect(identityPath.parentElement?.classList.contains("min-w-0")).toBe( + true, + ); + + const identity = identityPath.parentElement?.parentElement; + expect(identity?.classList.contains("min-w-0")).toBe(true); + expect(identity?.classList.contains("flex-1")).toBe(true); + expect( + screen.getByText("React Native").classList.contains("shrink-0"), + ).toBe(true); + }); + + it("keeps the tab row in its own horizontal scroller without wrapping", () => { + render(); + + const navigation = screen.getByRole("navigation", { + name: "Project sections", + }); + const scroller = navigation.parentElement; + expect(scroller?.classList.contains("overflow-x-auto")).toBe(true); + expect(scroller?.classList.contains("overscroll-x-contain")).toBe(true); + expect(navigation.classList.contains("w-max")).toBe(true); + expect(navigation.classList.contains("min-w-full")).toBe(true); + + const buttons = within(navigation).getAllByRole("button"); + expect(buttons).toHaveLength(8); + for (const button of buttons) { + expect(button.classList.contains("shrink-0")).toBe(true); + expect(button.classList.contains("whitespace-nowrap")).toBe(true); + } + + expect( + within(navigation) + .getByRole("button", { name: "Purchases" }) + .getAttribute("aria-current"), + ).toBe("page"); + expect( + within(navigation) + .getByRole("button", { name: "API Keys" }) + .textContent?.trim(), + ).toBe("API Keys"); + }); + + it("preserves project navigation from the scrollable tab row", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + + expect(mocks.navigate).toHaveBeenCalledWith( + "/hyo-dev/project/martie/settings", + ); + }); + + it("reveals the active tab when a deep link loads", () => { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this.classList.contains("overflow-x-auto")) { + return DOMRect.fromRect({ x: 0, width: 500 }); + } + if (this.textContent?.includes("Settings")) { + return DOMRect.fromRect({ x: 600, width: 100 }); + } + return DOMRect.fromRect(); + }, + ); + mocks.pathname = "/hyo-dev/project/martie/settings"; + + render(); + + const activeButton = screen.getByRole("button", { name: "Settings" }); + const scroller = activeButton.closest("nav")?.parentElement; + + expect(activeButton.getAttribute("aria-current")).toBe("page"); + expect(scroller?.scrollLeft).toBe(200); + }); + + it("does not move the tab row when the active tab is already visible", () => { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this.classList.contains("overflow-x-auto")) { + return DOMRect.fromRect({ x: 0, width: 500 }); + } + if (this.textContent?.includes("Purchases")) { + return DOMRect.fromRect({ x: 16, width: 100 }); + } + return DOMRect.fromRect(); + }, + ); + + render(); + + const activeButton = screen.getByRole("button", { name: "Purchases" }); + const scroller = activeButton.closest("nav")?.parentElement; + + expect(scroller?.scrollLeft).toBe(0); + }); +}); diff --git a/packages/kit/src/pages/auth/organization/project/index.tsx b/packages/kit/src/pages/auth/organization/project/index.tsx index 8cb2e73ef..1e3b047a2 100644 --- a/packages/kit/src/pages/auth/organization/project/index.tsx +++ b/packages/kit/src/pages/auth/organization/project/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useParams, useNavigate, useLocation, Outlet } from "react-router-dom"; import { useQuery } from "convex/react"; import { Badge, PlatformBadge } from "../../../../components/Badge"; @@ -131,6 +131,25 @@ export default function ProjectIndex() { return DEFAULT_TAB; }, [location.pathname, orgSlug, projectSlug]); + const tabScrollerRef = useRef(null); + const activeTabButtonRef = useRef(null); + + useEffect(() => { + const scroller = tabScrollerRef.current; + const activeButton = activeTabButtonRef.current; + if (!scroller || !activeButton) return; + + const scrollerRect = scroller.getBoundingClientRect(); + const activeButtonRect = activeButton.getBoundingClientRect(); + const leftOverflow = activeButtonRect.left - scrollerRect.left; + const rightOverflow = activeButtonRect.right - scrollerRect.right; + + if (leftOverflow < 0) { + scroller.scrollLeft += leftOverflow; + } else if (rightOverflow > 0) { + scroller.scrollLeft += rightOverflow; + } + }, [activeTab, project?._id]); // Show loading while organization is being fetched if (currentOrg === undefined) { @@ -189,27 +208,41 @@ export default function ProjectIndex() { {/* Header */}
-
+
-
-
+
+
-
-
-

{project.name}

+
+
+

+ {project.name} +

{project.platform && ( - + )}
-

+

{orgSlug}/{project.slug}

@@ -217,17 +250,27 @@ export default function ProjectIndex() {
- {/* Tabs */} -
-
+ {/* Keep wide project navigation in its own horizontal scroller. + Otherwise `
` becomes the scroller and shifts the page body + underneath the fixed-width organization sidebar. */} +
+
+
{/* Content */} -
+
diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index f941dca24..8fe265c9e 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -1122,33 +1122,29 @@ function DryRunButton({ ); } -// Meta Horizon doesn't expose a catalog REST API — only -// `verify_entitlement` (purchase check) and `consume_entitlement` -// (consumable burn-down) are reachable from the server side. SKU -// definitions live exclusively in Meta Quest Developer Hub. We -// surface the constraint here so a Horizon-enabled project's -// operator doesn't keep looking for a missing "Sync with Meta" -// button — kit handles entitlements (receipt verification + -// 6-hour reconciliation cron) but cannot mirror the catalog. +// IAPKit does not currently implement Meta Horizon catalog sync. Surface that +// product boundary here so an operator does not keep looking for a missing +// "Sync with Meta" button or mistake synchronous entitlement verification for +// background subscription tracking. function HorizonCatalogNotice() { return ( -
+
-
Horizon catalog is upstream-only
+
Horizon catalog sync is not supported

- Meta doesn't expose a catalog API — manage Quest / Horizon SKUs - in Meta Quest Developer Hub. kit verifies Horizon receipts and - reconciles subscription entitlements every 6 hours, but the SKU list - itself can't be synced. + Manage Quest / Horizon SKUs in Meta Horizon Developer Dashboard. + IAPKit currently supports on-demand entitlement checks through its raw + REST verification route; it does not sync the catalog or run + background Horizon subscription reconciliation.

- Open Meta Quest Developer Hub + Open Meta Horizon documentation
diff --git a/packages/kit/src/pages/auth/organization/project/purchases.test.tsx b/packages/kit/src/pages/auth/organization/project/purchases.test.tsx new file mode 100644 index 000000000..a81d0f181 --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/purchases.test.tsx @@ -0,0 +1,124 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + setSearchParams: vi.fn(), + searchParams: new URLSearchParams(), + result: { + page: [], + continueCursor: null, + isDone: true, + stats: { + total: 19, + apple: 2, + google: 8, + googleOrders: 3, + horizon: 4, + amazon: 5, + valid: 15, + invalid: 4, + }, + }, +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => mocks.navigate, + useOutletContext: () => ({ + project: { + _id: "projects_test", + organizationId: "organizations_test", + name: "Test Project", + slug: "test-project", + }, + }), + useParams: () => ({ orgSlug: "test-org", projectSlug: "test-project" }), + useSearchParams: () => [mocks.searchParams, mocks.setSearchParams], +})); + +vi.mock("convex/react", () => ({ + useQuery: () => mocks.result, +})); + +vi.mock("@/convex", () => ({ + api: { purchases: { query: { getReceiptsByProject: "purchases.list" } } }, + HarmonizedPurchaseState: { + Entitled: "ENTITLED", + Inauthentic: "INAUTHENTIC", + }, +})); + +vi.mock("@/lib/mixpanel", () => ({ + MixpanelEvent: { ViewedPurchases: "viewed_purchases" }, + trackEvent: vi.fn(), +})); + +vi.mock("./PurchasesTable", () => ({ + PurchasesTable: () =>
, +})); + +vi.mock("antd", () => ({ + Input: (props: { placeholder?: string }) => ( + + ), + Select: () =>
, +})); + +import ProjectPurchases from "./purchases"; + +describe("ProjectPurchases store stats", () => { + beforeEach(() => { + mocks.navigate.mockReset(); + mocks.setSearchParams.mockReset(); + mocks.searchParams = new URLSearchParams(); + }); + + afterEach(() => { + cleanup(); + }); + + it("shows every store in a responsive card grid", () => { + render(); + + expect( + screen.getByRole("button", { name: "App Store" }).textContent, + ).toContain("2"); + expect( + screen.getByRole("button", { name: "Google Play" }).textContent, + ).toContain("3"); + expect( + screen.getByRole("button", { name: "Meta Horizon" }).textContent, + ).toContain("4"); + expect( + screen.getByRole("button", { name: "Amazon Appstore" }).textContent, + ).toContain("5"); + + const grid = screen.getByRole("button", { + name: "Total Purchases", + }).parentElement; + expect(grid?.classList.contains("sm:grid-cols-2")).toBe(true); + expect(grid?.classList.contains("xl:grid-cols-4")).toBe(true); + expect( + screen.getByText(/Amazon lifecycle stays here through RVS rechecks/), + ).toBeTruthy(); + }); + + it("filters the purchases table from Amazon and Horizon cards", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Amazon Appstore" })); + expect(mocks.setSearchParams).toHaveBeenCalledOnce(); + expect( + (mocks.setSearchParams.mock.calls[0][0] as URLSearchParams).get("store"), + ).toBe("amazon"); + + mocks.setSearchParams.mockReset(); + fireEvent.keyDown(screen.getByRole("button", { name: "Meta Horizon" }), { + key: "Enter", + }); + expect( + (mocks.setSearchParams.mock.calls[0][0] as URLSearchParams).get("store"), + ).toBe("horizon"); + }); +}); diff --git a/packages/kit/src/pages/auth/organization/project/purchases.tsx b/packages/kit/src/pages/auth/organization/project/purchases.tsx index 6ed9e30df..3be6c736d 100644 --- a/packages/kit/src/pages/auth/organization/project/purchases.tsx +++ b/packages/kit/src/pages/auth/organization/project/purchases.tsx @@ -31,6 +31,8 @@ type PurchaseStats = { total: number; apple: number; google: number; + horizon: number; + amazon: number; // Count of distinct Play Console orderIds across the project's Google // purchases. Diverges from `google` when pending-acknowledgement or // error rows exist (those inflate `google` but carry no orderId). @@ -45,13 +47,22 @@ type PurchaseStats = { invalid: number; }; -type CardKey = "total" | "apple" | "google" | "valid" | "invalid"; +type CardKey = + | "total" + | "apple" + | "google" + | "horizon" + | "amazon" + | "valid" + | "invalid"; type StoreFilter = "apple" | "google" | "horizon" | "amazon"; const STATS_LABELS: Record = { total: "Total Purchases", apple: "App Store", google: "Google Play", + horizon: "Meta Horizon", + amazon: "Amazon Appstore", valid: "Valid", invalid: "Invalid", }; @@ -167,6 +178,8 @@ export default function ProjectPurchases() { total: 0, apple: 0, google: 0, + horizon: 0, + amazon: 0, googleOrders: 0, valid: 0, invalid: 0, @@ -176,29 +189,23 @@ export default function ProjectPurchases() { total: activePurchases.stats.total, apple: activePurchases.stats.apple, google: activePurchases.stats.google, + horizon: activePurchases.stats.horizon ?? 0, + amazon: activePurchases.stats.amazon ?? 0, googleOrders: activePurchases.stats.googleOrders ?? 0, valid: activePurchases.stats.valid, invalid: activePurchases.stats.invalid, }; }, [activePurchases]); - // Only the "Google Play" card displays the orderId-based count — - // that's the number a developer can cross-check directly against - // their Play Console Orders report. All other cards stay on the - // row-count fields so: - // - `total === valid + invalid` math holds for every dataset - // - Horizon rows (no separate store bucket today) still show up - // in "Total" / "Valid" / "Invalid" without us having to carry - // an additional `horizonOrders` counter - // - Apple's `remoteId` is already `originalTransactionId` so - // `stats.apple` is effectively an order count already - // Rows without an `orderId` (pending-ack, error bodies) inflate - // `stats.google` but not `stats.googleOrders`, so the Google Play - // card converges to Play Console's Orders number on its own. + // Only Google has a separate stable order identifier. Its card uses the + // distinct order count; the other store cards use persisted purchase rows. + // Total / Valid / Invalid remain row counts so their arithmetic stays exact. const cardValues: Record = { total: stats.total, apple: stats.apple, google: stats.googleOrders, + horizon: stats.horizon, + amazon: stats.amazon, valid: stats.valid, invalid: stats.invalid, }; @@ -302,6 +309,8 @@ export default function ProjectPurchases() { }, { key: "apple", accent: "from-blue-500/10 to-transparent" }, { key: "google", accent: "from-green-500/10 to-transparent" }, + { key: "horizon", accent: "from-sky-500/10 to-transparent" }, + { key: "amazon", accent: "from-orange-500/10 to-transparent" }, { key: "valid", accent: "from-emerald-500/10 to-transparent" }, { key: "invalid", accent: "from-rose-500/10 to-transparent" }, ]; @@ -312,33 +321,47 @@ export default function ProjectPurchases() {

{"Purchases"}

{ - "View store states captured by each purchase's latest verification. Use Subscriptions for live lifecycle state." + "View each purchase's latest store state. Apple and Google subscriptions also appear in Subscriptions; Amazon lifecycle stays here through RVS rechecks." }

{ - "The Google Play card counts distinct Play Console orders. Other cards count every verification call shown in the table below." + "Google Play counts distinct Play Console orders. Other store cards count persisted purchase rows." }

-
+
{statConfig.map((stat) => { // Determine which card matches the currently-active filter // so the selected card is visually distinct from hover (the // prior styling only highlighted on hover, so users couldn't // tell which card they had already clicked). + const storeCard: StoreFilter | undefined = + stat.key === "apple" || + stat.key === "google" || + stat.key === "horizon" || + stat.key === "amazon" + ? stat.key + : undefined; const isActive = stat.key === "total" ? !storeFilter && isValidFilter === undefined - : stat.key === "apple" - ? storeFilter === "apple" - : stat.key === "google" - ? storeFilter === "google" - : stat.key === "valid" - ? isValidFilter === true - : isValidFilter === false; + : storeCard + ? storeFilter === storeCard + : stat.key === "valid" + ? isValidFilter === true + : isValidFilter === false; + const applyCardFilter = () => { + if (stat.key === "total") { + resetFilters(); + } else if (storeCard) { + applyStoreFilter(storeCard); + } else { + applyValidityFilter(stat.key === "valid"); + } + }; return (
{ - if (stat.key === "total") { - resetFilters(); - } else if (stat.key === "apple") { - applyStoreFilter("apple"); - } else if (stat.key === "google") { - applyStoreFilter("google"); - } else if (stat.key === "valid") { - applyValidityFilter(true); - } else if (stat.key === "invalid") { - applyValidityFilter(false); - } - }} + onClick={applyCardFilter} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); - if (stat.key === "total") { - resetFilters(); - } else if (stat.key === "apple") { - applyStoreFilter("apple"); - } else if (stat.key === "google") { - applyStoreFilter("google"); - } else if (stat.key === "valid") { - applyValidityFilter(true); - } else if (stat.key === "invalid") { - applyValidityFilter(false); - } + applyCardFilter(); } }} aria-label={STATS_LABELS[stat.key]} @@ -404,7 +405,7 @@ export default function ProjectPurchases() {
-
+
} value={requestIpQuery} diff --git a/packages/kit/src/pages/auth/organization/project/settings.test.tsx b/packages/kit/src/pages/auth/organization/project/settings.test.tsx index 129075aa5..758549283 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.test.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.test.tsx @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ iosAppStoreIssuerId: "12345678-ABCD-1234-ABCD-1234567890AB", iosAppStoreKeyId: "ABCDE12345", androidPackageName: "com.markhub.markly", + amazonSandboxEnabled: false, }, saveFile: vi.fn(), toastError: vi.fn(), @@ -117,6 +118,7 @@ const AUTHORIZATION_LOST_MESSAGE = describe("ProjectSettings", () => { beforeEach(() => { + mocks.project.amazonSandboxEnabled = false; mocks.downloadFile.mockReset(); mocks.fetch.mockReset(); mocks.generateUploadUrl.mockReset(); @@ -195,6 +197,32 @@ describe("ProjectSettings", () => { ).toBeTruthy(); }); + it("saves an Amazon sandbox opt-in without requiring a production secret", async () => { + mocks.otherMutation.mockResolvedValueOnce(undefined); + render(); + + fireEvent.click( + screen.getByRole("checkbox", { + name: /Allow Amazon App Tester \/ RVS Cloud Sandbox/, + }), + ); + const save = screen.getByRole("button", { + name: "Save Amazon config", + }); + expect(save.disabled).toBe(false); + fireEvent.click(save); + + await waitFor(() => { + expect(mocks.otherMutation).toHaveBeenCalledWith({ + projectId: "projects_test", + amazonSandboxEnabled: true, + }); + }); + expect(mocks.toastSuccess).toHaveBeenCalledWith( + "Amazon RVS configuration saved.", + ); + }); + it.each([ { inputId: "ios-file-upload", diff --git a/packages/kit/src/pages/auth/organization/project/settings.tsx b/packages/kit/src/pages/auth/organization/project/settings.tsx index 791fa0abe..9d23d2cc3 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.tsx @@ -184,6 +184,7 @@ interface ProjectData { // receives this boolean so the browser never sees the production // secret after setup. hasAmazonSharedSecret?: boolean; + amazonSandboxEnabled?: boolean; } interface OutletContext { @@ -254,6 +255,9 @@ export default function ProjectSettings() { const [amazonSharedSecret, setAmazonSharedSecret] = useState(""); const [isReplacingAmazonSharedSecret, setIsReplacingAmazonSharedSecret] = useState(false); + const [amazonSandboxEnabled, setAmazonSandboxEnabled] = useState( + project?.amazonSandboxEnabled === true, + ); const [savingMetadata, setSavingMetadata] = useState(false); const [savingReportingCurrency, setSavingReportingCurrency] = useState(false); const [savingHorizon, setSavingHorizon] = useState(false); @@ -314,6 +318,7 @@ export default function ProjectSettings() { const hasAmazonSharedSecretConfigured = Boolean( project?.hasAmazonSharedSecret, ); + const originalAmazonSandboxEnabled = project?.amazonSandboxEnabled === true; useEffect(() => { if (!project) { @@ -335,6 +340,7 @@ export default function ProjectSettings() { setIsReplacingHorizonAppSecret(false); setAmazonSharedSecret(""); setIsReplacingAmazonSharedSecret(false); + setAmazonSandboxEnabled(originalAmazonSandboxEnabled); }, [ project, originalAndroidPackageName, @@ -348,6 +354,7 @@ export default function ProjectSettings() { originalHorizonAppId, hasHorizonAppSecretConfigured, hasAmazonSharedSecretConfigured, + originalAmazonSandboxEnabled, ]); const trimmedAndroidPackageName = androidPackageName.trim(); @@ -543,11 +550,11 @@ export default function ProjectSettings() { const amazonSharedSecretNeeded = !hasAmazonSharedSecretConfigured || isReplacingAmazonSharedSecret; const amazonSharedSecretValid = - !amazonSharedSecretNeeded || - (trimmedAmazonSharedSecret.length > 0 && - trimmedAmazonSharedSecret.length <= 2_048); + trimmedAmazonSharedSecret.length === 0 || + trimmedAmazonSharedSecret.length <= 2_048; const amazonHasChanges = - amazonSharedSecretNeeded && trimmedAmazonSharedSecret.length > 0; + amazonSandboxEnabled !== originalAmazonSandboxEnabled || + (amazonSharedSecretNeeded && trimmedAmazonSharedSecret.length > 0); const disableSaveAmazon = !amazonHasChanges || !amazonSharedSecretValid || savingAmazon; @@ -656,10 +663,18 @@ export default function ProjectSettings() { setSavingAmazon(true); try { - await updateProject({ + const payload: { + projectId: Id<"projects">; + amazonSandboxEnabled: boolean; + amazonSharedSecret?: string; + } = { projectId: project._id, - amazonSharedSecret: trimmedAmazonSharedSecret, - }); + amazonSandboxEnabled, + }; + if (trimmedAmazonSharedSecret.length > 0) { + payload.amazonSharedSecret = trimmedAmazonSharedSecret; + } + await updateProject(payload); setAmazonSharedSecret(""); setIsReplacingAmazonSharedSecret(false); @@ -2206,6 +2221,27 @@ export default function ProjectSettings() { server-side through RVS with a project-level shared secret. */}
+ +
)} -
+
))}
- - - - - - - - - - - - - {subscriptions.items.length === 0 && ( +
+
UserProductPlatformStateExpiresUpdated
+ - + + + + + + - )} - {subscriptions.items.map((sub) => ( - - - - - - - - - ))} - -
- No subscriptions for this filter yet. Webhook events from - Apple / Google will populate this table. - UserProductPlatformStateExpiresUpdated
- {sub.userId ?? unbound} - {sub.productId} - - {sub.platform} - - - - - {sub.expiresAt ? formatDate(sub.expiresAt) : "—"} - - {formatDate(sub.updatedAt)} -
+ +
+ No subscriptions for this filter yet. Webhook events from + Apple / Google will populate this table. +
+ {sub.userId ?? unbound} + {sub.productId} + + {sub.platform} + + + + + {sub.expiresAt ? formatDate(sub.expiresAt) : "—"} + + {formatDate(sub.updatedAt)} +
+ ); diff --git a/packages/kit/src/pages/auth/organization/project/webhooks.test.tsx b/packages/kit/src/pages/auth/organization/project/webhooks.test.tsx new file mode 100644 index 000000000..b2f8af09d --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/webhooks.test.tsx @@ -0,0 +1,52 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ + setup: { + ios: { configured: false, missing: ["iosBundleId"] }, + android: { configured: false, missing: ["androidPackageName"] }, + horizon: { configured: false, missing: ["horizonEnabled"] }, + amazon: { configured: true, missing: [] }, + }, +})); + +vi.mock("react-router-dom", () => ({ + Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( +
{children} + ), + useOutletContext: () => ({ project: { _id: "projects_test" } }), + useParams: () => ({ + orgSlug: "test-org", + projectSlug: "test-project", + }), +})); + +vi.mock("convex/react", () => ({ + useQuery: (reference: string) => + reference === "projects.getSetupStatus" ? mocks.setup : null, +})); + +vi.mock("@/convex", () => ({ + api: { + projects: { + query: { getWebhookEndpointPaths: "projects.getWebhookEndpointPaths" }, + setupStatus: { getSetupStatus: "projects.getSetupStatus" }, + }, + }, +})); + +import ProjectWebhooks from "./webhooks"; + +describe("ProjectWebhooks setup badges", () => { + afterEach(cleanup); + + it("renders a sandbox-only Amazon setup as ready", () => { + render(); + + const amazonBadge = screen.getByText("Amazon RVS").parentElement; + expect(amazonBadge).toBeTruthy(); + expect(within(amazonBadge!).getByText("Ready")).toBeTruthy(); + expect(within(amazonBadge!).queryByText("Not configured")).toBeNull(); + }); +}); diff --git a/packages/kit/src/pages/auth/organization/project/webhooks.tsx b/packages/kit/src/pages/auth/organization/project/webhooks.tsx index e7e9a5a0f..5f34a1333 100644 --- a/packages/kit/src/pages/auth/organization/project/webhooks.tsx +++ b/packages/kit/src/pages/auth/organization/project/webhooks.tsx @@ -86,7 +86,7 @@ export default function ProjectWebhooks() { settingsHref={settingsHref} /> + +

+ Enable Allow Amazon App Tester / RVS Cloud Sandbox in + project settings before sending sandbox: true. Amazon + accepts any non-empty shared secret in Cloud Sandbox, so IAPKit keeps + it disabled by default and never sends your production shared secret + to the sandbox endpoint. +

+
+

The JSON body is capped at 32 KB before parsing. Every string field is @@ -127,7 +138,8 @@ export default function ApiReferencePage() { "store": "amazon", "isValid": true, "state": "ENTITLED", - "productId": "premium_monthly" + "productId": "premium_monthly", + "environment": "Sandbox" }`} @@ -136,7 +148,22 @@ export default function ApiReferencePage() { state permits that operation, and the store-verified productId is present and matches the product your app expected. For Meta Horizon, productId is the SKU IAPKit - checked. + checked. Amazon responses also identify the server-selected{" "} + environment as Sandbox or{" "} + Production. A caller-supplied Amazon{" "} + expectedProductId mismatch returns INAUTHENTIC{" "} + without changing the persisted RVS verdict. +

+

+ Active Amazon purchase rows become due for another RVS check after 48 + hours. This is a scheduling cadence, not a completion guarantee: the + worker handles at most 20 rows per five-minute tick (5,760/day, or + 17,280 over 72 hours before failures), and backlog or retries add delay. + Request starts remain below Amazon's 10 TPS polling ceiling. A + non-null cancelDate is authoritative loss of access; a past{" "} + renewalDate alone is not treated as expiry. These checks + refresh purchase snapshots only and do not create Amazon subscription + rows.

Apple and Google requests that explicitly send{" "} @@ -191,9 +218,10 @@ export default function ApiReferencePage() {

If your own backend keeps an entitlement ledger, do not trust a client-provided product id. Send expectedProductId with the - Apple or Google request. IAPKit compares it against the store-verified{" "} - productId and returns isValid: false with{" "} - state: "INAUTHENTIC" on mismatch. + Apple, Google, or Amazon request. IAPKit compares it against the + store-verified productId and returns{" "} + isValid: false with state: "INAUTHENTIC" on + mismatch.

@@ -542,7 +570,7 @@ async function refreshEntitlements( 200 - {`{ store, isValid, state, productId?, clientPayload? }`} + {`{ store, isValid, state, productId?, environment?, clientPayload? }`} Verification completed. diff --git a/packages/kit/src/pages/docs/sections/introduction.tsx b/packages/kit/src/pages/docs/sections/introduction.tsx index 220740e13..5975ddace 100644 --- a/packages/kit/src/pages/docs/sections/introduction.tsx +++ b/packages/kit/src/pages/docs/sections/introduction.tsx @@ -17,8 +17,9 @@ export default function IntroductionPage() { without building their own receipt server. You send a store-specific receipt to /v1/purchase/verify, IAPKit calls the upstream store with credentials it already holds for your project, and returns a - normalized {`{ store, isValid, state, productId? }`} result - your app can use. + normalized{" "} + {`{ store, isValid, state, productId?, environment? }`}{" "} + result your app can use.

When to reach for IAPKit

@@ -77,7 +78,7 @@ export default function IntroductionPage() { } title="Amazon Appstore" - detail="Fire OS receipts verified through Amazon RVS using the project's shared secret. Clients send only (userId, receiptId)." + detail="Fire OS receipts verified and periodically refreshed through Amazon RVS. Cloud Sandbox is disabled by default and requires an explicit project opt-in." slug="api" /> @@ -98,7 +99,7 @@ export default function IntroductionPage() { { store, ... } Horizon / Amazon ◄── verified receipt { store, isValid, state, - productId? } ◄─── harmonized state + productId?, environment? } ◄── harmonized state `} diff --git a/packages/kit/src/pages/docs/sections/operations.tsx b/packages/kit/src/pages/docs/sections/operations.tsx index ad084198c..beee952bb 100644 --- a/packages/kit/src/pages/docs/sections/operations.tsx +++ b/packages/kit/src/pages/docs/sections/operations.tsx @@ -248,8 +248,8 @@ X-RateLimit-Remaining: 599`} productId ≤ 256 chars (catalog / subscriptions)
  • - expectedProductId ≤ 256 chars (optional Apple / Google - verify match guard) + expectedProductId ≤ 256 chars (optional Apple / Google / + Amazon verify match guard)
  • diff --git a/packages/kit/src/pages/docs/sections/quickstart.tsx b/packages/kit/src/pages/docs/sections/quickstart.tsx index 016019a75..c8f79020c 100644 --- a/packages/kit/src/pages/docs/sections/quickstart.tsx +++ b/packages/kit/src/pages/docs/sections/quickstart.tsx @@ -77,6 +77,13 @@ export default function QuickstartPage() { {" "} — App ID + App Secret (inside the Android card). +

  • + + Amazon Appstore + {" "} + — RVS shared secret for production, or explicit App Tester / Cloud + Sandbox opt-in (inside the Android card). +
  • 4. Issue an API key

    @@ -176,17 +183,26 @@ export default function QuickstartPage() { "store": "amazon", "userId": "amzn1.account.ABC123", "receiptId": "amzn1.receipt.ABC123456789", - "sandbox": true + "sandbox": true, + "expectedProductId": "premium_monthly" }'`} +

    + Sandbox requests are rejected until you explicitly enable Amazon App + Tester / RVS Cloud Sandbox in project settings. Leave{" "} + sandbox unset for production, which requires the stored + Amazon RVS shared secret. +

    +

    Expected response:

    {`{ "store": "amazon", "isValid": true, "state": "ENTITLED", - "productId": "premium_monthly" + "productId": "premium_monthly", + "environment": "Sandbox" }`} diff --git a/packages/kit/src/pages/docs/sections/verification-horizon.tsx b/packages/kit/src/pages/docs/sections/verification-horizon.tsx index 47a0de0ef..17c7d0eba 100644 --- a/packages/kit/src/pages/docs/sections/verification-horizon.tsx +++ b/packages/kit/src/pages/docs/sections/verification-horizon.tsx @@ -8,7 +8,7 @@ export default function VerificationHorizonPage() {

    Meta Horizon (Quest / Meta VR) uses a billing SDK that's{" "} @@ -81,6 +81,24 @@ export default function VerificationHorizonPage() {

    + +

    + Horizon verification is currently available through the raw{" "} + POST /v1/purchase/verify route shown below. It is not a + member of the first-party SDK verification request type, does not + create a subscription record, and does not run a background + reconciliation job. Reverify the exact userId +{" "} + sku pair whenever your backend needs a current + entitlement decision. +

    +

    + Meta's binary response does not identify whether the SKU is a + consumable, durable, or subscription, nor does it provide a billing + term. IAPKit therefore does not infer subscription lifecycle events or + Horizon revenue from this response. +

    +
    +

    Verify call

    {`curl -X POST https://kit.openiap.dev/v1/purchase/verify \\ @@ -119,6 +137,13 @@ access_token=OC%7C{APP_ID}%7C{APP_SECRET}&user_id={userId}&sku={sku} success: true maps to ENTITLED,{" "} false to INAUTHENTIC.

    +

    + A receipt-history row is written only when Meta returns a successful + HTTP response containing an actual boolean success field. + Rate limits, server or network failures, timeouts, invalid JSON, and + ambiguous response shapes return an error without replacing the last + confirmed result. +

    Error codes

    diff --git a/scripts/assert-lcov-coverage.mjs b/scripts/assert-lcov-coverage.mjs index 486dfa16e..6de674eae 100644 --- a/scripts/assert-lcov-coverage.mjs +++ b/scripts/assert-lcov-coverage.mjs @@ -4,16 +4,61 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -export function readLcovLineCoverage(source) { +function sourcePathSegments(sourcePath) { + return path.posix + .normalize(sourcePath.replaceAll("\\", "/")) + .split("/") + .filter((segment) => segment !== "" && segment !== "."); +} + +function sourcePathMatchesPrefix(sourcePath, sourcePrefix) { + const sourceSegments = sourcePathSegments(sourcePath); + const prefixSegments = sourcePathSegments(sourcePrefix); + if (prefixSegments.length === 0) return true; + + for ( + let start = 0; + start <= sourceSegments.length - prefixSegments.length; + start += 1 + ) { + if ( + prefixSegments.every( + (prefixSegment, offset) => + sourceSegments[start + offset] === prefixSegment, + ) + ) { + return true; + } + } + return false; +} + +export function readLcovLineCoverage(source, sourcePrefix) { let found = 0; let hit = 0; + const displayedPrefix = + sourcePrefix === undefined ? undefined : sourcePrefix.replaceAll("\\", "/"); + let includeRecord = sourcePrefix === undefined; for (const line of source.split(/\r?\n/)) { + if (line.startsWith("SF:")) { + includeRecord = + sourcePrefix === undefined || + sourcePathMatchesPrefix(line.slice(3), sourcePrefix); + continue; + } + if (!includeRecord) continue; if (line.startsWith("LF:")) found += Number(line.slice(3)); if (line.startsWith("LH:")) hit += Number(line.slice(3)); } if (!Number.isFinite(found) || !Number.isFinite(hit) || found <= 0) { - throw new Error("LCOV report does not contain valid LF/LH line totals"); + const scope = + displayedPrefix === undefined + ? "" + : ` for source prefix ${JSON.stringify(displayedPrefix)}`; + throw new Error( + `LCOV report does not contain valid LF/LH line totals${scope}`, + ); } if (hit < 0 || hit > found) { throw new Error(`LCOV line totals are invalid: ${hit}/${found}`); @@ -22,11 +67,14 @@ export function readLcovLineCoverage(source) { return { found, hit, percentage: (hit / found) * 100 }; } -export function assertLcovLineCoverage(reportPath, minimum) { +export function assertLcovLineCoverage(reportPath, minimum, sourcePrefix) { if (!Number.isFinite(minimum) || minimum < 0 || minimum > 100) { throw new Error(`Coverage minimum must be between 0 and 100: ${minimum}`); } - const coverage = readLcovLineCoverage(fs.readFileSync(reportPath, "utf8")); + const coverage = readLcovLineCoverage( + fs.readFileSync(reportPath, "utf8"), + sourcePrefix, + ); if (coverage.percentage < minimum) { throw new Error( `Line coverage ${coverage.percentage.toFixed(2)}% (${coverage.hit}/${coverage.found}) is below ${minimum.toFixed(2)}%`, @@ -39,19 +87,21 @@ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); if (isMain) { - const [reportPath, minimumInput] = process.argv.slice(2); + const [reportPath, minimumInput, sourcePrefix] = process.argv.slice(2); if (!reportPath || minimumInput === undefined) { console.error( - "Usage: node scripts/assert-lcov-coverage.mjs ", + "Usage: node scripts/assert-lcov-coverage.mjs [source-prefix]", ); process.exit(2); } try { const minimum = Number(minimumInput); - const coverage = assertLcovLineCoverage(reportPath, minimum); + const coverage = assertLcovLineCoverage(reportPath, minimum, sourcePrefix); + const scope = + sourcePrefix === undefined ? "" : ` for ${JSON.stringify(sourcePrefix)}`; console.log( - `Line coverage ${coverage.percentage.toFixed(2)}% (${coverage.hit}/${coverage.found}) meets ${minimum.toFixed(2)}%`, + `Line coverage${scope} ${coverage.percentage.toFixed(2)}% (${coverage.hit}/${coverage.found}) meets ${minimum.toFixed(2)}%`, ); } catch (error) { console.error(`::error::${error instanceof Error ? error.message : error}`); diff --git a/scripts/assert-lcov-coverage.test.mjs b/scripts/assert-lcov-coverage.test.mjs index 75d5aac37..3d772ef22 100644 --- a/scripts/assert-lcov-coverage.test.mjs +++ b/scripts/assert-lcov-coverage.test.mjs @@ -33,6 +33,109 @@ describe("LCOV line coverage guard", () => { ); }); + it("scopes line totals to matching source paths", () => { + const source = [ + "SF:server/api.ts", + "LF:10", + "LH:9", + "end_of_record", + "SF:convex/purchases/amazon.ts", + "LF:8", + "LH:4", + "end_of_record", + "SF:convex\\purchases\\horizon.ts", + "LF:2", + "LH:2", + "end_of_record", + ].join("\n"); + + assert.deepEqual(readLcovLineCoverage(source, "server/"), { + found: 10, + hit: 9, + percentage: 90, + }); + assert.deepEqual(readLcovLineCoverage(source, "./convex/"), { + found: 10, + hit: 6, + percentage: 60, + }); + const reportPath = report(source); + assert.doesNotThrow(() => + assertLcovLineCoverage(reportPath, 60, "convex/"), + ); + assert.throws( + () => assertLcovLineCoverage(reportPath, 61, "convex/"), + /60\.00%.*below 61\.00%/, + ); + }); + + it("matches relative and absolute paths on directory boundaries", () => { + const source = [ + "SF:convex/purchases/amazon.ts", + "LF:4", + "LH:2", + "end_of_record", + "SF:/workspace/openiap/packages/kit/convex/purchases/horizon.ts", + "LF:3", + "LH:3", + "end_of_record", + "SF:C:\\workspace\\openiap\\packages\\kit\\convex\\purchases\\ios.ts", + "LF:3", + "LH:1", + "end_of_record", + "SF:/workspace/openiap/packages/kit/convexity/not-convex.ts", + "LF:100", + "LH:0", + "end_of_record", + ].join("\n"); + + assert.deepEqual(readLcovLineCoverage(source, "convex"), { + found: 10, + hit: 6, + percentage: 60, + }); + assert.deepEqual(readLcovLineCoverage(source, "./convex/"), { + found: 10, + hit: 6, + percentage: 60, + }); + }); + + it("does not match a source directory that only shares the prefix", () => { + assert.throws( + () => + readLcovLineCoverage( + "SF:/workspace/packages/kit/convexity/file.ts\nLF:1\nLH:1\n", + "convex", + ), + /source prefix "convex"/, + ); + }); + + it("resolves parent-directory segments before prefix matching", () => { + const source = [ + "SF:convex/../server/api.ts", + "LF:4", + "LH:4", + "end_of_record", + "SF:convex/purchases/amazon.ts", + "LF:6", + "LH:3", + "end_of_record", + ].join("\n"); + + assert.deepEqual(readLcovLineCoverage(source, "convex/"), { + found: 6, + hit: 3, + percentage: 50, + }); + assert.deepEqual(readLcovLineCoverage(source, "server/"), { + found: 4, + hit: 4, + percentage: 100, + }); + }); + it("accepts the exact minimum and rejects lower coverage", () => { assert.doesNotThrow(() => assertLcovLineCoverage(report("LF:10\nLH:9\n"), 90), @@ -52,5 +155,13 @@ describe("LCOV line coverage guard", () => { () => assertLcovLineCoverage(report("LF:1\nLH:1\n"), 101), /between 0 and 100/, ); + assert.throws( + () => + readLcovLineCoverage( + "SF:server/api.ts\nLF:1\nLH:1\nend_of_record\n", + "convex/", + ), + /source prefix "convex\/"/, + ); }); }); diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 2e53f0b1b..ae5ff6919 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -42,6 +42,11 @@ execFileSync( ["--test", path.resolve(root, "scripts/assert-lcov-coverage.test.mjs")], { stdio: "inherit" }, ); +execFileSync( + process.execPath, + ["--test", path.resolve(root, "scripts/e2e-web-sites.test.mjs")], + { stdio: "inherit" }, +); execFileSync( process.execPath, [ @@ -596,19 +601,53 @@ function checkFrameworkCiAndCoverageBadges() { workflowFile: "ci-flutter-inapp-purchase.yml", }, { + additionalCoverageComponents: [ + { + componentId: "iapkit-convex", + componentName: "IAPKit Convex", + coveragePath: "packages/kit/convex", + coverageTarget: 48, + generatedCoverageFile: null, + ignoredCoveragePaths: [ + "packages/kit/convex/_generated/**", + "packages/kit/convex/**/*.test.ts", + "packages/kit/convex/test.setup.ts", + ], + statusPath: "packages/kit/convex", + }, + ], codecovTargetPath: "packages/kit/server", codecovConfigPush: false, + coverageAssertions: [ + "run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90 server/", + "run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 48 convex/", + ], componentId: "iapkit-server", componentName: "IAPKit Server", coveragePath: "packages/kit/server", + expectedCoverageCommand: + "vitest run --coverage --coverage.include='server/**/*.ts' --coverage.include='convex/**/*.ts' --coverage.exclude='**/*.test.ts' --coverage.exclude='convex/_generated/**' --coverage.exclude='convex/test.setup.ts' --coverage.reporter=text --coverage.reporter=lcov", generatedCoverageFile: null, libraryPath: "packages/kit", readmePath: "packages/kit/README.md", + statusPath: "packages/kit/server", testCommand: "run: bun run test:coverage", testJob: "verify", + uploadFlag: "iapkit", + uploadName: "iapkit", workflowFile: "deploy-kit.yml", }, ]; + const coverageComponents = contracts.flatMap((contract) => { + const uploadFlag = contract.uploadFlag ?? contract.componentId; + return [ + { ...contract, uploadFlag }, + ...(contract.additionalCoverageComponents ?? []).map((component) => ({ + ...component, + uploadFlag: component.uploadFlag ?? uploadFlag, + })), + ]; + }); function extractHttpsUrls(source) { return [...source.matchAll(/https:\/\/[^\s"'<>()[\]]+/g)].map((match) => @@ -784,9 +823,26 @@ function checkFrameworkCiAndCoverageBadges() { if (exists("codecov.yml")) { const codecovConfig = read("codecov.yml"); - const expectedComponentIds = contracts.map( + const lcovParserBlock = [ + "parsers:", + " lcov:", + " partials_as_hits: true", + ].join("\n"); + if (!codecovConfig.includes(lcovParserBlock)) { + fail( + "codecov.yml must count LCOV partial lines as hits to match the local line-coverage gates", + ); + } + const expectedComponentIds = coverageComponents.map( ({ componentId }) => componentId, ); + const coverageFlagComponents = new Map(); + for (const component of coverageComponents) { + const components = coverageFlagComponents.get(component.uploadFlag) ?? []; + components.push(component); + coverageFlagComponents.set(component.uploadFlag, components); + } + const expectedFlagIds = [...coverageFlagComponents.keys()]; const flagIds = uniqueMatches( extractTopLevelYamlSection(codecovConfig, "flags"), /^ ([A-Za-z0-9_-]+):$/gm, @@ -795,56 +851,68 @@ function checkFrameworkCiAndCoverageBadges() { extractTopLevelYamlSection(codecovConfig, "component_management"), /^ - component_id:\s*([A-Za-z0-9_-]+)$/gm, ); - expectSameSet("Codecov flags", expectedComponentIds, flagIds); + expectSameSet("Codecov flags", expectedFlagIds, flagIds); expectSameSet("Codecov components", expectedComponentIds, componentIds); - for (const contract of contracts) { - const generatedCoveragePath = contract.generatedCoverageFile - ? `${contract.coveragePath}/${contract.generatedCoverageFile}` - : null; + for (const [flagId, components] of coverageFlagComponents) { + const coveragePaths = [ + ...new Set(components.map(({ coveragePath }) => coveragePath)), + ]; const flagBlock = [ - ` ${contract.componentId}:`, + ` ${flagId}:`, " paths:", - ` - \"${contract.coveragePath}/**\"`, + ...coveragePaths.map( + (coveragePath) => ` - \"${coveragePath}/**\"`, + ), " carryforward: true", ].join("\n"); + if (!codecovConfig.includes(flagBlock)) { + fail( + `codecov.yml must define the ${flagId} carryforward flag for ${coveragePaths.join(", ")}`, + ); + } + } + for (const contract of coverageComponents) { const componentBlock = [ ` - component_id: ${contract.componentId}`, ` name: ${contract.componentName}`, " paths:", ` - \"${contract.coveragePath}/**\"`, " flag_regexes:", - ` - \"^${contract.componentId}$\"`, + ` - \"^${contract.uploadFlag}$\"`, ].join("\n"); - if (!codecovConfig.includes(flagBlock)) { - fail( - `codecov.yml must define the ${contract.componentId} carryforward flag for ${contract.coveragePath}`, - ); - } if (!codecovConfig.includes(componentBlock)) { fail( `codecov.yml must map component ${contract.componentId} to its matching path and flag`, ); } - if ( - generatedCoveragePath && - !codecovConfig.includes(` - "${generatedCoveragePath}"`) - ) { - fail( - `codecov.yml must ignore generated coverage file ${generatedCoveragePath}`, - ); + const ignoredCoveragePaths = [ + ...(contract.generatedCoverageFile + ? [`${contract.coveragePath}/${contract.generatedCoverageFile}`] + : []), + ...(contract.ignoredCoveragePaths ?? []), + ]; + for (const ignoredCoveragePath of ignoredCoveragePaths) { + if (!codecovConfig.includes(` - "${ignoredCoveragePath}"`)) { + fail( + `codecov.yml must ignore generated or test coverage path ${ignoredCoveragePath}`, + ); + } } + const coverageTarget = contract.coverageTarget ?? 90; + const statusScope = contract.statusPath + ? [" paths:", ` - \"${contract.statusPath}/**\"`] + : [" flags:", ` - ${contract.uploadFlag}`]; const statusBlock = [ ` ${contract.componentId}:`, - " target: 90%", + ` target: ${coverageTarget}%`, " threshold: 0%", " informational: false", - " flags:", - ` - ${contract.componentId}`, + ...statusScope, ].join("\n"); const statusOccurrences = codecovConfig.split(statusBlock).length - 1; if (statusOccurrences !== 2) { fail( - `codecov.yml project and patch statuses must enforce 90% for ${contract.componentId}`, + `codecov.yml project and patch statuses must enforce ${coverageTarget}% for ${contract.componentId}`, ); } } @@ -856,24 +924,41 @@ function checkFrameworkCiAndCoverageBadges() { expectFile(contract.readmePath); if (!exists(workflowPath) || !exists(contract.readmePath)) continue; + if (contract.expectedCoverageCommand) { + const packageJsonPath = `${contract.libraryPath}/package.json`; + expectFile(packageJsonPath); + if (exists(packageJsonPath)) { + const coverageCommand = + readJson(packageJsonPath).scripts?.["test:coverage"]; + if (coverageCommand !== contract.expectedCoverageCommand) { + fail( + `${packageJsonPath} must collect all ${contract.uploadFlag} coverage in one guarded Vitest run`, + ); + } + } + } + const workflowSource = read(workflowPath); const testJob = extractWorkflowJob(workflowSource, contract.testJob); if (!testJob) { fail(`${workflowPath} is missing the ${contract.testJob} coverage job`); continue; } + const coverageAssertions = contract.coverageAssertions ?? [ + "run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90", + ]; for (const needle of [ "permissions:\n contents: read\n id-token: write", "fetch-depth: 0\n persist-credentials: false", contract.testCommand, - "run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90", + ...coverageAssertions, "uses: codecov/codecov-action@v7", "use_oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}", "fail_ci_if_error: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}", "disable_search: true", "files: coverage/lcov.info", - `flags: ${contract.componentId}`, - `name: ${contract.componentId}`, + `flags: ${contract.uploadFlag ?? contract.componentId}`, + `name: ${contract.uploadName ?? contract.componentId}`, `network_prefix: ${contract.libraryPath}/`, `working-directory: ${contract.libraryPath}`, ]) { @@ -888,14 +973,14 @@ function checkFrameworkCiAndCoverageBadges() { `${workflowPath} ${contract.testJob} must upload exactly one coverage report`, ); } - const coverageAssertionIndex = testJob.indexOf( - "run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90", - ); const uploadIndex = testJob.indexOf("uses: codecov/codecov-action@v7"); - if (coverageAssertionIndex < 0 || uploadIndex <= coverageAssertionIndex) { - fail( - `${workflowPath} must enforce LCOV coverage before uploading coverage`, - ); + for (const coverageAssertion of coverageAssertions) { + const coverageAssertionIndex = testJob.indexOf(coverageAssertion); + if (coverageAssertionIndex < 0 || uploadIndex <= coverageAssertionIndex) { + fail( + `${workflowPath} must enforce LCOV coverage before uploading coverage`, + ); + } } if (/^\s+token:/m.test(testJob)) { fail(`${workflowPath} must use Codecov OIDC without a stored token`); @@ -2255,6 +2340,113 @@ function checkKmp() { ); } +function checkIapkitAmazonContractWiring() { + expectIncludes( + "packages/apple/Sources/OpenIapModule.swift", + [ + "expectedProductId: amazon.expectedProductId", + "let environment = try Self.iapkitEnvironment", + "environment: environment", + ], + "Apple IAPKit Amazon verification contract", + ); + expectIncludes( + "packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt", + [ + 'amazon.expectedProductId?.let { put("expectedProductId", it) }', + 'it == "Sandbox" || it == "Production"', + "environment = environment", + ], + "Google IAPKit Amazon verification contract", + ); + expectIncludes( + "packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt", + [ + "expectedProductId = amazon.expectedProductId", + "includeClientPayload = options.includeClientPayload", + "withResolvedAmazonUserId(options, userId)", + ], + "Amazon user-data resolution must preserve IAPKit verification options", + ); + expectNotIncludes( + "packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt", + ["options.copy(amazon = amazon.copy"], + "Amazon IAPKit options must not use data-class copy for compatibility fields", + ); + expectIncludes( + "libraries/react-native-iap/src/specs/RnIap.nitro.ts", + ["expectedProductId?: string | null", "environment?: string | null"], + "React Native Nitro IAPKit Amazon contract", + ); + for (const [file, needles, label] of [ + [ + "libraries/react-native-iap/ios/HybridRnIap.swift", + ['amazonDict["expectedProductId"]', "environment: RnIapHelper.wrapString"], + "React Native iOS IAPKit bridge", + ], + [ + "libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt", + ['amazonMap["expectedProductId"]', "environment = item.environment"], + "React Native Android IAPKit bridge", + ], + [ + "libraries/react-native-iap/src/vega-adapter.ts", + ["expectedProductId: amazon.expectedProductId", "environment !== 'Production'"], + "React Native Vega IAPKit bridge", + ], + [ + "libraries/expo-iap/src/vega-adapter.ts", + ["expectedProductId: amazon.expectedProductId", "environment !== 'Production'"], + "Expo Vega IAPKit bridge", + ], + ]) { + expectIncludes(file, needles, label); + } + expectIncludes( + "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", + [ + "'expectedProductId':", + "environmentValue != 'Production'", + "environment: environmentValue as String?", + ], + "Flutter IAPKit Amazon contract", + ); + for (const file of [ + "libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt", + "libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift", + "libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift", + ]) { + expectIncludes( + file, + ["expectedProductId"], + `${file} Amazon product binding`, + ); + } + for (const file of [ + "libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift", + "libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift", + ]) { + expectNotIncludes( + file, + ["trimmedExpectedProductId"], + `${file} must preserve exact Amazon product ids`, + ); + } + expectIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt", + [ + "expectedProductId = amazon.expectedProductId", + "environment = androidResult.environment", + ], + "KMP Android IAPKit Amazon contract", + ); + expectIncludes( + "libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt", + ['"Sandbox", "Production"', "environment = environment"], + "KMP iOS IAPKit response contract", + ); +} + function checkApple() { const base = "packages/apple"; for (const file of [ @@ -4613,6 +4805,9 @@ function checkFrameworkDependencyHygiene() { "no-store liveness metadata", "public revision", "no Convex round-trip", + '{ store: "amazon", userId, receiptId, sandbox?, expectedProductId? }', + "sandbox requires the project's explicit", + '"environment": "Sandbox"', ], "Kit compact assistant contract must match authentication and safety SSOT", ); @@ -4626,7 +4821,7 @@ function checkFrameworkDependencyHygiene() { [ "github.com/hyodotdev/openiap/tree/main/packages/kit", ".github/workflows/deploy-kit.yml", - "Apple/Amazon: consumable ready for durable fulfillment", + "Apple, Amazon, or catalog-known Google consumable ready for durable fulfillment", "deploys additive Convex functions", '"apiVersion": "v1"', '"revision": "a1b2c3d4e5f6"', @@ -8608,6 +8803,7 @@ checkExpoRouterExample("libraries/expo-iap/example", "src/utils/constants.ts"); checkReactNativeClassic(); checkFlutter(); checkKmp(); +checkIapkitAmazonContractWiring(); checkApple(); checkGoogle(); checkMaui(); diff --git a/scripts/e2e-web-sites.mjs b/scripts/e2e-web-sites.mjs index a1e5fe2dc..529b1b634 100644 --- a/scripts/e2e-web-sites.mjs +++ b/scripts/e2e-web-sites.mjs @@ -1,5 +1,7 @@ #!/usr/bin/env node import { chromium, request as playwrightRequest } from "@playwright/test"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; const DEFAULT_TIMEOUT_MS = Number(process.env.WEB_E2E_TIMEOUT_MS ?? 30_000); const STRICT = process.env.WEB_E2E_STRICT === "1"; @@ -56,7 +58,7 @@ const SITES = [ }, ]; -const CONSOLE_IGNORE = [ +const CONSOLE_MESSAGE_IGNORE = [ /favicon/i, /ResizeObserver loop/i, /Failed to load resource.*analytics/i, @@ -64,6 +66,15 @@ const CONSOLE_IGNORE = [ /Failed to load resource.*mixpanel/i, ]; +const RESOURCE_URL_IGNORE = [ + /(?:^|\.)sentry\.io$/i, + /(?:^|\.)mixpanel\.com$/i, + /(?:^|\.)google-analytics\.com$/i, + /(?:^|\.)googletagmanager\.com$/i, + /^fonts\.googleapis\.com$/i, + /^fonts\.gstatic\.com$/i, +]; + const PERFORMANCE_BUDGETS = { domContentLoadedMs: Number(process.env.WEB_E2E_DCL_BUDGET_MS ?? 5_000), loadMs: Number(process.env.WEB_E2E_LOAD_BUDGET_MS ?? 10_000), @@ -120,7 +131,24 @@ function compact(value) { } function isIgnoredConsole(text) { - return CONSOLE_IGNORE.some((pattern) => pattern.test(text)); + return CONSOLE_MESSAGE_IGNORE.some((pattern) => pattern.test(text)); +} + +export function isIgnoredResourceUrl(value) { + try { + const url = new URL(value); + const basename = url.pathname.split("/").at(-1) ?? ""; + if (/^favicon(?:[-.].*)?$/i.test(basename)) return true; + return RESOURCE_URL_IGNORE.some((pattern) => pattern.test(url.hostname)); + } catch { + return false; + } +} + +function isHttpResourceConsoleError(text) { + return /^Failed to load resource: the server responded with a status of \d+/i.test( + text, + ); } function absoluteUrl(baseUrl, path) { @@ -166,8 +194,29 @@ async function collectPageErrors(page) { page.on("console", (message) => { if (message.type() !== "error") return; const text = message.text(); - if (!isIgnoredConsole(text)) { - errors.push(`console.error: ${text}`); + if (isIgnoredConsole(text)) return; + + // Chromium's HTTP resource error omits the URL from `message.text()`, + // which made harmless ignored assets and real broken assets + // indistinguishable (and produced route-random CI failures). The response + // listener below records the same failure with its exact URL and type. + if (isHttpResourceConsoleError(text)) return; + + const location = message.location(); + const source = location.url + ? ` (${location.url}:${location.lineNumber}:${location.columnNumber})` + : ""; + errors.push(`console.error: ${text}${source}`); + }); + + page.on("response", (response) => { + if (response.status() < 400) return; + + const request = response.request(); + const resourceType = request.resourceType(); + const url = response.url(); + if (!isIgnoredResourceUrl(url)) { + errors.push(`response ${response.status()} ${resourceType}: ${url}`); } }); @@ -175,7 +224,7 @@ async function collectPageErrors(page) { const url = request.url(); const resourceType = request.resourceType(); const failure = request.failure(); - if (["image", "stylesheet", "script", "document"].includes(resourceType)) { + if (!isIgnoredResourceUrl(url)) { errors.push( `requestfailed ${resourceType}: ${url} (${failure?.errorText ?? "unknown"})`, ); @@ -689,8 +738,14 @@ async function main() { console.log("web-e2e: docs and IAPKit passed"); } -main().catch((error) => { - console.error("web-e2e: unexpected failure"); - console.error(error); - process.exitCode = 1; -}); +const isMain = + process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); + +if (isMain) { + main().catch((error) => { + console.error("web-e2e: unexpected failure"); + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/e2e-web-sites.test.mjs b/scripts/e2e-web-sites.test.mjs new file mode 100644 index 000000000..58871b651 --- /dev/null +++ b/scripts/e2e-web-sites.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { isIgnoredResourceUrl } from "./e2e-web-sites.mjs"; + +describe("web E2E resource URL filtering", () => { + it("ignores only attributable favicon and explicit third-party URLs", () => { + assert.equal( + isIgnoredResourceUrl("https://kit.openiap.dev/favicon.ico"), + true, + ); + assert.equal( + isIgnoredResourceUrl("https://api-eu.mixpanel.com/track/?ip=1"), + true, + ); + assert.equal( + isIgnoredResourceUrl( + "https://o123.ingest.us.sentry.io/api/456/envelope/", + ), + true, + ); + assert.equal( + isIgnoredResourceUrl( + "https://fonts.gstatic.com/s/roboto/v51/missing.woff2", + ), + true, + ); + assert.equal( + isIgnoredResourceUrl( + "https://fonts.googleapis.com/css2?family=Roboto:wght@400", + ), + true, + ); + }); + + it("keeps same-origin and unrelated 404 URLs actionable", () => { + assert.equal( + isIgnoredResourceUrl("https://kit.openiap.dev/missing.js"), + false, + ); + assert.equal( + isIgnoredResourceUrl("https://kit.openiap.dev/docs/analytics/missing.js"), + false, + ); + assert.equal( + isIgnoredResourceUrl("https://cdn.example.test/missing.js"), + false, + ); + assert.equal( + isIgnoredResourceUrl("https://kit.openiap.dev/fonts/missing.woff2"), + false, + ); + }); +});