From 31581e81791722c36e1bdce6b95b7cf789e1ea7e Mon Sep 17 00:00:00 2001 From: Hyo Date: Tue, 11 Aug 2026 16:16:05 +0900 Subject: [PATCH 1/8] fix(kit): harden store verification Closes #310 Closes #311 Closes #312 --- .github/workflows/deploy-kit.yml | 11 +- codecov.yml | 34 +- knowledge/_claude-context/context.md | 14 +- knowledge/external/webhook-mapping.md | 12 +- packages/docs/public/llms-full.txt | 14 +- packages/docs/public/llms.txt | 2 +- packages/kit/CONVENTION.md | 2 +- packages/kit/README.md | 35 + packages/kit/convex/_generated/api.d.ts | 4 - packages/kit/convex/crons.ts | 24 +- packages/kit/convex/projects/mutation.ts | 7 + .../purchases/amazon-reconciliation.test.ts | 337 +++++++++ packages/kit/convex/purchases/amazon.test.ts | 665 +++++++++++++++++- packages/kit/convex/purchases/amazon.ts | 621 ++++++++++++---- packages/kit/convex/purchases/errors.ts | 9 + packages/kit/convex/purchases/horizon.test.ts | 312 +++++++- packages/kit/convex/purchases/horizon.ts | 177 +++-- packages/kit/convex/purchases/internal.ts | 170 +++++ .../save-purchase-idempotency.test.ts | 48 +- packages/kit/convex/purchases/shared.ts | 10 + packages/kit/convex/schema.ts | 47 +- packages/kit/convex/subscriptions/horizon.ts | 308 -------- .../subscriptions/horizonInternal.test.ts | 169 ----- .../convex/subscriptions/horizonInternal.ts | 310 -------- .../subscriptions/revenueMetrics.test.ts | 36 + .../convex/subscriptions/revenueMetrics.ts | 21 +- packages/kit/convex/subscriptions/stats.ts | 24 +- packages/kit/convex/webhooks/internal.ts | 1 - packages/kit/package.json | 2 +- packages/kit/public/llms-full.txt | 46 +- .../kit/server/api/v1/replay-guard.test.ts | 10 +- packages/kit/server/api/v1/replay-guard.ts | 17 +- .../server/api/v1/route-input-schemas.test.ts | 12 + .../kit/server/api/v1/route-input-schemas.ts | 3 +- .../api/v1/route-response-schemas.test.ts | 21 + .../server/api/v1/route-response-schemas.ts | 8 + packages/kit/server/api/v1/routes.test.ts | 107 +++ packages/kit/server/api/v1/routes.ts | 13 +- .../auth/organization/project/products.tsx | 26 +- .../organization/project/settings.test.tsx | 28 + .../auth/organization/project/settings.tsx | 52 +- .../auth/organization/project/webhooks.tsx | 2 +- packages/kit/src/pages/docs/sections/api.tsx | 42 +- .../src/pages/docs/sections/introduction.tsx | 4 +- .../src/pages/docs/sections/operations.tsx | 4 +- .../src/pages/docs/sections/quickstart.tsx | 13 +- .../docs/sections/verification-horizon.tsx | 27 +- scripts/assert-lcov-coverage.mjs | 66 +- scripts/assert-lcov-coverage.test.mjs | 87 +++ scripts/audit-non-godot-parity.mjs | 140 +++- 50 files changed, 2950 insertions(+), 1204 deletions(-) create mode 100644 packages/kit/convex/purchases/amazon-reconciliation.test.ts delete mode 100644 packages/kit/convex/subscriptions/horizon.ts delete mode 100644 packages/kit/convex/subscriptions/horizonInternal.test.ts delete mode 100644 packages/kit/convex/subscriptions/horizonInternal.ts 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..923bb7354 100644 --- a/codecov.yml +++ b/codecov.yml @@ -30,8 +30,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 +65,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 +87,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 +118,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 +137,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..0ff5310ca 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-11T07:10:55.303Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -4755,9 +4755,15 @@ 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 revisits active Amazon receipt rows within Amazon's 72-hour guidance; +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..7f5757a6d 100644 --- a/knowledge/external/webhook-mapping.md +++ b/knowledge/external/webhook-mapping.md @@ -81,6 +81,12 @@ 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 revisits active Amazon receipt rows within Amazon's 72-hour guidance; +it updates state only from authoritative RVS outcomes and preserves the last +confirmed state across transient or malformed responses. diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 7c29a587d..40bf75505 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-11T07:10:55.312Z ## Table of Contents 1. Installation @@ -2036,9 +2036,15 @@ 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 revisits active Amazon receipt rows within Amazon's 72-hour guidance; +it updates state only from authoritative RVS outcomes and preserves the last +confirmed state across transient or malformed responses. --- diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index 7515eb4ae..671cc0a5e 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-11T07:10:55.312Z ## Installation 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..64e9c5c22 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` 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/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/purchases/amazon-reconciliation.test.ts b/packages/kit/convex/purchases/amazon-reconciliation.test.ts new file mode 100644 index 000000000..9916ec1a6 --- /dev/null +++ b/packages/kit/convex/purchases/amazon-reconciliation.test.ts @@ -0,0 +1,337 @@ +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 } 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; + return ( + (typeof leftAt === "number" ? leftAt : -Infinity) - + (typeof rightAt === "number" ? rightAt : -Infinity) + ); + }), + ); + } + + async take(limit: number): Promise { + return this.rows.slice(0, limit); + } + + async first(): Promise { + return this.rows[0] ?? null; + } +} + +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([]); + }); +}); + +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, + 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..1539ccbeb 100644 --- a/packages/kit/convex/purchases/amazon.test.ts +++ b/packages/kit/convex/purchases/amazon.test.ts @@ -1,13 +1,70 @@ -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"; +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", () => { expect( @@ -28,60 +85,614 @@ 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); }); +}); + +describe("parseAmazonReceiptResponse", () => { + test("accepts a typed RVS object while retaining future extra fields", () => { + const raw = validReceipt({ futureField: "retained" }); + expect(parseAmazonReceiptResponse(raw)).toBe(raw); + }); - test("falls back to unknown for unrecognized product types", () => { - expect(mapAmazonReceiptState({ productType: "FUTURE_KIND" })).toBe( - HarmonizedPurchaseState.UNKNOWN, + 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("rejects non-object RVS responses", () => { - expect(() => parseAmazonReceiptResponse(null)).toThrow( + 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("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, + }), + ); + + for (const response of [ + () => Promise.reject(new TypeError("network down")), + () => Promise.resolve(new Response("invalid json")), + () => Promise.resolve(new Response("secret", { status: 496 })), + ]) { + 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: expect.any(Number), + }), + ); + expect(failed.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 () => { + 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" }), + ); + }); + + 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..1429c91cd 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,9 @@ import { retryOnTransient, } from "./retry"; import { + AMAZON_RECONCILE_BATCH_LIMIT, + AMAZON_RECONCILE_RETRY_MS, + applyExpectedProductId, getProjectByApiKey, isValidState, receiptResponseValidator, @@ -26,21 +31,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 +89,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 +132,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 +151,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 +268,190 @@ 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"); + } + 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 }, +): Promise { + await ctx.runMutation( + internal.purchases.internal.rescheduleAmazonPurchaseReconciliation, + { + purchaseId: probe.purchaseId, + claimedLeaseUntil: probe.leaseUntil, + retryAt: Date.now() + AMAZON_RECONCILE_RETRY_MS, + }, + ); +} + +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 +459,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 +480,174 @@ 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; + await rescheduleAmazonProbe(ctx, probe); + 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; + await rescheduleAmazonProbe(ctx, probe); + 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/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..c2d09364f 100644 --- a/packages/kit/convex/purchases/horizon.test.ts +++ b/packages/kit/convex/purchases/horizon.test.ts @@ -1,6 +1,314 @@ -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("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 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("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..b25c5227a 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,112 @@ 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) { + // 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 { + 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 +174,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 +245,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..5ed4fa584 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, @@ -18,12 +19,17 @@ import { type PurchaseStatsDelta, } from "./stats"; import { + AMAZON_RECONCILE_BATCH_LIMIT, + AMAZON_RECONCILE_INTERVAL_MS, + AMAZON_RECONCILE_LEASE_MS, extractOrderIdFromRemoteResponse, extractProductIdFromRemoteResponse, + isValidState, } from "./shared"; type PurchaseStore = Infer; type PurchaseRequestData = Infer; +type ReceiptEnvironment = Infer; export type SavePurchaseArgs = { ctx: MutationCtx; @@ -35,6 +41,7 @@ export type SavePurchaseArgs = { remoteResponse?: string; state: HarmonizedPurchaseState; isValid: boolean; + environment?: ReceiptEnvironment; requestIp?: string; verificationDurationMs?: number; }; @@ -49,6 +56,7 @@ export async function savePurchaseInternal({ remoteResponse, state, isValid, + environment, requestIp, verificationDurationMs, }: SavePurchaseArgs) { @@ -66,6 +74,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 +153,8 @@ export async function savePurchaseInternal({ state, isValid, updatedAt: now, + environment, + nextAmazonReconcileAt, productId, orderId, verificationDurationMs, @@ -197,6 +211,8 @@ export async function savePurchaseInternal({ state, isValid, updatedAt: now, + environment, + nextAmazonReconcileAt, productId, orderId, verificationDurationMs, @@ -219,6 +235,8 @@ 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, @@ -253,6 +271,8 @@ type PurchasePatchArgs = { state: HarmonizedPurchaseState; isValid: boolean; updatedAt: number; + environment?: ReceiptEnvironment; + nextAmazonReconcileAt?: number; productId: string | null; orderId: string | null; verificationDurationMs?: number; @@ -368,6 +388,12 @@ async function patchExistingPurchase( state: args.state, isValid: args.isValid, 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 @@ -445,6 +471,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 +488,151 @@ 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: leaseUntil }); + 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/save-purchase-idempotency.test.ts b/packages/kit/convex/purchases/save-purchase-idempotency.test.ts index d9f631a0e..c00deaa8a 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, }; } @@ -254,6 +264,40 @@ describe("savePurchaseInternal — idempotency regression guard", () => { expect(db.purchaseCount()).toBe(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 () => { await savePurchaseInternal({ ctx, 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/schema.ts b/packages/kit/convex/schema.ts index 733388c5a..a267872a9 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()), @@ -457,6 +468,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"], @@ -562,8 +578,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 +762,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..35500e2c1 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: @@ -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: diff --git a/packages/kit/server/api/v1/replay-guard.test.ts b/packages/kit/server/api/v1/replay-guard.test.ts index c4faba60c..1a8f87ae3 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); } diff --git a/packages/kit/server/api/v1/replay-guard.ts b/packages/kit/server/api/v1/replay-guard.ts index 5a3b8c696..dbb3cb93e 100644 --- a/packages/kit/server/api/v1/replay-guard.ts +++ b/packages/kit/server/api/v1/replay-guard.ts @@ -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)}`; 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..b14b5333c 100644 --- a/packages/kit/server/api/v1/route-input-schemas.test.ts +++ b/packages/kit/server/api/v1/route-input-schemas.test.ts @@ -115,10 +115,22 @@ describe("verifyPurchaseInputSchema", () => { 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..93855d902 100644 --- a/packages/kit/server/api/v1/route-input-schemas.ts +++ b/packages/kit/server/api/v1/route-input-schemas.ts @@ -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/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index f941dca24..047a2f318 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/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. */}
+ +
@@ -98,7 +98,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..6415f2f2b 100644 --- a/packages/kit/src/pages/docs/sections/quickstart.tsx +++ b/packages/kit/src/pages/docs/sections/quickstart.tsx @@ -176,17 +176,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..38b825512 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 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..48a7cfde9 100644 --- a/scripts/assert-lcov-coverage.test.mjs +++ b/scripts/assert-lcov-coverage.test.mjs @@ -33,6 +33,85 @@ 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("accepts the exact minimum and rejects lower coverage", () => { assert.doesNotThrow(() => assertLcovLineCoverage(report("LF:10\nLH:9\n"), 90), @@ -52,5 +131,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..9a3987a85 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -596,19 +596,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 +818,16 @@ function checkFrameworkCiAndCoverageBadges() { if (exists("codecov.yml")) { const codecovConfig = read("codecov.yml"); - const expectedComponentIds = contracts.map( + 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 +836,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 +909,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 +958,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`); From 58c04541dc36bf1feb5a2367ba93bfd1cdca2f5e Mon Sep 17 00:00:00 2001 From: Hyo Date: Tue, 11 Aug 2026 16:54:20 +0900 Subject: [PATCH 2/8] fix(kit): contain project tab overflow --- .../auth/organization/Sidebar/Tablet.tsx | 2 +- .../pages/auth/organization/index.test.tsx | 88 +++++++++++ .../kit/src/pages/auth/organization/index.tsx | 8 +- .../auth/organization/project/index.test.tsx | 144 ++++++++++++++++++ .../pages/auth/organization/project/index.tsx | 43 +++++- 5 files changed, 274 insertions(+), 11 deletions(-) create mode 100644 packages/kit/src/pages/auth/organization/index.test.tsx create mode 100644 packages/kit/src/pages/auth/organization/project/index.test.tsx 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"; + vi.restoreAllMocks(); + }); + + 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..bb63de634 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) { @@ -217,17 +236,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 */} -
    +
    From aca19343029356889492ded489fd4e90695634fe Mon Sep 17 00:00:00 2001 From: Hyo Date: Tue, 11 Aug 2026 17:05:17 +0900 Subject: [PATCH 3/8] test: report web e2e resource urls --- scripts/e2e-web-sites.mjs | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/scripts/e2e-web-sites.mjs b/scripts/e2e-web-sites.mjs index a1e5fe2dc..745013152 100644 --- a/scripts/e2e-web-sites.mjs +++ b/scripts/e2e-web-sites.mjs @@ -123,6 +123,12 @@ function isIgnoredConsole(text) { return CONSOLE_IGNORE.some((pattern) => pattern.test(text)); } +function isHttpResourceConsoleError(text) { + return /^Failed to load resource: the server responded with a status of \d+/i.test( + text, + ); +} + function absoluteUrl(baseUrl, path) { return new URL(path, `${baseUrl}/`).toString(); } @@ -166,8 +172,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 (!isIgnoredConsole(url)) { + errors.push(`response ${response.status()} ${resourceType}: ${url}`); } }); @@ -175,7 +202,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 (!isIgnoredConsole(url)) { errors.push( `requestfailed ${resourceType}: ${url} (${failure?.errorText ?? "unknown"})`, ); From bd2242a5241dc9b8e7cd3ad1fa37a6a4e9128c9a Mon Sep 17 00:00:00 2001 From: Hyo Date: Tue, 11 Aug 2026 20:57:26 +0900 Subject: [PATCH 4/8] fix: harden store verification and dashboard UX --- knowledge/_claude-context/context.md | 10 +- knowledge/external/webhook-mapping.md | 8 +- libraries/expo-iap/example/.env.example | 3 + libraries/expo-iap/example/README.md | 2 +- .../example/__tests__/purchase-flow.test.tsx | 461 ++++++++++- .../__tests__/subscription-flow.test.tsx | 512 +++++++++++- .../example/__tests__/vega-runtime.test.ts | 135 ++++ libraries/expo-iap/example/app.config.ts | 1 + .../expo-iap/example/app/purchase-flow.tsx | 508 +++++++----- .../example/app/subscription-flow.tsx | 740 ++++++++++-------- .../example/scripts/build-vega-example.mjs | 10 +- .../example/scripts/vega-build-config.mjs | 1 + .../scripts/vega-build-config.test.mjs | 6 + .../expo-iap/example/src/utils/vegaRuntime.ts | 110 ++- .../expo-iap/src/__tests__/index.test.ts | 16 +- .../src/__tests__/vega-adapter.test.ts | 44 +- libraries/expo-iap/src/index.ts | 4 +- libraries/expo-iap/src/types.ts | 11 + libraries/expo-iap/src/vega-adapter.ts | 15 + .../AndroidInappPurchasePlugin.kt | 3 + .../FlutterInappPurchasePlugin.swift | 3 + .../lib/flutter_inapp_purchase.dart | 15 + .../flutter_inapp_purchase/lib/types.dart | 13 + .../FlutterInappPurchasePlugin.swift | 3 + .../flutter_inapp_purchase_channel_test.dart | 45 ++ .../Example/tests/test_types_only.gd | 41 + libraries/godot-iap/addons/godot-iap/types.gd | 12 + .../hyochan/kmpiap/InAppPurchaseAndroid.kt | 2 + .../hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt | 15 + .../io/github/hyochan/kmpiap/openiap/Types.kt | 53 +- .../github/hyochan/kmpiap/VerificationTest.kt | 20 + .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 8 + .../Utils/IapKitSettings.cs | 1 + libraries/maui-iap/src/OpenIap.Maui/Types.cs | 13 + .../OpenIap.Maui.ContractTests/Program.cs | 35 + .../OpenIap.Maui.Tests/RecordJsonTests.cs | 34 + .../java/com/margelo/nitro/iap/HybridRnIap.kt | 4 + .../react-native-iap/example/.env.example | 3 + libraries/react-native-iap/example/README.md | 2 +- .../__tests__/screens/PurchaseFlow.test.tsx | 21 +- .../screens/SubscriptionFlow.test.tsx | 1 + .../__tests__/utils/vegaRuntime.test.ts | 109 ++- .../react-native-iap/example/jest.setup.js | 1 + .../example/screens/PurchaseFlow.tsx | 7 +- .../example/screens/SubscriptionFlow.tsx | 7 +- .../example/scripts/build-vega-example.mjs | 4 +- .../example/src/types/env.d.ts | 1 + .../example/src/utils/vegaRuntime.ts | 48 +- .../react-native-iap/ios/HybridRnIap.swift | 4 + .../__tests__/iapkit-base-url-bridge.test.js | 31 +- .../src/__tests__/index.test.ts | 4 + .../src/__tests__/vega-adapter.test.ts | 39 + libraries/react-native-iap/src/index.ts | 7 +- .../react-native-iap/src/specs/RnIap.nitro.ts | 4 + libraries/react-native-iap/src/types.ts | 11 + .../react-native-iap/src/vega-adapter.ts | 15 + packages/apple/Sources/Models/Types.swift | 9 + packages/apple/Sources/OpenIapModule.swift | 63 +- .../VerifyPurchaseWithProviderTests.swift | 38 + packages/docs/public/llms-full.txt | 29 +- packages/docs/public/llms.txt | 2 +- .../docs/src/pages/docs/examples/fireos.tsx | 14 +- .../docs/src/pages/docs/examples/index.tsx | 20 +- .../docs/src/pages/docs/features/purchase.tsx | 35 +- .../src/pages/docs/features/validation.tsx | 13 +- packages/docs/src/pages/docs/kit-backend.tsx | 45 +- .../src/pages/docs/setup/store/amazon.tsx | 25 +- .../verify-purchase-with-provider-props.tsx | 19 +- .../verify-purchase-with-provider-result.tsx | 21 +- .../java/dev/hyo/openiap/OpenIapModule.kt | 22 +- .../src/main/java/dev/hyo/openiap/Types.kt | 53 +- .../utils/PurchaseVerificationValidator.kt | 9 + .../PurchaseVerificationValidatorTest.kt | 64 +- .../hyo/openiap/AmazonIapkitOptionsTest.kt | 33 + packages/gql/codegen/plugins/kotlin.ts | 102 ++- .../gql/src/generated-compatibility.test.ts | 24 + packages/gql/src/generated/Types.cs | 13 + packages/gql/src/generated/Types.kt | 53 +- packages/gql/src/generated/Types.swift | 9 + packages/gql/src/generated/types.dart | 13 + packages/gql/src/generated/types.gd | 12 + packages/gql/src/generated/types.ts | 11 + packages/gql/src/type.graphql | 11 + packages/kit/README.md | 34 + packages/kit/convex/migrations.ts | 83 +- .../project-child-pending-deletion.test.ts | 2 + .../kit/convex/projects/setupStatus.test.ts | 81 ++ packages/kit/convex/projects/setupStatus.ts | 8 +- .../purchases/amazon-reconciliation.test.ts | 56 +- packages/kit/convex/purchases/amazon.test.ts | 35 +- packages/kit/convex/purchases/amazon.ts | 20 +- packages/kit/convex/purchases/cleanup.test.ts | 68 ++ packages/kit/convex/purchases/cleanup.ts | 56 +- packages/kit/convex/purchases/horizon.test.ts | 87 ++ packages/kit/convex/purchases/horizon.ts | 17 +- packages/kit/convex/purchases/internal.ts | 66 +- packages/kit/convex/purchases/mutation.ts | 32 +- packages/kit/convex/purchases/query.ts | 3 +- .../save-purchase-idempotency.test.ts | 92 ++- .../purchases/stats-integration.test.ts | 325 +++++++- packages/kit/convex/purchases/stats.test.ts | 122 ++- packages/kit/convex/purchases/stats.ts | 109 ++- packages/kit/convex/schema.ts | 8 + packages/kit/public/llms-full.txt | 4 +- packages/kit/public/llms.txt | 19 +- .../kit/server/api/v1/replay-guard.test.ts | 30 +- packages/kit/server/api/v1/replay-guard.ts | 16 +- .../server/api/v1/route-input-schemas.test.ts | 9 + .../kit/server/api/v1/route-input-schemas.ts | 10 +- .../auth/organization/project/index.test.tsx | 33 + .../pages/auth/organization/project/index.tsx | 32 +- .../auth/organization/project/products.tsx | 4 +- .../organization/project/purchases.test.tsx | 124 +++ .../auth/organization/project/purchases.tsx | 101 +-- .../project/subscriptions.test.tsx | 67 ++ .../organization/project/subscriptions.tsx | 94 +-- .../organization/project/webhooks.test.tsx | 52 ++ .../src/pages/docs/sections/introduction.tsx | 5 +- .../src/pages/docs/sections/quickstart.tsx | 7 + scripts/assert-lcov-coverage.mjs | 4 +- scripts/assert-lcov-coverage.test.mjs | 24 + scripts/audit-non-godot-parity.mjs | 118 ++- scripts/e2e-web-sites.mjs | 44 +- scripts/e2e-web-sites.test.mjs | 38 + 124 files changed, 5229 insertions(+), 908 deletions(-) create mode 100644 packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonIapkitOptionsTest.kt create mode 100644 packages/kit/convex/projects/setupStatus.test.ts create mode 100644 packages/kit/src/pages/auth/organization/project/purchases.test.tsx create mode 100644 packages/kit/src/pages/auth/organization/project/subscriptions.test.tsx create mode 100644 packages/kit/src/pages/auth/organization/project/webhooks.test.tsx create mode 100644 scripts/e2e-web-sites.test.mjs diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index 0ff5310ca..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-11T07:10:55.303Z +> Last updated: 2026-08-11T08:49:51.526Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -4761,9 +4761,11 @@ Meta Horizon has no inbound webhook or background lifecycle lane in IAPKit. 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 revisits active Amazon receipt rows within Amazon's 72-hour guidance; -it updates state only from authoritative RVS outcomes and preserves the last -confirmed state across transient or malformed responses. +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 7f5757a6d..443272b01 100644 --- a/knowledge/external/webhook-mapping.md +++ b/knowledge/external/webhook-mapping.md @@ -87,6 +87,8 @@ Meta Horizon has no inbound webhook or background lifecycle lane in IAPKit. 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 revisits active Amazon receipt rows within Amazon's 72-hour guidance; -it updates state only from authoritative RVS outcomes and preserves the last -confirmed state across transient or malformed responses. +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..0dfe0771a 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 @@ -2817,6 +2817,7 @@ void main() { 'isValid': true, 'state': 'entitled', 'store': 'amazon', + 'environment': 'Sandbox', }, }); } @@ -2834,6 +2835,7 @@ void main() { iapkit: const types.RequestVerifyPurchaseWithIapkitProps( apiKey: 'test-api-key', amazon: types.RequestVerifyPurchaseWithIapkitAmazonProps( + expectedProductId: 'dev.hyo.martie.10bulbs', receiptId: 'amzn1.receipt.test', sandbox: true, userId: 'amzn1.account.test', @@ -2857,11 +2859,16 @@ 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); }); @@ -3123,5 +3130,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 40bf75505..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-11T07:10:55.312Z +> Generated: 2026-08-11T09:33:24.495Z ## Table of Contents 1. Installation @@ -2042,9 +2042,11 @@ Meta Horizon has no inbound webhook or background lifecycle lane in IAPKit. 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 revisits active Amazon receipt rows within Amazon's 72-hour guidance; -it updates state only from authoritative RVS outcomes and preserves the last -confirmed state across transient or malformed responses. +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. --- @@ -2053,7 +2055,7 @@ confirmed state across transient or malformed responses. > 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, @@ -2110,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 671cc0a5e..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-11T07:10:55.312Z +> 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/README.md b/packages/kit/README.md index 64e9c5c22..bcf887d2a 100644 --- a/packages/kit/README.md +++ b/packages/kit/README.md @@ -564,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/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/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 index 9916ec1a6..8706ee2c6 100644 --- a/packages/kit/convex/purchases/amazon-reconciliation.test.ts +++ b/packages/kit/convex/purchases/amazon-reconciliation.test.ts @@ -7,7 +7,7 @@ import { rescheduleAmazonPurchaseReconciliation, } from "./internal"; import { HarmonizedPurchaseState } from "./purchaseState"; -import { AMAZON_RECONCILE_LEASE_MS } from "./shared"; +import { AMAZON_RECONCILE_LEASE_MS, AMAZON_RECONCILE_RETRY_MS } from "./shared"; type Row = Record & { _id: string }; @@ -48,15 +48,15 @@ class MemQuery { ); } - order(_direction: "asc" | "desc"): MemQuery { + order(direction: "asc" | "desc"): MemQuery { return new MemQuery( [...this.rows].sort((left, right) => { const leftAt = left.nextAmazonReconcileAt; const rightAt = right.nextAmazonReconcileAt; - return ( + const comparison = (typeof leftAt === "number" ? leftAt : -Infinity) - - (typeof rightAt === "number" ? rightAt : -Infinity) - ); + (typeof rightAt === "number" ? rightAt : -Infinity); + return direction === "asc" ? comparison : -comparison; }), ); } @@ -70,6 +70,27 @@ class MemQuery { } } +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; @@ -196,6 +217,30 @@ describe("claimAmazonPurchasesForReconciliation", () => { ); 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", () => { @@ -302,6 +347,7 @@ describe("Amazon reconciliation compare-and-set mutations", () => { }), state: HarmonizedPurchaseState.ENTITLED, isValid: true, + statsCounted: true, nextAmazonReconcileAt: 10_000, }); const handler = testableFunction(applyAmazonReconciliationVerdict)._handler; diff --git a/packages/kit/convex/purchases/amazon.test.ts b/packages/kit/convex/purchases/amazon.test.ts index 1539ccbeb..4e6e51599 100644 --- a/packages/kit/convex/purchases/amazon.test.ts +++ b/packages/kit/convex/purchases/amazon.test.ts @@ -17,6 +17,10 @@ import { 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"; @@ -543,10 +547,10 @@ describe("Amazon purchase reconciler", () => { }), ); + vi.useFakeTimers({ now: 20_000 }); for (const response of [ () => Promise.reject(new TypeError("network down")), () => Promise.resolve(new Response("invalid json")), - () => Promise.resolve(new Response("secret", { status: 496 })), ]) { const failed = reconcileContext([probe()]); vi.stubGlobal("fetch", vi.fn().mockImplementation(response)); @@ -557,13 +561,34 @@ describe("Amazon purchase reconciler", () => { expect.objectContaining({ purchaseId: "purchases_due", claimedLeaseUntil: 10_000, - retryAt: expect.any(Number), + 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", @@ -623,6 +648,7 @@ describe("Amazon purchase reconciler", () => { }); test("reschedules a disabled sandbox row without contacting Amazon", async () => { + vi.useFakeTimers({ now: 30_000 }); const { ctx, runMutation } = reconcileContext([ probe({ requestData: { @@ -643,7 +669,10 @@ describe("Amazon purchase reconciler", () => { ).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" }), + expect.objectContaining({ + purchaseId: "purchases_due", + retryAt: 30_000 + AMAZON_RECONCILE_INTERVAL_MS, + }), ); }); diff --git a/packages/kit/convex/purchases/amazon.ts b/packages/kit/convex/purchases/amazon.ts index 1429c91cd..0611dfe6c 100644 --- a/packages/kit/convex/purchases/amazon.ts +++ b/packages/kit/convex/purchases/amazon.ts @@ -20,6 +20,7 @@ import { } from "./retry"; import { AMAZON_RECONCILE_BATCH_LIMIT, + AMAZON_RECONCILE_INTERVAL_MS, AMAZON_RECONCILE_RETRY_MS, applyExpectedProductId, getProjectByApiKey, @@ -328,7 +329,9 @@ async function requestAmazonReceipt(args: { ); } if (response.status === 496) { - throw new AmazonReceiptVerificationError("invalid shared secret"); + throw new AmazonReceiptVerificationError("invalid shared secret", { + status: 496, + }); } if (!response.ok) { const error = new Error( @@ -392,13 +395,14 @@ async function persistAmazonVerdict( 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() + AMAZON_RECONCILE_RETRY_MS, + retryAt: Date.now() + delayMs, }, ); } @@ -593,7 +597,10 @@ export const reconcileAmazonPurchases = internalAction({ }); } catch (error) { failures += 1; - await rescheduleAmazonProbe(ctx, probe); + // 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, @@ -639,7 +646,12 @@ export const reconcileAmazonPurchases = internalAction({ } failures += 1; - await rescheduleAmazonProbe(ctx, probe); + 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, 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/horizon.test.ts b/packages/kit/convex/purchases/horizon.test.ts index c2d09364f..12632392a 100644 --- a/packages/kit/convex/purchases/horizon.test.ts +++ b/packages/kit/convex/purchases/horizon.test.ts @@ -224,6 +224,33 @@ describe("verifyMetaHorizonReceiptInternalV1", () => { 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 @@ -248,6 +275,31 @@ describe("verifyMetaHorizonReceiptInternalV1", () => { 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( @@ -278,6 +330,41 @@ describe("verifyMetaHorizonReceiptInternalV1", () => { 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 })); diff --git a/packages/kit/convex/purchases/horizon.ts b/packages/kit/convex/purchases/horizon.ts index b25c5227a..e1dcab59f 100644 --- a/packages/kit/convex/purchases/horizon.ts +++ b/packages/kit/convex/purchases/horizon.ts @@ -114,6 +114,15 @@ async function requestHorizonVerification( }); 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( @@ -126,7 +135,13 @@ async function requestHorizonVerification( let responseBody: unknown; try { responseBody = (await response.json()) as unknown; - } catch { + } 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.", ); diff --git a/packages/kit/convex/purchases/internal.ts b/packages/kit/convex/purchases/internal.ts index 5ed4fa584..71ad4ed8c 100644 --- a/packages/kit/convex/purchases/internal.ts +++ b/packages/kit/convex/purchases/internal.ts @@ -14,14 +14,18 @@ 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, @@ -240,6 +244,9 @@ export async function savePurchaseInternal({ // 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 } : {}), @@ -297,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, + ); } /** @@ -327,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">, @@ -358,6 +341,8 @@ async function patchExistingPurchase( store: PurchaseStore; isValid?: boolean; orderId?: string; + statsCounted?: boolean; + storeStatsCounted?: boolean; }, args: PurchasePatchArgs, extraDelta: PurchaseStatsDelta = {}, @@ -379,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, @@ -387,6 +383,8 @@ 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 } @@ -416,7 +414,7 @@ async function patchExistingPurchase( return await applyPurchaseStatsDelta( ctx, projectId, - mergeStatsDeltas(patchDelta, extraDelta), + mergePurchaseStatsDeltas(legacyStatsDelta, patchDelta, extraDelta), ); } @@ -541,7 +539,9 @@ export const claimAmazonPurchasesForReconciliation = internalMutation({ ) { // 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: leaseUntil }); + await ctx.db.patch(purchase._id, { + nextAmazonReconcileAt: now + AMAZON_RECONCILE_RETRY_MS, + }); continue; } 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 c00deaa8a..587120dff 100644 --- a/packages/kit/convex/purchases/save-purchase-idempotency.test.ts +++ b/packages/kit/convex/purchases/save-purchase-idempotency.test.ts @@ -262,6 +262,62 @@ 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 () => { @@ -638,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, @@ -651,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({ @@ -660,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/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 a267872a9..a8f3e7c4f 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -454,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"]) @@ -490,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 diff --git a/packages/kit/public/llms-full.txt b/packages/kit/public/llms-full.txt index 35500e2c1..1edd19dfa 100644 --- a/packages/kit/public/llms-full.txt +++ b/packages/kit/public/llms-full.txt @@ -283,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 | @@ -445,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 1a8f87ae3..491660e1c 100644 --- a/packages/kit/server/api/v1/replay-guard.test.ts +++ b/packages/kit/server/api/v1/replay-guard.test.ts @@ -297,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; @@ -313,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], @@ -331,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, })); } @@ -368,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 dbb3cb93e..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 @@ -404,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 b14b5333c..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,6 +109,15 @@ 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", diff --git a/packages/kit/server/api/v1/route-input-schemas.ts b/packages/kit/server/api/v1/route-input-schemas.ts index 93855d902..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.", diff --git a/packages/kit/src/pages/auth/organization/project/index.test.tsx b/packages/kit/src/pages/auth/organization/project/index.test.tsx index de7545b03..23961fe28 100644 --- a/packages/kit/src/pages/auth/organization/project/index.test.tsx +++ b/packages/kit/src/pages/auth/organization/project/index.test.tsx @@ -54,9 +54,42 @@ describe("ProjectIndex responsive tabs", () => { 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(); diff --git a/packages/kit/src/pages/auth/organization/project/index.tsx b/packages/kit/src/pages/auth/organization/project/index.tsx index bb63de634..1e3b047a2 100644 --- a/packages/kit/src/pages/auth/organization/project/index.tsx +++ b/packages/kit/src/pages/auth/organization/project/index.tsx @@ -208,27 +208,41 @@ export default function ProjectIndex() { {/* Header */}
    -
    +
    -
    -
    +
    +
    -
    -
    -

    {project.name}

    +
    +
    +

    + {project.name} +

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

    +

    {orgSlug}/{project.slug}

    diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index 047a2f318..8fe265c9e 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -1128,7 +1128,7 @@ function DryRunButton({ // background subscription tracking. function HorizonCatalogNotice() { return ( -
    +
    Horizon catalog sync is not supported
    @@ -1142,7 +1142,7 @@ function HorizonCatalogNotice() { href="https://developers.meta.com/horizon/documentation/native/ps-iap-s2s/" target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1 underline hover:text-blue-100" + className="inline-flex items-center gap-1 underline hover:text-blue-800 dark:hover:text-blue-100" > 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/subscriptions.test.tsx b/packages/kit/src/pages/auth/organization/project/subscriptions.test.tsx new file mode 100644 index 000000000..a090a025c --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/subscriptions.test.tsx @@ -0,0 +1,67 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ + project: { + _id: "projects_test", + organizationId: "organizations_test", + name: "Martie", + slug: "martie", + reportingCurrency: "USD", + }, + metrics: { + reportingCurrency: "USD", + mrrMicros: 0, + mrrByCurrency: [], + excludedMrrByCurrency: [], + activeSubs: 1, + inGracePeriod: 0, + inBillingRetry: 0, + refunded30d: 0, + canceled30d: 0, + }, + subscriptions: { + total: 1, + items: [], + }, +})); + +vi.mock("react-router-dom", () => ({ + useOutletContext: () => ({ project: mocks.project }), +})); + +vi.mock("convex/react", () => ({ + useQuery: (reference: string) => + reference === "subscriptions.metricsSummary" + ? mocks.metrics + : mocks.subscriptions, +})); + +vi.mock("@/convex", () => ({ + api: { + subscriptions: { + query: { + metricsSummary: "subscriptions.metricsSummary", + listSubscriptions: "subscriptions.listSubscriptions", + }, + }, + }, +})); + +import ProjectSubscriptions from "./subscriptions"; + +describe("ProjectSubscriptions responsive metrics", () => { + afterEach(cleanup); + + it("stacks secondary metric cards before the small breakpoint", () => { + render(); + + const refundedLabel = screen.getByText("Refunded (30d)"); + const secondaryMetrics = refundedLabel.parentElement?.parentElement; + + expect(secondaryMetrics?.classList.contains("grid-cols-1")).toBe(true); + expect(secondaryMetrics?.classList.contains("sm:grid-cols-3")).toBe(true); + expect(secondaryMetrics?.classList.contains("grid-cols-3")).toBe(false); + }); +}); diff --git a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx index 524611db0..d96ca7fb2 100644 --- a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx +++ b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx @@ -140,7 +140,7 @@ export default function ProjectSubscriptions() {
    )} -
    +
    ))}
    - - - - - - - - - - - - - {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/docs/sections/introduction.tsx b/packages/kit/src/pages/docs/sections/introduction.tsx index b69a0e9f6..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

    diff --git a/packages/kit/src/pages/docs/sections/quickstart.tsx b/packages/kit/src/pages/docs/sections/quickstart.tsx index 6415f2f2b..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

    diff --git a/scripts/assert-lcov-coverage.mjs b/scripts/assert-lcov-coverage.mjs index 38b825512..6de674eae 100644 --- a/scripts/assert-lcov-coverage.mjs +++ b/scripts/assert-lcov-coverage.mjs @@ -5,8 +5,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; function sourcePathSegments(sourcePath) { - return sourcePath - .replaceAll("\\", "/") + return path.posix + .normalize(sourcePath.replaceAll("\\", "/")) .split("/") .filter((segment) => segment !== "" && segment !== "."); } diff --git a/scripts/assert-lcov-coverage.test.mjs b/scripts/assert-lcov-coverage.test.mjs index 48a7cfde9..3d772ef22 100644 --- a/scripts/assert-lcov-coverage.test.mjs +++ b/scripts/assert-lcov-coverage.test.mjs @@ -112,6 +112,30 @@ describe("LCOV line coverage guard", () => { ); }); + 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), diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 9a3987a85..802158e82 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, [ @@ -2325,6 +2330,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 [ @@ -4683,6 +4795,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", ); @@ -4696,7 +4811,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"', @@ -8678,6 +8793,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 745013152..db019fe63 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,13 @@ 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, +]; + 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 +129,18 @@ 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) { @@ -193,7 +213,7 @@ async function collectPageErrors(page) { const request = response.request(); const resourceType = request.resourceType(); const url = response.url(); - if (!isIgnoredConsole(url)) { + if (!isIgnoredResourceUrl(url)) { errors.push(`response ${response.status()} ${resourceType}: ${url}`); } }); @@ -202,7 +222,7 @@ async function collectPageErrors(page) { const url = request.url(); const resourceType = request.resourceType(); const failure = request.failure(); - if (!isIgnoredConsole(url)) { + if (!isIgnoredResourceUrl(url)) { errors.push( `requestfailed ${resourceType}: ${url} (${failure?.errorText ?? "unknown"})`, ); @@ -716,8 +736,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..dd921f7be --- /dev/null +++ b/scripts/e2e-web-sites.test.mjs @@ -0,0 +1,38 @@ +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 third-party telemetry 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, + ); + }); + + 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, + ); + }); +}); From 76ad2e94b55cb68e7c5ece113dc878af6fcb9f9f Mon Sep 17 00:00:00 2001 From: Hyo Date: Tue, 11 Aug 2026 21:12:34 +0900 Subject: [PATCH 5/8] fix: isolate web E2E from Google Fonts outages --- scripts/e2e-web-sites.mjs | 2 ++ scripts/e2e-web-sites.test.mjs | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/scripts/e2e-web-sites.mjs b/scripts/e2e-web-sites.mjs index db019fe63..529b1b634 100644 --- a/scripts/e2e-web-sites.mjs +++ b/scripts/e2e-web-sites.mjs @@ -71,6 +71,8 @@ const RESOURCE_URL_IGNORE = [ /(?:^|\.)mixpanel\.com$/i, /(?:^|\.)google-analytics\.com$/i, /(?:^|\.)googletagmanager\.com$/i, + /^fonts\.googleapis\.com$/i, + /^fonts\.gstatic\.com$/i, ]; const PERFORMANCE_BUDGETS = { diff --git a/scripts/e2e-web-sites.test.mjs b/scripts/e2e-web-sites.test.mjs index dd921f7be..58871b651 100644 --- a/scripts/e2e-web-sites.test.mjs +++ b/scripts/e2e-web-sites.test.mjs @@ -4,7 +4,7 @@ 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 third-party telemetry URLs", () => { + it("ignores only attributable favicon and explicit third-party URLs", () => { assert.equal( isIgnoredResourceUrl("https://kit.openiap.dev/favicon.ico"), true, @@ -19,6 +19,18 @@ describe("web E2E resource URL filtering", () => { ), 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", () => { @@ -34,5 +46,9 @@ describe("web E2E resource URL filtering", () => { isIgnoredResourceUrl("https://cdn.example.test/missing.js"), false, ); + assert.equal( + isIgnoredResourceUrl("https://kit.openiap.dev/fonts/missing.woff2"), + false, + ); }); }); From 3e75b145eee2d7a297a5a3c6eeb07e3d6b8a22e9 Mon Sep 17 00:00:00 2001 From: Hyo Date: Tue, 11 Aug 2026 23:39:39 +0900 Subject: [PATCH 6/8] docs: add pr dashboard preview --- .../pr-313-kit-store-verification-ui.mp4 | Bin 0 -> 39403 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/pr-previews/pr-313-kit-store-verification-ui.mp4 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 0000000000000000000000000000000000000000..8bd41c682d87d0beba4801c35fc7ffe1423eb74a GIT binary patch literal 39403 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 literal 0 HcmV?d00001 From 136ee634e78030bcbb2238d8be3099023374491b Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 12 Aug 2026 00:13:59 +0900 Subject: [PATCH 7/8] test(flutter): cover production environment --- .../flutter_inapp_purchase_channel_test.dart | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) 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 0dfe0771a..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,7 +2818,7 @@ void main() { 'isValid': true, 'state': 'entitled', 'store': 'amazon', - 'environment': 'Sandbox', + 'environment': environment, }, }); } @@ -2830,17 +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( - expectedProductId: 'dev.hyo.martie.10bulbs', - receiptId: 'amzn1.receipt.test', - sandbox: true, - userId: 'amzn1.account.test', - ), - ), + iapkit: verificationRequest, ); final verifyCall = calls.singleWhere( @@ -2871,6 +2873,13 @@ void main() { 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 { From f8a763c7538ab84d15cac68278ba1fd5a91ddfc4 Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 12 Aug 2026 00:24:17 +0900 Subject: [PATCH 8/8] fix(ci): align codecov lcov line gates --- codecov.yml | 4 ++++ scripts/audit-non-godot-parity.mjs | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/codecov.yml b/codecov.yml index 923bb7354..0cea4f369 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,7 @@ +parsers: + lcov: + partials_as_hits: true + coverage: precision: 2 round: down diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 802158e82..ae5ff6919 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -823,6 +823,16 @@ function checkFrameworkCiAndCoverageBadges() { if (exists("codecov.yml")) { const codecovConfig = read("codecov.yml"); + 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, );