diff --git a/.claude/launch.json b/.claude/launch.json index 6c65ba6ae..f76177c14 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,20 @@ "runtimeExecutable": "bun", "runtimeArgs": ["run", "--cwd", "packages/docs", "dev"], "port": 5173 + }, + { + "name": "kit-dashboard", + "runtimeExecutable": "bun", + "runtimeArgs": [ + "run", + "--cwd", + "packages/kit", + "vite", + "--port", + "5173", + "--strictPort" + ], + "port": 5173 } ] } diff --git a/.github/workflows/deploy-kit.yml b/.github/workflows/deploy-kit.yml index 48ca3d2a7..c788e1a97 100644 --- a/.github/workflows/deploy-kit.yml +++ b/.github/workflows/deploy-kit.yml @@ -9,12 +9,17 @@ on: branches: [main] paths: - "packages/kit/**" + # kit.openiap.dev/mcp is served by kit's Fly binary importing + # @hyodotdev/openiap-mcp-server/web straight from source, so an + # MCP-server change must redeploy kit or it never ships (issue #287). + - "packages/mcp-server/**" - ".github/workflows/deploy-kit.yml" - "bun.lock" - "package.json" pull_request: paths: - "packages/kit/**" + - "packages/mcp-server/**" - ".github/workflows/deploy-kit.yml" - "bun.lock" - "package.json" @@ -56,6 +61,15 @@ jobs: - name: Run tests (convex + server unit tests) run: bun run test + - name: Lint + test MCP server (served by kit's /mcp route) + # kit's Fly binary imports @hyodotdev/openiap-mcp-server/web from + # source, so its regressions ship with kit deploys. This workflow + # is the only CI that runs the MCP server's own suite. + working-directory: packages/mcp-server + run: | + bun run lint + bun run test + - name: Vite build env: VITE_KIT_CONVEX_URL: https://placeholder-build-1.convex.cloud diff --git a/.husky/pre-commit b/.husky/pre-commit index 592ca18e7..2b5e28341 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -68,7 +68,12 @@ node scripts/audit-non-godot-parity.mjs # Cost: roughly 30-60s on first run after a clean checkout, ~15-20s on # warm checkouts (lint + tests + smoke). If you really need to bypass, # fix the underlying issue rather than passing --no-verify. -if git diff --cached --name-only --diff-filter=ACMR | grep -q '^packages/kit/'; then +# `packages/mcp-server` is compiled into kit's Fly binary and served at +# kit.openiap.dev/mcp, so its regressions ship with kit. deploy-kit.yml +# triggers on both paths and runs both suites; mirror that here or a +# commit touching only the MCP server would skip the gate entirely. +if git diff --cached --name-only --diff-filter=ACMR \ + | grep -qE '^packages/(kit|mcp-server)/'; then echo "🧰 kit-touched commit — running CI-equivalent gate…" # Lockfile must satisfy package.json. Without --frozen-lockfile, @@ -103,6 +108,12 @@ if git diff --cached --name-only --diff-filter=ACMR | grep -q '^packages/kit/'; # conflicts, missing dist/index.html, server.ts import order issues. echo "→ kit smoke (compile + boot probe)" bun run --filter @hyodotdev/openiap-kit smoke:server + + # MCP server ships inside the same binary; its suite is the only + # coverage for the /mcp session-routing behaviour. + echo "→ mcp-server lint + tests" + bun run --filter @hyodotdev/openiap-mcp-server lint + bun run --filter @hyodotdev/openiap-mcp-server test fi # Paths-aware Flutter analyze. Triggers on any libraries/flutter_inapp_purchase diff --git a/bun.lock b/bun.lock index 67a7be704..7a8e2e6e2 100644 --- a/bun.lock +++ b/bun.lock @@ -160,6 +160,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", + "prettier": "^3.6.2", "typescript": "^5.9.2", "vitest": "^4.1.5", }, diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts index 9499a68f2..398644b2e 100644 --- a/packages/kit/convex/_generated/api.d.ts +++ b/packages/kit/convex/_generated/api.d.ts @@ -35,9 +35,11 @@ import type * as products_asc from "../products/asc.js"; import type * as products_ascReview from "../products/ascReview.js"; import type * as products_jobs from "../products/jobs.js"; import type * as products_jwt from "../products/jwt.js"; +import type * as products_localizations from "../products/localizations.js"; import type * as products_mutation from "../products/mutation.js"; import type * as products_play from "../products/play.js"; import type * as products_query from "../products/query.js"; +import type * as products_regions from "../products/regions.js"; import type * as products_sync from "../products/sync.js"; import type * as products_syncResult from "../products/syncResult.js"; import type * as projects_helpers from "../projects/helpers.js"; @@ -120,9 +122,11 @@ declare const fullApi: ApiFromModules<{ "products/ascReview": typeof products_ascReview; "products/jobs": typeof products_jobs; "products/jwt": typeof products_jwt; + "products/localizations": typeof products_localizations; "products/mutation": typeof products_mutation; "products/play": typeof products_play; "products/query": typeof products_query; + "products/regions": typeof products_regions; "products/sync": typeof products_sync; "products/syncResult": typeof products_syncResult; "projects/helpers": typeof projects_helpers; diff --git a/packages/kit/convex/migrations.ts b/packages/kit/convex/migrations.ts index 06b8c3008..8cf3a8ed5 100644 --- a/packages/kit/convex/migrations.ts +++ b/packages/kit/convex/migrations.ts @@ -123,8 +123,13 @@ export const backfillPurchaseStatsFromPurchases = migrations.define({ const hasOrderId = typeof doc.orderId === "string" && doc.orderId.length > 0 ? true - : extractOrderIdFromRemoteResponse(doc.store, doc.remoteResponse) !== - null; + : extractOrderIdFromRemoteResponse( + doc.store, + doc.remoteResponse, + doc.requestData.store === "google" + ? doc.requestData.expectedProductId + : undefined, + ) !== null; await applyPurchaseStatsDelta( ctx, @@ -197,6 +202,9 @@ export const backfillPurchaseProductIds = migrations.define({ const productId = extractProductIdFromRemoteResponse( doc.store, doc.remoteResponse, + doc.requestData.store === "google" + ? doc.requestData.expectedProductId + : undefined, ); if (productId === null) { @@ -242,6 +250,9 @@ export const backfillPurchaseOrderIds = migrations.define({ const orderId = extractOrderIdFromRemoteResponse( doc.store, doc.remoteResponse, + doc.requestData.store === "google" + ? doc.requestData.expectedProductId + : undefined, ); if (orderId === null) { diff --git a/packages/kit/convex/products/asc.test.ts b/packages/kit/convex/products/asc.test.ts index 4740b173b..27a0e6942 100644 --- a/packages/kit/convex/products/asc.test.ts +++ b/packages/kit/convex/products/asc.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { + ProductSyncCancelledError, + ascPriceStartAttributes, + pushAscReviewLocalizations, + syncAscReviewLocalization, ascCustomerPriceToMicros, createAscReviewEligibilityLoader, getAscReviewFinalizeDisposition, @@ -542,6 +546,18 @@ describe("mapAscOfferKind", () => { }); }); +describe("ascPriceStartAttributes", () => { + it("omits startDate for an immediately effective IAP price", () => { + expect(ascPriceStartAttributes()).toEqual({}); + }); + + it("keeps an explicitly scheduled startDate", () => { + expect(ascPriceStartAttributes("2026-08-08")).toEqual({ + startDate: "2026-08-08", + }); + }); +}); + describe("pickActivePriceRow", () => { const today = new Date().toISOString().slice(0, 10); const yesterday = new Date(Date.now() - 86_400_000) @@ -689,3 +705,104 @@ describe("parseIntroOffers", () => { expect(out).toEqual([]); }); }); + +describe("pushAscReviewLocalizations", () => { + const listings = [ + { locale: "en-US", title: "Coins", description: "100 coins" }, + { locale: "ko-KR", title: "코인" }, + { locale: "ja-JP", title: "コイン" }, + ]; + + it("writes every locale, not just the base listing", async () => { + const seen: string[] = []; + await pushAscReviewLocalizations({ + listings, + productId: "coins", + upsert: async (l) => { + seen.push(l.locale); + }, + recordFailure: () => { + throw new Error("unexpected failure"); + }, + }); + expect(seen).toEqual(["en-US", "ko-KR", "ja-JP"]); + }); + + it("keeps going after one locale fails, and names it", async () => { + const seen: string[] = []; + const failures: Array<{ productId: string; reason: string }> = []; + await pushAscReviewLocalizations({ + listings, + productId: "coins", + upsert: async (l) => { + seen.push(l.locale); + if (l.locale === "ko-KR") throw new Error("ASC rejected it"); + }, + recordFailure: (f) => failures.push(f), + }); + // ja-JP must still be attempted. + expect(seen).toEqual(["en-US", "ko-KR", "ja-JP"]); + expect(failures).toEqual([ + { productId: "coins (localization ko-KR)", reason: "ASC rejected it" }, + ]); + }); + + it("propagates a base-listing failure so the row fails", async () => { + await expect( + pushAscReviewLocalizations({ + listings, + productId: "coins", + upsert: async () => { + throw new Error("base blew up"); + }, + recordFailure: () => undefined, + }), + ).rejects.toThrow("base blew up"); + }); + + it("lets a cancellation keep unwinding instead of grinding on", async () => { + const seen: string[] = []; + await expect( + pushAscReviewLocalizations({ + listings, + productId: "coins", + upsert: async (l) => { + seen.push(l.locale); + if (l.locale === "ko-KR") throw new ProductSyncCancelledError(); + }, + recordFailure: () => { + throw new Error("cancellation must not be recorded as a failure"); + }, + }), + ).rejects.toBeInstanceOf(ProductSyncCancelledError); + expect(seen).toEqual(["en-US", "ko-KR"]); + }); + + it("propagates cancellation through the outer review sync boundary", async () => { + const seen: string[] = []; + const recordFailure = vi.fn(); + + await expect( + syncAscReviewLocalization({ + reviewVersion: { + versionId: "version-1", + alreadySubmitted: false, + attachedToSubmission: false, + }, + listings, + productId: "coins", + findMismatchedLocale: vi.fn(async () => undefined), + upsert: async (listing) => { + seen.push(listing.locale); + if (listing.locale === "ko-KR") { + throw new ProductSyncCancelledError(); + } + }, + recordFailure, + }), + ).rejects.toBeInstanceOf(ProductSyncCancelledError); + + expect(seen).toEqual(["en-US", "ko-KR"]); + expect(recordFailure).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts index 295c94d64..fe66f6f83 100644 --- a/packages/kit/convex/products/asc.ts +++ b/packages/kit/convex/products/asc.ts @@ -9,6 +9,7 @@ import { getProjectByApiKey } from "../purchases/shared"; import { mapWithConcurrency } from "../utils/concurrency"; import { validateAppleReviewScreenshotContent } from "../files/validation"; import { mintAscJwt } from "./jwt"; +import { listingRowsForProduct, splitStoreListings } from "./localizations"; import { coerceBillingPeriod } from "./sync"; import { isProductSyncDeadlineReached, @@ -16,7 +17,7 @@ import { truncatePlannedWrites, } from "./syncResult"; import { - ascReviewLocalizationMatches, + ascReviewLocalizationMismatch, ASC_REVIEW_SUBMISSION_ITEM_LIMIT, ASC_REVIEW_SYNC_BATCH_LIMIT, ensureAscReviewVersion, @@ -24,6 +25,7 @@ import { isAscApprovedReviewHistoryState, inspectAscReviewVersion, planAscReviewVersion, + readAscReviewListings, partitionAscReviewSubmissionItems, submitAscReviewVersions, uploadAscReviewScreenshot, @@ -39,7 +41,7 @@ import { // Shared cancellation/deadline signal. The worker checks at phase and chunk // boundaries, AscClient checks before every API request, and the review helper // checks between upload operations and asset-delivery polls. -class ProductSyncCancelledError extends Error { +export class ProductSyncCancelledError extends Error { constructor() { super("Sync cancelled by operator"); this.name = "ProductSyncCancelledError"; @@ -53,6 +55,114 @@ class ProductSyncDeadlineError extends Error { } } +/** + * Pushes one ASC localization resource per locale. + * + * Apple keeps a separate resource per locale on a version, so this is an + * upsert per locale rather than a single replace — a locale added + * directly in ASC is left alone rather than deleted. + * + * The base listing propagates its error so the caller's benign-replay + * handling still applies and the row fails. Later locales fail + * individually and name themselves, because one bad translation must not + * strand the ones behind it. Aborts always keep unwinding: recording a + * cancellation or a deadline as a per-locale failure would let the loop + * grind on after the operator cancelled. + */ +export async function pushAscReviewLocalizations(args: { + listings: Array<{ locale: string; title: string; description?: string }>; + productId: string; + upsert: (listing: { + locale: string; + title: string; + description?: string; + }) => Promise; + recordFailure: (failure: { productId: string; reason: string }) => void; +}): Promise { + for (const [index, listing] of args.listings.entries()) { + if (index === 0) { + await args.upsert(listing); + continue; + } + try { + await args.upsert(listing); + } catch (error) { + if (isProductSyncAbortError(error)) throw error; + if (isBenignAscRetryConflict(error)) continue; + args.recordFailure({ + productId: `${args.productId} (localization ${listing.locale})`, + reason: error instanceof Error ? error.message : String(error), + }); + } + } +} + +interface SyncAscReviewLocalizationArgs { + reviewVersion: { + versionId: string; + alreadySubmitted: boolean; + attachedToSubmission: boolean; + }; + listings: Array<{ locale: string; title: string; description?: string }>; + productId: string; + findMismatchedLocale: () => Promise; + upsert: (listing: { + locale: string; + title: string; + description?: string; + }) => Promise; + recordFailure: (failure: { productId: string; reason: string }) => void; +} + +/** + * Synchronizes the review localization boundary for one ASC version. + * + * This wrapper intentionally owns the outer failure policy as well as the + * per-locale writer. Cancellation and deadline errors can originate from any + * request in either layer, so they must escape before replay conflicts or + * ordinary localization failures are handled. + */ +export async function syncAscReviewLocalization( + args: SyncAscReviewLocalizationArgs, +): Promise { + try { + if ( + args.reviewVersion.alreadySubmitted || + args.reviewVersion.attachedToSubmission + ) { + const mismatchedLocale = await args.findMismatchedLocale(); + if (mismatchedLocale) { + args.recordFailure({ + productId: `${args.productId} (review version)`, + reason: `The current ASC review version is already attached or submitted and its ${mismatchedLocale} metadata differs from this Draft. Finish or cancel that review in App Store Connect, then run Push Sync again to create an editable version.`, + }); + } + return; + } + await pushAscReviewLocalizations({ + listings: args.listings, + productId: args.productId, + upsert: args.upsert, + recordFailure: args.recordFailure, + }); + } catch (error) { + if (isProductSyncAbortError(error)) throw error; + // A 409 on an editable version is a benign replay from a partial prior + // sync. Reads/comparisons against attached versions are never treated as + // replay success. + if ( + args.reviewVersion.alreadySubmitted || + args.reviewVersion.attachedToSubmission || + !isBenignAscRetryConflict(error) + ) { + args.recordFailure({ + productId: `${args.productId} (localization)`, + reason: error instanceof Error ? error.message : String(error), + }); + } + } +} + function isProductSyncAbortError(error: unknown): boolean { return ( error instanceof ProductSyncCancelledError || @@ -274,6 +384,17 @@ function isBenignAscRetryConflict(error: unknown): boolean { ); } +interface AscPriceStartAttributes { + startDate?: string; +} + +/** Omitting startDate is ASC's representation for an immediate price. */ +export function ascPriceStartAttributes( + startDate?: string, +): AscPriceStartAttributes { + return startDate === undefined ? {} : { startDate }; +} + class AscClient { private cached: AscToken | null = null; @@ -611,7 +732,6 @@ class AscClient { startDate?: string; // YYYY-MM-DD; omit for "effective immediately" }) { const priceLid = "${newPrice}"; - const today = args.startDate ?? new Date().toISOString().slice(0, 10); return this.call<{ data: { id: string } }>( `/v1/inAppPurchasePriceSchedules`, { @@ -635,7 +755,7 @@ class AscClient { { type: "inAppPurchasePrices", id: priceLid, - attributes: { startDate: today }, + attributes: ascPriceStartAttributes(args.startDate), relationships: { inAppPurchasePricePoint: { data: { @@ -1499,6 +1619,10 @@ async function performIosSync( { detailedErrors: true }, ); const client = new AscClient(issuerId, keyId, keyContent, checkCancelled); + const ascJsonRequest: AscJsonRequest = ( + path: string, + init?: RequestInit & { body?: string }, + ) => client.request(path, init); const direction = args.direction ?? "both"; const failures: Array<{ productId: string; reason: string }> = []; @@ -1553,17 +1677,46 @@ async function performIosSync( const productId = item.attributes.productId; if (!productId) return null; const type = mapAscIapType(item.attributes.inAppPurchaseType); - const pricePoint = await client.iapCurrentPrice(item.id); + const [pricePoint, listings] = await Promise.all([ + client.iapCurrentPrice(item.id), + readAscReviewListings({ + request: ascJsonRequest, + kind: "iap", + parentId: item.id, + checkCancelled, + }).catch((error) => { + if (isProductSyncAbortError(error)) throw error; + failures.push({ + productId: `${productId} (localization lookup)`, + reason: error instanceof Error ? error.message : String(error), + }); + return []; + }), + ]); const reviewProductType = mapAscReviewProductType( item.attributes.inAppPurchaseType, type, ); - return { item, productId, type, pricePoint, reviewProductType }; + return { + item, + productId, + type, + pricePoint, + listings, + reviewProductType, + }; }, ); for (const result of iapResults) { if (!result) continue; - const { item, productId, type, pricePoint, reviewProductType } = result; + const { + item, + productId, + type, + pricePoint, + listings, + reviewProductType, + } = result; ascReviewProductTypeByStoreRef.set(item.id, reviewProductType); if (pricePoint instanceof Error) { failures.push({ @@ -1584,7 +1737,9 @@ async function performIosSync( productId, platform: "IOS", type, - title: item.attributes.name ?? productId, + // The parent name is an internal reference. Customer-facing + // metadata lives on the current review-version localizations. + ...splitStoreListings(listings, item.attributes.name ?? productId), priceAmountMicros, currency, storeRef: item.id, @@ -1633,16 +1788,30 @@ async function performIosSync( async (sub) => { const productId = sub.attributes.productId; if (!productId) return null; - const [pricePoint, introOffers] = await Promise.all([ + const [pricePoint, introOffers, listings] = await Promise.all([ client.subCurrentPrice(sub.id), client.subIntroductoryOffer(sub.id), + readAscReviewListings({ + request: ascJsonRequest, + kind: "subscription", + parentId: sub.id, + checkCancelled, + }).catch((error) => { + if (isProductSyncAbortError(error)) throw error; + failures.push({ + productId: `${productId} (localization lookup)`, + reason: + error instanceof Error ? error.message : String(error), + }); + return []; + }), ]); - return { sub, productId, pricePoint, introOffers }; + return { sub, productId, pricePoint, introOffers, listings }; }, ); for (const result of subResults) { if (!result) continue; - const { sub, productId, pricePoint, introOffers } = result; + const { sub, productId, pricePoint, introOffers, listings } = result; if (pricePoint instanceof Error) { failures.push({ productId: `${productId} (price lookup)`, @@ -1668,7 +1837,7 @@ async function performIosSync( productId, platform: "IOS", type: "Subscription", - title: sub.attributes.name ?? productId, + ...splitStoreListings(listings, sub.attributes.name ?? productId), priceAmountMicros, currency, storeRef: sub.id, @@ -1769,10 +1938,7 @@ async function performIosSync( current: pulled, failuresCount: failures.length, }); - const reviewRequest: AscJsonRequest = ( - path: string, - init?: RequestInit & { body?: string }, - ) => client.request(path, init); + const reviewRequest = ascJsonRequest; const reviewCleanupRequest: AscJsonRequest = ( path: string, init?: RequestInit & { body?: string }, @@ -2007,51 +2173,33 @@ async function performIosSync( attachedToSubmission: boolean; }, ): Promise => { - try { - if ( - reviewVersion.alreadySubmitted || - reviewVersion.attachedToSubmission - ) { - const matches = await ascReviewLocalizationMatches({ + await syncAscReviewLocalization({ + reviewVersion, + listings: listingRowsForProduct(row), + productId: row.productId, + // Compare EVERY locale, not just the base pair: a Draft whose only + // change is a new or edited translation would otherwise look + // identical to the locked version and get silently marked pushed. + findMismatchedLocale: () => + ascReviewLocalizationMismatch({ request: reviewRequest, kind, versionId: reviewVersion.versionId, - name: row.title, - description: row.description ?? row.title, + listings: listingRowsForProduct(row), checkCancelled, - }); - if (!matches) { - recordFailure({ - productId: `${row.productId} (review version)`, - reason: - "The current ASC review version is already attached or submitted and its en-US metadata differs from this Draft. Finish or cancel that review in App Store Connect, then run Push Sync again to create an editable version.", - }); - } - return; - } - await upsertAscReviewLocalization({ - request: reviewRequest, - kind, - versionId: reviewVersion.versionId, - name: row.title, - description: row.description ?? row.title, - checkCancelled, - }); - } catch (error) { - // A 409 on an editable version is a benign replay from a partial - // prior sync. Reads/comparisons against attached versions are never - // treated as replay success. - if ( - reviewVersion.alreadySubmitted || - reviewVersion.attachedToSubmission || - !isBenignAscRetryConflict(error) - ) { - recordFailure({ - productId: `${row.productId} (localization)`, - reason: error instanceof Error ? error.message : String(error), - }); - } - } + }), + upsert: (listing) => + upsertAscReviewLocalization({ + request: reviewRequest, + kind, + versionId: reviewVersion.versionId, + name: listing.title, + description: listing.description ?? listing.title, + locale: listing.locale, + checkCancelled, + }), + recordFailure, + }); }; const finalizeReview = async ( kind: "iap" | "subscription", @@ -2367,35 +2515,43 @@ async function performIosSync( reviewVersion.alreadySubmitted || reviewVersion.attachedToSubmission ) { - const matches = await ascReviewLocalizationMatches({ + const mismatchedLocale = await ascReviewLocalizationMismatch({ request: reviewRequest, kind: "subscription", versionId: reviewVersion.versionId, - name: row.title, - description: row.description ?? row.title, + listings: listingRowsForProduct(row), checkCancelled, }); + const matches = mismatchedLocale === undefined; if (!matches) { recordFailure({ productId: `${row.productId} (review version)`, - reason: - "The current ASC review version is already attached or submitted and its en-US metadata differs from this Draft.", + reason: `The current ASC review version is already attached or submitted and its ${mismatchedLocale} metadata differs from this Draft.`, }); } else { plannedWrites.push({ productId: row.productId, - step: "keep locked en-US version localization", - detail: "Current ASC metadata already matches.", + step: "keep locked version localizations", + detail: `Current ASC metadata already matches (${listingRowsForProduct( + row, + ) + .map((listing) => listing.locale) + .join(", ")}).`, }); } } else { - plannedWrites.push({ - productId: row.productId, - step: row.storeRef - ? "patch en-US version localization" - : "create en-US version localization", - detail: row.description ?? row.title, - }); + // One planned line per locale: the real push writes them + // all, so a preview that mentioned only en-US would hide + // exactly the translations the operator is verifying. + for (const listing of listingRowsForProduct(row)) { + plannedWrites.push({ + productId: row.productId, + step: row.storeRef + ? `patch ${listing.locale} version localization` + : `create ${listing.locale} version localization`, + detail: listing.description ?? listing.title, + }); + } } } else if (reviewVersion) { await syncReviewLocalization("subscription", reviewVersion); @@ -2543,35 +2699,43 @@ async function performIosSync( reviewVersion.alreadySubmitted || reviewVersion.attachedToSubmission ) { - const matches = await ascReviewLocalizationMatches({ + const mismatchedLocale = await ascReviewLocalizationMismatch({ request: reviewRequest, kind: "iap", versionId: reviewVersion.versionId, - name: row.title, - description: row.description ?? row.title, + listings: listingRowsForProduct(row), checkCancelled, }); + const matches = mismatchedLocale === undefined; if (!matches) { recordFailure({ productId: `${row.productId} (review version)`, - reason: - "The current ASC review version is already attached or submitted and its en-US metadata differs from this Draft.", + reason: `The current ASC review version is already attached or submitted and its ${mismatchedLocale} metadata differs from this Draft.`, }); } else { plannedWrites.push({ productId: row.productId, - step: "keep locked en-US version localization", - detail: "Current ASC metadata already matches.", + step: "keep locked version localizations", + detail: `Current ASC metadata already matches (${listingRowsForProduct( + row, + ) + .map((listing) => listing.locale) + .join(", ")}).`, }); } } else { - plannedWrites.push({ - productId: row.productId, - step: row.storeRef - ? "patch en-US version localization" - : "create en-US version localization", - detail: row.description ?? row.title, - }); + // One planned line per locale: the real push writes them + // all, so a preview that mentioned only en-US would hide + // exactly the translations the operator is verifying. + for (const listing of listingRowsForProduct(row)) { + plannedWrites.push({ + productId: row.productId, + step: row.storeRef + ? `patch ${listing.locale} version localization` + : `create ${listing.locale} version localization`, + detail: listing.description ?? listing.title, + }); + } } } else if (reviewVersion) { await syncReviewLocalization("iap", reviewVersion); diff --git a/packages/kit/convex/products/ascReview.test.ts b/packages/kit/convex/products/ascReview.test.ts index 2b02f0b25..2b16dd55c 100644 --- a/packages/kit/convex/products/ascReview.test.ts +++ b/packages/kit/convex/products/ascReview.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { - ascReviewLocalizationMatches, + ascReviewLocalizationMismatch, classifyAscManualReviewAction, ensureAscReviewVersion, getAscReviewEligibilityActions, @@ -9,6 +9,7 @@ import { md5Hex, partitionAscReviewSubmissionItems, planAscReviewVersion, + readAscReviewListings, submitAscReviewVersions, uploadAscReviewScreenshot, upsertAscReviewLocalization, @@ -24,6 +25,65 @@ class MockAscError extends Error { } } +describe("readAscReviewListings", () => { + it("reads every locale from the current review version", async () => { + const paths: string[] = []; + const request: AscJsonRequest = async (path: string) => { + paths.push(path); + if (path.includes("/versions")) { + return { + data: [ + { + id: "version-current", + attributes: { state: "PREPARE_FOR_SUBMISSION" }, + }, + ], + } as T; + } + return { + data: [ + { + id: "loc-ko", + type: "inAppPurchaseLocalizations", + attributes: { + locale: "ko", + name: "문 세이지", + description: "전체 해금", + }, + }, + { + id: "loc-ja", + type: "inAppPurchaseLocalizations", + attributes: { locale: "ja", name: "ムーンセージ" }, + }, + ], + } as T; + }; + + await expect( + readAscReviewListings({ request, kind: "iap", parentId: "iap-1" }), + ).resolves.toEqual([ + { locale: "ko", title: "문 세이지", description: "전체 해금" }, + { locale: "ja", title: "ムーンセージ" }, + ]); + expect(paths).toEqual([ + "/v2/inAppPurchases/iap-1/versions?limit=200", + "/v1/inAppPurchaseVersions/version-current/localizations?limit=200", + ]); + }); + + it("returns no listings when the product has no review version", async () => { + const request: AscJsonRequest = async () => ({ data: [] }) as T; + await expect( + readAscReviewListings({ + request, + kind: "subscription", + parentId: "sub-1", + }), + ).resolves.toEqual([]); + }); +}); + describe("uploadAscReviewScreenshot", () => { it("honors every IAP upload operation without forwarding ASC auth", async () => { const bytes = Uint8Array.from([1, 2, 3, 4, 5]); @@ -873,6 +933,37 @@ describe("ASC version and submission workflow", () => { }); }); + it("maps common Japanese and Korean BCP-47 tags at the ASC boundary", async () => { + const bodies: unknown[] = []; + const request: AscJsonRequest = async ( + path: string, + init?: RequestInit & { body?: string }, + ) => { + if (path.includes("/localizations?")) return { data: [] } as T; + if (init?.body) bodies.push(JSON.parse(init.body)); + return { data: { id: "loc-1" } } as T; + }; + + for (const locale of ["ja-JP", "ko-KR"]) { + await upsertAscReviewLocalization({ + request, + kind: "iap", + versionId: "iap-version", + name: "Localized name", + description: "Localized description", + locale, + }); + } + + expect( + bodies.map( + (body) => + (body as { data: { attributes: { locale: string } } }).data.attributes + .locale, + ), + ).toEqual(["ja", "ko"]); + }); + it("treats READY_FOR_REVIEW as attached and does not create a mutable version", async () => { const request = vi.fn(async () => ({ data: [ @@ -1010,23 +1101,117 @@ describe("ASC version and submission workflow", () => { })) as unknown as AscJsonRequest; await expect( - ascReviewLocalizationMatches({ + ascReviewLocalizationMismatch({ + request, + kind: "iap", + versionId: "attached-version", + listings: [ + { locale: "en-US", title: "Coins", description: "100 coins" }, + ], + }), + ).resolves.toBeUndefined(); + await expect( + ascReviewLocalizationMismatch({ + request, + kind: "iap", + versionId: "attached-version", + listings: [ + { locale: "en-US", title: "Coins Plus", description: "100 coins" }, + ], + }), + ).resolves.toBe("en-US"); + // Description alone, title untouched. A comparison that only looked + // at `name` would call this a match and mark the product pushed with + // the old description still live on the locked version. + await expect( + ascReviewLocalizationMismatch({ + request, + kind: "iap", + versionId: "attached-version", + listings: [ + { locale: "en-US", title: "Coins", description: "200 coins" }, + ], + }), + ).resolves.toBe("en-US"); + }); + + // A Draft whose only change is a translation used to compare equal to + // the locked version, so it was marked pushed without the translation + // ever reaching ASC. + it("reports a locale the locked version is missing", async () => { + const request = (async () => ({ + data: [ + { + id: "loc-1", + type: "inAppPurchaseLocalizations", + attributes: { + locale: "en-US", + name: "Coins", + description: "100 coins", + }, + }, + ], + })) as unknown as AscJsonRequest; + + await expect( + ascReviewLocalizationMismatch({ request, kind: "iap", versionId: "attached-version", - name: "Coins", - description: "100 coins", + listings: [ + { locale: "en-US", title: "Coins", description: "100 coins" }, + { locale: "ko-KR", title: "코인", description: "코인 100개" }, + ], }), - ).resolves.toBe(true); + ).resolves.toBe("ko"); + }); + + it("finds matching metadata on a later localization page", async () => { + const request = vi.fn(async (path: string) => + path.includes("cursor=page-2") + ? { + data: [ + { + id: "loc-ko", + type: "inAppPurchaseLocalizations", + attributes: { + locale: "ko", + name: "코인", + description: "코인 100개", + }, + }, + ], + } + : { + data: [ + { + id: "loc-en", + type: "inAppPurchaseLocalizations", + attributes: { + locale: "en-US", + name: "Coins", + description: "100 coins", + }, + }, + ], + links: { + next: `${"https://api.appstoreconnect.apple.com"}/v1/inAppPurchaseVersions/attached-version/localizations?cursor=page-2`, + }, + }, + ) as unknown as AscJsonRequest; + await expect( - ascReviewLocalizationMatches({ + ascReviewLocalizationMismatch({ request, kind: "iap", versionId: "attached-version", - name: "Coins Plus", - description: "200 coins", + listings: [ + { locale: "en-US", title: "Coins", description: "100 coins" }, + { locale: "ko-KR", title: "코인", description: "코인 100개" }, + ], }), - ).resolves.toBe(false); + ).resolves.toBeUndefined(); + expect(request).toHaveBeenCalledTimes(2); }); it("creates one review submission with IAP and subscription version items", async () => { diff --git a/packages/kit/convex/products/ascReview.ts b/packages/kit/convex/products/ascReview.ts index 6064ddaee..a357bd4c5 100644 --- a/packages/kit/convex/products/ascReview.ts +++ b/packages/kit/convex/products/ascReview.ts @@ -2,6 +2,8 @@ import { createHash } from "node:crypto"; +import { localeForAppStoreConnect } from "./localizations"; + export type AscReviewKind = "iap" | "subscription"; export const ASC_REVIEW_SUBMISSION_ITEM_LIMIT = 200; // Keep one worker's prepare→submit unit comfortably below Convex's action @@ -123,6 +125,41 @@ interface AscLocalizationResponse { description?: string; }; }>; + links?: { next?: string | null }; +} + +const ASC_API_ORIGIN = "https://api.appstoreconnect.apple.com"; + +function ascNextPath(next: string): string { + if (next.startsWith("/")) return next; + const url = new URL(next); + if (url.origin !== ASC_API_ORIGIN) { + throw new Error( + `ASC pagination returned an unexpected host: ${url.origin}`, + ); + } + return `${url.pathname}${url.search}`; +} + +async function readAllAscLocalizations(args: { + request: AscJsonRequest; + initialPath: string; + checkCancelled: () => Promise; +}): Promise { + const rows: AscLocalizationResponse["data"] = []; + let path: string | null = args.initialPath; + let pages = 0; + while (path && pages < 200) { + await args.checkCancelled(); + const page: AscLocalizationResponse = await args.request(path); + rows.push(...page.data); + path = page.links?.next ? ascNextPath(page.links.next) : null; + pages += 1; + } + if (path) { + throw new Error("ASC localization pagination exceeded 200 pages"); + } + return rows; } interface AscReviewSubmissionResponse { @@ -712,6 +749,51 @@ export async function inspectAscReviewVersion(args: { return approved ? { versionId: approved.id, state: "approved" } : null; } +/** + * Reads the localized metadata attached to the current ASC review version. + * + * Product names on the parent resource are internal reference names, not the + * customer-facing store listing. Pull-sync must read the version subresource + * or it will lose every ASC-authored locale and later publish the reference + * name as en-US. + */ +export async function readAscReviewListings(args: { + request: AscJsonRequest; + kind: AscReviewKind; + parentId: string; + checkCancelled?: () => Promise; +}): Promise> { + const checkCancelled = args.checkCancelled ?? (async () => undefined); + const current = await inspectAscReviewVersion({ + request: args.request, + kind: args.kind, + parentId: args.parentId, + checkCancelled, + }); + if (!current) return []; + + const resources = await readAllAscLocalizations({ + request: args.request, + initialPath: VERSION_CONFIG[args.kind].localizationListPath( + current.versionId, + ), + checkCancelled, + }); + return resources.flatMap((resource) => { + const locale = resource.attributes?.locale; + const title = resource.attributes?.name; + if (!locale || !title) return []; + const description = resource.attributes?.description; + return [ + { + locale, + title, + ...(description ? { description } : {}), + }, + ]; + }); +} + export async function ensureAscReviewVersion(args: { request: AscJsonRequest; kind: AscReviewKind; @@ -800,7 +882,10 @@ export async function upsertAscReviewLocalization(args: { checkCancelled?: () => Promise; }): Promise { const config = VERSION_CONFIG[args.kind]; - const locale = args.locale ?? "en-US"; + // Normalize again at the ASC boundary so legacy rows saved before locale + // validation was strict do not keep replaying unsupported `ja-JP` / `ko-KR` + // values into App Store Connect. + const locale = localeForAppStoreConnect(args.locale ?? "en-US"); const checkCancelled = args.checkCancelled ?? (async () => undefined); await checkCancelled(); const localizations = await args.request( @@ -848,28 +933,45 @@ export async function upsertAscReviewLocalization(args: { }); } -export async function ascReviewLocalizationMatches(args: { +/** + * Whether a locked review version already carries exactly the listings + * kit would push. + * + * Takes the whole set rather than one locale: the push writes every + * locale on the row, so comparing only the base pair would report + * "matches" for a Draft whose sole change is a translation — and that + * Draft would then be marked pushed without the translation shipping. + * One list fetch serves every comparison. + * + * @returns The first locale that differs, or undefined when all match. + */ +export async function ascReviewLocalizationMismatch(args: { request: AscJsonRequest; kind: AscReviewKind; versionId: string; - name: string; - description: string; - locale?: string; + listings: Array<{ locale: string; title: string; description?: string }>; checkCancelled?: () => Promise; -}): Promise { +}): Promise { const config = VERSION_CONFIG[args.kind]; - const locale = args.locale ?? "en-US"; - await (args.checkCancelled ?? (async () => undefined))(); - const localizations = await args.request( - config.localizationListPath(args.versionId), - ); - const existing = localizations.data.find( - (localization) => localization.attributes?.locale === locale, - ); - return ( - existing?.attributes?.name === args.name && - existing.attributes.description === args.description - ); + const checkCancelled = args.checkCancelled ?? (async () => undefined); + const localizations = await readAllAscLocalizations({ + request: args.request, + initialPath: config.localizationListPath(args.versionId), + checkCancelled, + }); + for (const listing of args.listings) { + const locale = localeForAppStoreConnect(listing.locale); + const existing = localizations.find( + (localization) => localization.attributes?.locale === locale, + ); + if ( + existing?.attributes?.name !== listing.title || + existing.attributes.description !== (listing.description ?? listing.title) + ) { + return locale; + } + } + return undefined; } export async function submitAscReviewVersions(args: { diff --git a/packages/kit/convex/products/localizations.test.ts b/packages/kit/convex/products/localizations.test.ts new file mode 100644 index 000000000..5e2af8620 --- /dev/null +++ b/packages/kit/convex/products/localizations.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from "vitest"; + +import { + BASE_LISTING_LOCALE, + listingRowsForProduct, + normalizeProductLocalizations, + splitStoreListings, +} from "./localizations"; + +describe("normalizeProductLocalizations", () => { + it("sorts by locale so a store request body is deterministic", () => { + expect( + normalizeProductLocalizations( + [ + { locale: "ko-KR", title: "코인" }, + { locale: "de-DE", title: "Münzen" }, + { locale: "ja-JP", title: "コイン" }, + ], + "Android", + "Consumable", + )?.map((l) => l.locale), + ).toEqual(["de-DE", "ja-JP", "ko-KR"]); + }); + + it("trims and drops blank descriptions", () => { + expect( + normalizeProductLocalizations( + [ + { locale: " ja-JP ", title: " ムーンセージ ", description: " " }, + { locale: "ko-KR", title: "문 세이지", description: " 전체 해금 " }, + ], + "Android", + "Consumable", + ), + ).toEqual([ + { locale: "ja-JP", title: "ムーンセージ" }, + { locale: "ko-KR", title: "문 세이지", description: "전체 해금" }, + ]); + }); + + it("treats an absent or empty list as nothing to store", () => { + expect( + normalizeProductLocalizations(undefined, "Android", "Consumable"), + ).toBeUndefined(); + expect( + normalizeProductLocalizations([], "Android", "Consumable"), + ).toBeUndefined(); + }); + + // Play and ASC use different vocabularies (zh-CN vs zh-Hans, es-419 vs + // es-MX) and a row targets one platform, so both families must pass. + it("accepts every locale shape the two stores actually use", () => { + for (const locale of [ + "ko", + "ko-KR", + "pt-BR", + "en-GB", + "zh-CN", + "zh-Hans", + "zh-Hant", + "es-419", + "zh-Hant-TW", + ]) { + expect( + normalizeProductLocalizations( + [{ locale, title: "x" }], + "Android", + "Consumable", + ), + ).toEqual([{ locale, title: "x" }]); + } + }); + + it("normalizes common Japanese and Korean tags to ASC shortcodes", () => { + expect( + normalizeProductLocalizations( + [ + { locale: "ja-JP", title: "コイン" }, + { locale: "ko-KR", title: "코인" }, + ], + "IOS", + "Consumable", + ), + ).toEqual([ + { locale: "ja", title: "コイン" }, + { locale: "ko", title: "코인" }, + ]); + }); + + it("rejects BCP-47 locales outside ASC's supported shortcode list", () => { + expect(() => + normalizeProductLocalizations( + [{ locale: "es-419", title: "Monedas" }], + "IOS", + "Consumable", + ), + ).toThrow(/App Store Connect locale.*es-419/); + }); + + it("detects duplicates after applying ASC aliases", () => { + expect(() => + normalizeProductLocalizations( + [ + { locale: "ko", title: "하나" }, + { locale: "ko-KR", title: "둘" }, + ], + "IOS", + "Consumable", + ), + ).toThrow(/Duplicate localization locale/); + }); + + it("rejects malformed locales rather than letting the store 400", () => { + for (const locale of ["ko_KR", "", "k", "ko-", "-KR", "ko KR"]) { + expect(() => + normalizeProductLocalizations( + [{ locale, title: "x" }], + "Android", + "Consumable", + ), + ).toThrow(/Invalid localization locale/); + } + }); + + it("reserves the base locale for the product's own title", () => { + expect(() => + normalizeProductLocalizations( + [{ locale: BASE_LISTING_LOCALE, title: "Moon Sage" }], + "Android", + "Consumable", + ), + ).toThrow(/reserved/); + }); + + it("rejects duplicate locales", () => { + expect(() => + normalizeProductLocalizations( + [ + { locale: "ko-KR", title: "하나" }, + { locale: "ko-KR", title: "둘" }, + ], + "Android", + "Consumable", + ), + ).toThrow(/Duplicate localization locale/); + }); + + it("rejects a blank title and over-long store text", () => { + expect(() => + normalizeProductLocalizations( + [{ locale: "ko-KR", title: " " }], + "Android", + "Consumable", + ), + ).toThrow(/needs a title/); + expect(() => + normalizeProductLocalizations( + [{ locale: "ko-KR", title: "가".repeat(56) }], + "Android", + "Consumable", + ), + ).toThrow(/at most 55/); + expect(() => + normalizeProductLocalizations( + [{ locale: "ko-KR", title: "코인", description: "가".repeat(201) }], + "Android", + "Consumable", + ), + ).toThrow(/at most 200/); + }); + + // ASC caps IAP localization name/description far below Play's limits; + // validating against one store would either block a legal Android + // title or pass an iOS one that ASC then rejects. + it("applies each platform's own store limits", () => { + const long = { locale: "ko-KR", title: "가".repeat(40) }; + expect( + normalizeProductLocalizations([long], "Android", "Consumable"), + ).toEqual([long]); + expect(() => + normalizeProductLocalizations([long], "IOS", "Consumable"), + ).toThrow(/IOS accepts at most 30/); + }); + + // Play documents a 55-char title for a one-time product but no title + // cap for a subscription, so holding both to 55 would refuse a legal + // subscription name. + it("does not cap a Play subscription title", () => { + const long = { locale: "ko-KR", title: "가".repeat(80) }; + expect( + normalizeProductLocalizations([long], "Android", "Subscription"), + ).toEqual([long]); + expect(() => + normalizeProductLocalizations([long], "Android", "Consumable"), + ).toThrow(/at most 55/); + }); + + it("canonicalizes locale casing so ko-kr and ko-KR are one locale", () => { + expect( + normalizeProductLocalizations( + [{ locale: "ko-kr", title: "코인" }], + "Android", + "Consumable", + ), + ).toEqual([{ locale: "ko-KR", title: "코인" }]); + expect( + normalizeProductLocalizations( + [{ locale: "zh-hans", title: "币" }], + "Android", + "Consumable", + ), + ).toEqual([{ locale: "zh-Hans", title: "币" }]); + // Case-insensitive input is a feature, not a typo to reject. + expect( + normalizeProductLocalizations( + [{ locale: "KO", title: "코인" }], + "Android", + "Consumable", + ), + ).toEqual([{ locale: "ko", title: "코인" }]); + expect(() => + normalizeProductLocalizations( + [ + { locale: "ko-KR", title: "하나" }, + { locale: "ko-kr", title: "둘" }, + ], + "Android", + "Consumable", + ), + ).toThrow(/Duplicate localization locale/); + // Casing must not let a caller sneak past the base-locale guard. + expect(() => + normalizeProductLocalizations( + [{ locale: "EN-us", title: "x" }], + "Android", + "Consumable", + ), + ).toThrow(/reserved/); + }); +}); + +describe("listingRowsForProduct", () => { + it("puts the base listing first, then the extra locales", () => { + expect( + listingRowsForProduct({ + title: "Moon Sage", + description: "Unlock Moon Sage", + localizations: [{ locale: "ko-KR", title: "문 세이지" }], + }), + ).toEqual([ + { + locale: BASE_LISTING_LOCALE, + title: "Moon Sage", + description: "Unlock Moon Sage", + }, + { locale: "ko-KR", title: "문 세이지" }, + ]); + }); + + it("produces exactly the pre-localization single listing when none are set", () => { + expect(listingRowsForProduct({ title: "Moon Sage" })).toEqual([ + { locale: BASE_LISTING_LOCALE, title: "Moon Sage" }, + ]); + }); +}); + +describe("non-English base localization validation", () => { + it("reserves the actual base locale and permits en-US as an extra", () => { + expect( + normalizeProductLocalizations( + [{ locale: "en-US", title: "Moon Sage" }], + "Android", + "Consumable", + "ko-KR", + ), + ).toEqual([{ locale: "en-US", title: "Moon Sage" }]); + expect(() => + normalizeProductLocalizations( + [{ locale: "ko-KR", title: "중복" }], + "Android", + "Consumable", + "ko-KR", + ), + ).toThrow(/ko-KR.*reserved/); + }); +}); + +describe("splitStoreListings", () => { + it("splits a pulled listing set into base plus localizations", () => { + expect( + splitStoreListings( + [ + { locale: "ko-KR", title: "문 세이지", description: "전체 해금" }, + { locale: "en-US", title: "Moon Sage", description: "Unlock" }, + ], + "fallback", + ), + ).toEqual({ + title: "Moon Sage", + description: "Unlock", + baseLocale: "en-US", + localizations: [ + { locale: "ko-KR", title: "문 세이지", description: "전체 해금" }, + ], + }); + }); + + it("promotes the first listing and records its locale", () => { + expect( + splitStoreListings([{ locale: "ko-KR", title: "문 세이지" }], "fallback"), + ).toEqual({ + title: "문 세이지", + baseLocale: "ko-KR", + }); + }); + + it("round-trips a store that has no en-US listing", () => { + const pulled = splitStoreListings( + [ + { locale: "ko-KR", title: "문 세이지", description: "전체 해금" }, + { locale: "ja-JP", title: "ムーンセージ" }, + ], + "fallback", + ); + // ko-KR stays the base rather than being flattened into a fabricated + // en-US listing on the next push. + expect(listingRowsForProduct(pulled).map((row) => row.locale)).toEqual([ + "ko-KR", + "ja-JP", + ]); + }); + + it("falls back to the product id when nothing is usable", () => { + expect(splitStoreListings([], "hero.sage")).toEqual({ + title: "hero.sage", + }); + expect( + splitStoreListings([{ locale: "ko-KR", title: null }], "hero.sage"), + ).toEqual({ title: "hero.sage" }); + }); + + it("round-trips with listingRowsForProduct", () => { + const product = { + title: "Moon Sage", + description: "Unlock Moon Sage", + localizations: [ + { locale: "ja-JP", title: "ムーンセージ" }, + { locale: "ko-KR", title: "문 세이지", description: "전체 해금" }, + ], + }; + expect( + splitStoreListings(listingRowsForProduct(product), "unused"), + ).toEqual({ ...product, baseLocale: "en-US" }); + }); + + it("round-trips a non-en-US base without inventing another locale", () => { + const product = { + title: "문 세이지", + description: "전체 해금", + baseLocale: "ko-KR", + localizations: [{ locale: "ja-JP", title: "ムーンセージ" }], + }; + expect( + splitStoreListings(listingRowsForProduct(product), "unused"), + ).toEqual(product); + }); + + it("honors an explicit store default locale", () => { + expect( + splitStoreListings( + [ + { locale: "en-US", title: "Moon Sage" }, + { locale: "ko-KR", title: "문 세이지" }, + ], + "unused", + "ko-KR", + ), + ).toEqual({ + title: "문 세이지", + baseLocale: "ko-KR", + localizations: [{ locale: "en-US", title: "Moon Sage" }], + }); + }); +}); diff --git a/packages/kit/convex/products/localizations.ts b/packages/kit/convex/products/localizations.ts new file mode 100644 index 000000000..f2f968d71 --- /dev/null +++ b/packages/kit/convex/products/localizations.ts @@ -0,0 +1,338 @@ +import { ConvexError, v } from "convex/values"; + +// Localized store-listing text. A product's `title` / `description` +// remain the base listing every store requires; `localizations` only +// adds languages on top of it, so a row without any behaves exactly as +// it did before this existed. +// +// Both stores take BCP-47 codes in the same shape — Play calls the field +// `languageCode` on its listing objects, App Store Connect calls it +// `locale` on inAppPurchaseLocalizations / subscriptionLocalizations — +// so one representation serves both push paths. + +/** Locale every product's base `title` / `description` is published as. */ +export const BASE_LISTING_LOCALE = "en-US"; + +// The stores cap listing text differently, and Play differs again by +// product type: it documents 55/200 for a one-time product but only a +// description cap for a subscription, leaving the title uncapped. App +// Store Connect allows 30/45. Validate against the exact surface the +// row targets so an Android operator isn't held to Apple's limit, an +// iOS operator isn't told their text is fine right up until ASC rejects +// it, and a legal subscription title isn't refused for exceeding a +// limit Play never states. +export type ProductPlatform = "IOS" | "Android"; +export type ProductListingType = + | "Subscription" + | "NonConsumable" + | "Consumable"; + +export interface ListingLimits { + /** undefined = the store documents no cap for this surface. */ + title?: number; + description: number; +} + +export function listingLimitsFor( + platform: ProductPlatform, + type: ProductListingType, +): ListingLimits { + if (platform === "IOS") return { title: 30, description: 45 }; + // 200, not the 80 the bundled googleapis 157 types still claim: Play's + // live discovery document reports "Maximum length - 200 characters" + // for SubscriptionListing.description. Validating at 80 would refuse + // text Play accepts. + if (type === "Subscription") return { description: 200 }; + return { title: 55, description: 200 }; +} + +export interface ProductLocalization { + locale: string; + title: string; + description?: string; +} + +export const productLocalizationValidator = v.object({ + locale: v.string(), + title: v.string(), + description: v.optional(v.string()), +}); + +export const productLocalizationsValidator = v.array( + productLocalizationValidator, +); + +// Play and ASC do NOT share a locale vocabulary — Simplified Chinese is +// `zh-CN` on Play and `zh-Hans` on ASC; Latin American Spanish is +// `es-419` on Play and `es-MX` on ASC. A product row targets exactly one +// platform. The pattern must admit script subtags (`zh-Hans`) and numeric +// region subtags (`es-419`); platform-specific validation below then enforces +// ASC's fixed shortcode inventory while Play keeps accepting general BCP-47. +const LOCALE_PATTERN = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,2}$/; + +// App Store Connect accepts a fixed locale-shortcode vocabulary rather than +// every valid BCP-47 tag. In particular, Japanese and Korean are `ja` / `ko`, +// not the equally valid region-qualified `ja-JP` / `ko-KR` forms that Play +// accepts. Keep the store boundary explicit so a dashboard edit fails locally +// instead of surfacing as an opaque ASC 409 during push-sync. +// https://developer.apple.com/documentation/appstoreconnectapi/managing-metadata-in-your-app-by-using-locale-shortcodes +const ASC_LOCALE_SHORTCODES = new Set([ + "ar-SA", + "bn-BD", + "ca", + "zh-Hans", + "zh-Hant", + "hr", + "cs", + "da", + "nl-NL", + "en-AU", + "en-CA", + "en-GB", + "en-US", + "fi", + "fr-FR", + "fr-CA", + "de-DE", + "el", + "gu-IN", + "he", + "hi", + "hu", + "id", + "it", + "ja", + "kn-IN", + "ko", + "ms", + "ml-IN", + "mr-IN", + "no", + "or-IN", + "pl", + "pt-BR", + "pt-PT", + "pa-IN", + "ro", + "ru", + "sk", + "sl-SI", + "es-MX", + "es-ES", + "sv", + "ta-IN", + "te-IN", + "th", + "tr", + "uk", + "ur-PK", + "vi", +]); + +const ASC_LOCALE_ALIASES = new Map([ + ["ja-JP", "ja"], + ["ko-KR", "ko"], +]); + +/** + * Canonicalizes a BCP-47 tag's casing: lowercase language, Titlecase + * script, uppercase region — `ko-kr` → `ko-KR`, `zh-hans` → `zh-Hans`. + */ +function canonicalizeLocale(raw: string): string { + const parts = raw.trim().split("-"); + return parts + .map((part, index) => { + if (index === 0) return part.toLowerCase(); + if (part.length === 4) { + return part[0].toUpperCase() + part.slice(1).toLowerCase(); + } + return part.toUpperCase(); + }) + .join("-"); +} + +/** Converts a BCP-47 locale into the shortcode accepted by ASC. */ +export function localeForAppStoreConnect(raw: string): string { + const canonical = canonicalizeLocale(raw); + const locale = ASC_LOCALE_ALIASES.get(canonical) ?? canonical; + if (!ASC_LOCALE_SHORTCODES.has(locale)) { + throw invalidListing( + `Invalid App Store Connect locale "${raw}". Use an ASC locale shortcode such as "en-US", "ja", "ko", or "zh-Hans".`, + ); + } + return locale; +} + +/** + * Normalizes and validates operator-supplied localizations. + * + * @param localizations Raw rows from the dashboard / MCP / a pull. + * @returns The cleaned list, or undefined when there is nothing to store. + * @throws When a locale is malformed, duplicated, collides with the base + * locale, has a blank title, or exceeds a store length limit. + */ +/** + * Structured so the REST route and MCP tool map it to 400 rather than a + * generic 500 — these are operator input mistakes, not server faults. + */ +function invalidListing(message: string): ConvexError<{ + code: string; + message: string; +}> { + return new ConvexError({ code: "INVALID_INPUT", message }); +} + +export function normalizeProductLocalizations( + localizations: ProductLocalization[] | undefined, + platform: ProductPlatform, + type: ProductListingType, + baseLocale: string = BASE_LISTING_LOCALE, +): ProductLocalization[] | undefined { + if (!localizations || localizations.length === 0) return undefined; + + const limits = listingLimitsFor(platform, type); + const normalizedBaseLocale = + platform === "IOS" + ? localeForAppStoreConnect(baseLocale) + : canonicalizeLocale(baseLocale); + const seen = new Set(); + const normalized: ProductLocalization[] = []; + + for (const entry of localizations) { + // Canonicalize case before comparing: `ko-kr` and `ko-KR` are the + // same locale, so without this a duplicate slips through and the + // store rejects the pair — and `EN-us` would dodge the base-locale + // guard entirely. + const canonicalLocale = canonicalizeLocale(entry.locale); + const locale = + platform === "IOS" + ? localeForAppStoreConnect(canonicalLocale) + : canonicalLocale; + if (!LOCALE_PATTERN.test(locale)) { + throw invalidListing( + `Invalid localization locale "${entry.locale}". Use a BCP-47 code such as "ko" or "ko-KR".`, + ); + } + if (locale === normalizedBaseLocale) { + throw invalidListing( + `Localization locale "${normalizedBaseLocale}" is reserved for the product's own title and description. Edit those instead of adding a localization for it.`, + ); + } + if (seen.has(locale)) { + throw invalidListing(`Duplicate localization locale "${locale}".`); + } + seen.add(locale); + + const title = entry.title.trim(); + if (!title) { + throw invalidListing(`Localization "${locale}" needs a title.`); + } + if (limits.title !== undefined && title.length > limits.title) { + throw invalidListing( + `Localization "${locale}" title is ${title.length} characters; ${platform} accepts at most ${limits.title}.`, + ); + } + + const description = entry.description?.trim() || undefined; + if (description && description.length > limits.description) { + throw invalidListing( + `Localization "${locale}" description is ${description.length} characters; ${platform} accepts at most ${limits.description}.`, + ); + } + + normalized.push({ + locale, + title, + ...(description ? { description } : {}), + }); + } + + // Stable order keeps request bodies (and therefore diffs and test + // fixtures) deterministic regardless of dashboard input order. + normalized.sort((a, b) => a.locale.localeCompare(b.locale)); + return normalized; +} + +/** + * Splits store listings into the base listing plus the extra locales. + * + * The pull direction's counterpart to {@link listingRowsForProduct}: a + * store's listing array becomes the `title` / `description` / + * `localizations` triple a product row stores. The base locale is + * preferred as the base listing; when a store has no entry for it (an + * app authored entirely in another language) the first listing takes + * that role so the required `title` is never empty. + */ +export function splitStoreListings( + listings: Array<{ + locale?: string | null; + title?: string | null; + description?: string | null; + }>, + fallbackTitle: string, + preferredBaseLocale: string = BASE_LISTING_LOCALE, +): { + title: string; + description?: string; + baseLocale?: string; + localizations?: ProductLocalization[]; +} { + const usable = listings.filter( + ( + listing, + ): listing is { + locale: string; + title: string; + description?: string | null; + } => Boolean(listing.locale && listing.title), + ); + if (usable.length === 0) return { title: fallbackTitle }; + + const base = + usable.find((listing) => listing.locale === preferredBaseLocale) ?? + usable.find((listing) => listing.locale === BASE_LISTING_LOCALE); + // A store with no en-US listing still has to yield a non-empty `title`, + // so the first listing becomes the base. Keep its locale explicitly: + // without `baseLocale`, the next push would relabel that text as en-US. + const promoted = base ?? usable[0]; + const others = usable + .filter((listing) => listing.locale !== promoted.locale) + .map((listing) => ({ + locale: listing.locale, + title: listing.title, + ...(listing.description ? { description: listing.description } : {}), + })); + + return { + title: promoted.title, + ...(promoted.description ? { description: promoted.description } : {}), + baseLocale: promoted.locale, + ...(others.length > 0 ? { localizations: others } : {}), + }; +} + +/** + * Expands a product's base listing plus its localizations into the + * `{locale, title, description}` rows a store push writes. + * + * The base listing always comes first so a store that treats the first + * entry as the default gets the language the operator authored. + */ +export function listingRowsForProduct(product: { + title: string; + description?: string; + baseLocale?: string; + localizations?: ProductLocalization[]; +}): ProductLocalization[] { + const baseLocale = product.baseLocale ?? BASE_LISTING_LOCALE; + return [ + { + locale: baseLocale, + title: product.title, + ...(product.description ? { description: product.description } : {}), + }, + ...(product.localizations ?? []).filter( + (entry) => entry.locale !== baseLocale, + ), + ]; +} diff --git a/packages/kit/convex/products/mutation.test.ts b/packages/kit/convex/products/mutation.test.ts index 12c484cad..084e6b405 100644 --- a/packages/kit/convex/products/mutation.test.ts +++ b/packages/kit/convex/products/mutation.test.ts @@ -16,6 +16,7 @@ import { MAX_PRODUCT_CLIENT_PAYLOAD_BYTES, nextStateForKitProductUpsert, removeProduct as registeredRemoveProduct, + upsertProduct as registeredUpsertProduct, removeProductClientPayload as registeredRemoveProductClientPayload, removeProductClientPayloadWithApiKey as registeredRemoveProductClientPayloadWithApiKey, upsertProductClientPayload as registeredUpsertProductClientPayload, @@ -25,6 +26,7 @@ import { import { testableFunction } from "../test.setup"; const removeProduct = testableFunction(registeredRemoveProduct); +const upsertProduct = testableFunction(registeredUpsertProduct); const removeProductClientPayload = testableFunction( registeredRemoveProductClientPayload, ); @@ -582,3 +584,176 @@ describe("product client payload mutations", () => { expect(ctx.db.rows("productClientPayloadSummaries")).toHaveLength(1); }); }); + +// The dashboard's delete-all depends entirely on this distinction, and +// nothing asserted it: `undefined` means "not specified, keep what is +// stored", an explicit `[]` means "the operator removed them all". +// Convex treats `undefined` in a patch as a no-op, which is why the +// clear has to become `null`. +describe("upsertProduct localization clearing contract", () => { + const PROJECT = "projects_a" as Id<"projects">; + + const storedLocalizations = (ctx: ReturnType) => + ( + ctx.db.rows("products").find((r) => r._id === "product_android") as + | { localizations?: unknown } + | undefined + )?.localizations; + + function seededCtx() { + const ctx = makeCtx(); + ctx.db.seed("products", { + _id: "product_android", + projectId: PROJECT, + platform: "Android", + productId: "premium.monthly", + type: "Consumable", + title: "Premium", + localizations: [{ locale: "ko-KR", title: "프리미엄" }], + }); + return ctx; + } + + const base = { + projectId: PROJECT, + productId: "premium.monthly", + platform: "Android" as const, + type: "Consumable" as const, + title: "Premium", + }; + + beforeEach(() => { + projectMocks.byProjectId.mockResolvedValue({ + project: { _id: PROJECT }, + role: "admin", + userId: "user_a", + }); + }); + + it("preserves stored localizations when the field is omitted", async () => { + const ctx = seededCtx(); + await upsertProduct._handler(ctx, base); + expect(storedLocalizations(ctx)).toEqual([ + { locale: "ko-KR", title: "프리미엄" }, + ]); + }); + + it("clears them with null when an explicit empty array is sent", async () => { + const ctx = seededCtx(); + await upsertProduct._handler(ctx, { ...base, localizations: [] }); + // Not `undefined` — Convex would read that as "leave unchanged" and + // the next push would republish the locale the operator deleted. + expect(storedLocalizations(ctx)).toBeNull(); + }); + + it("replaces them when a new set is sent", async () => { + const ctx = seededCtx(); + await upsertProduct._handler(ctx, { + ...base, + localizations: [{ locale: "ja-JP", title: "プレミアム" }], + }); + expect(storedLocalizations(ctx)).toEqual([ + { locale: "ja-JP", title: "プレミアム" }, + ]); + }); +}); + +describe("upsertProduct sales-region contract", () => { + const PROJECT = "projects_a" as Id<"projects">; + const base = { + projectId: PROJECT, + productId: "coins", + platform: "Android" as const, + type: "Consumable" as const, + title: "Coins", + }; + + beforeEach(() => { + projectMocks.byProjectId.mockResolvedValue({ + project: { _id: PROJECT }, + role: "admin", + userId: "user_a", + }); + }); + + function seededCtx(regions: "all" | string[] = ["US"]) { + const ctx = makeCtx(); + ctx.db.seed("products", { + _id: "product_android", + projectId: PROJECT, + productId: "coins", + platform: "Android", + type: "Consumable", + title: "Coins", + regions, + }); + return ctx; + } + + const storedRegions = (ctx: ReturnType) => + ( + ctx.db.rows("products").find((row) => row._id === "product_android") as + | { regions?: unknown } + | undefined + )?.regions; + + it("preserves an existing footprint when omitted", async () => { + const ctx = seededCtx(["US"]); + await upsertProduct._handler(ctx, base); + expect(storedRegions(ctx)).toEqual(["US"]); + }); + + it('stores explicit "all" and clears back to inherit with []', async () => { + const ctx = seededCtx(["US"]); + await upsertProduct._handler(ctx, { ...base, regions: "all" }); + expect(storedRegions(ctx)).toBe("all"); + + await upsertProduct._handler(ctx, { ...base, regions: [] }); + expect(storedRegions(ctx)).toBeNull(); + }); + + it("rejects explicit footprints on unsupported products", async () => { + const ctx = makeCtx(); + await expect( + upsertProduct._handler(ctx, { + ...base, + platform: "IOS", + regions: "all", + }), + ).rejects.toThrow("Sales regions are currently Android-only"); + + await expect( + upsertProduct._handler(ctx, { + ...base, + type: "Subscription", + regions: ["US"], + }), + ).rejects.toThrow("Sales regions cannot be set on a subscription"); + }); + + it("clears a stale footprint when a product becomes unsupported", async () => { + const ctx = seededCtx(["US"]); + await upsertProduct._handler(ctx, { + ...base, + type: "Subscription", + }); + expect(storedRegions(ctx)).toBeNull(); + }); + + it("revalidates preserved localizations when the product type changes", async () => { + const ctx = makeCtx(); + ctx.db.seed("products", { + _id: "product_android", + projectId: PROJECT, + productId: "coins", + platform: "Android", + type: "Subscription", + title: "Coins", + localizations: [{ locale: "ko-KR", title: "x".repeat(56) }], + }); + + await expect(upsertProduct._handler(ctx, base)).rejects.toThrow( + /accepts at most 55/, + ); + }); +}); diff --git a/packages/kit/convex/products/mutation.ts b/packages/kit/convex/products/mutation.ts index ce3881b13..5d38ba8ba 100644 --- a/packages/kit/convex/products/mutation.ts +++ b/packages/kit/convex/products/mutation.ts @@ -3,6 +3,11 @@ import { ConvexError, v } from "convex/values"; import type { Doc, Id } from "../_generated/dataModel"; import { parse as parseToml } from "smol-toml"; +import { + normalizeProductLocalizations, + productLocalizationsValidator, +} from "./localizations"; +import { normalizeProductRegions, productRegionsValidator } from "./regions"; import { resolveProjectByApiKeyFromDb, resolveProjectByIdForCurrentUserFromDb, @@ -545,6 +550,8 @@ export const upsertProduct = mutation({ type: typeValidator, title: v.string(), description: v.optional(v.string()), + localizations: v.optional(productLocalizationsValidator), + regions: v.optional(productRegionsValidator), priceAmountMicros: v.optional(v.number()), currency: v.optional(v.string()), billingPeriod: v.optional( @@ -582,6 +589,34 @@ export const upsertProduct = mutation({ throw new Error("priceAmountMicros must be a non-negative safe integer"); } + const existing: Doc<"products"> | null = await ctx.db + .query("products") + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", project._id) + .eq("platform", args.platform) + .eq("productId", args.productId), + ) + .unique(); + + // Throws on a malformed/duplicate locale or an over-long string so + // the operator sees the problem here rather than as an opaque 400 + // from Play or ASC during the next push. Store-imported products can + // have a non-English base; reserve that actual locale, not en-US. + const localizationsForValidation = + args.localizations === undefined && + existing !== null && + existing.type !== args.type + ? (existing.localizations ?? undefined) + : args.localizations; + const localizations = normalizeProductLocalizations( + localizationsForValidation, + args.platform, + args.type, + existing?.baseLocale, + ); + const regions = normalizeProductRegions(args.regions); + // iOS subscriptions REQUIRE a subscriptionGroupName upstream — // related tiers must share a group for StoreKit 2's native // upgrade/downgrade UI to work. The Apple push-sync (asc.ts) @@ -602,15 +637,28 @@ export const upsertProduct = mutation({ ); } - const existing: Doc<"products"> | null = await ctx.db - .query("products") - .withIndex("by_project_and_platform_and_product", (q) => - q - .eq("projectId", project._id) - .eq("platform", args.platform) - .eq("productId", args.productId), - ) - .unique(); + // Only the Android one-time push applies a region footprint. App + // Store Connect prices per-territory through a different resource + // this workflow does not touch, and Play's subscription update masks + // `listings` only, so a base plan's regional configs are fixed at + // create. Accepting the field for those would make it a phantom — + // stored, shown in the dashboard, and silently never applied. + const supportsRegions = + args.platform === "Android" && args.type !== "Subscription"; + // "all" is as much a declared footprint as a list is — it is what + // an operator picks to expand — so it has to be refused on the same + // surfaces, or the dashboard would offer a choice iOS silently + // drops. + const declaresFootprint = + regions === "all" || Boolean(Array.isArray(regions) && regions.length); + if (declaresFootprint && !supportsRegions) { + throw clientPayloadError( + "CLIENT_PAYLOAD_INVALID", + args.platform === "IOS" + ? "Sales regions are currently Android-only. Set App Store availability in App Store Connect." + : "Sales regions cannot be set on a subscription. Play fixes a base plan's regional configs when it is created; change them in Play Console.", + ); + } const now = Date.now(); if (existing) { @@ -622,6 +670,24 @@ export const upsertProduct = mutation({ type: args.type, title: args.title, description: args.description ?? existing.description, + // Explicitly authoritative, like every other field here: an + // operator who removes the last localization means to clear it. + // An explicitly supplied empty array is a clear request; Convex + // needs `null` for that, since `undefined` would be a no-op and + // silently keep republishing the old locales. + localizations: + args.localizations === undefined + ? existing.localizations + : (localizations ?? null), + // Retyping an Android one-time product as a subscription, or + // touching an old iOS row written before this guard existed, must + // clear the phantom footprint. An omitted field only preserves the + // stored value on a surface where the Play worker can apply it. + regions: !supportsRegions + ? null + : args.regions === undefined + ? existing.regions + : (regions ?? null), priceAmountMicros: args.priceAmountMicros ?? existing.priceAmountMicros, currency: args.currency ?? existing.currency, billingPeriod: args.billingPeriod ?? existing.billingPeriod, @@ -658,6 +724,8 @@ export const upsertProduct = mutation({ type: args.type, title: args.title, description: args.description, + localizations, + regions: supportsRegions ? regions : undefined, priceAmountMicros: args.priceAmountMicros, currency: args.currency, billingPeriod: args.billingPeriod, diff --git a/packages/kit/convex/products/play.test.ts b/packages/kit/convex/products/play.test.ts index 7e3d7dc83..ed3a56556 100644 --- a/packages/kit/convex/products/play.test.ts +++ b/packages/kit/convex/products/play.test.ts @@ -2,11 +2,18 @@ import { google, type Common } from "googleapis"; import { describe, expect, it } from "vitest"; import { + assertLegacyPathUsableFor, basePlanIdForPeriod, + buildSubscriptionRegionalConfigs, mapModernPlayOneTimeState, + mergedSubscriptionListings, + pickPlayRegionalPrice, + pickSubBasePlanPrice, moneyToMicros, playPriceMicrosToNumber, shouldFallbackToLegacyOneTimeProduct, + splitLegacyPlayListings, + upsertAndroidOneTimeProduct, upsertModernAndroidOneTimeProduct, } from "./play"; @@ -41,37 +48,88 @@ describe("mapModernPlayOneTimeState", () => { }); }); +/** + * Stubs the three Android Publisher calls the one-time upsert makes: + * `onetimeproducts.get` (read-before-write), `convertRegionPrices`, and + * the `onetimeproducts.patch` write. Each handler may return undefined + * to fall through to a default, or throw a `{code}` object to simulate + * an API error. + */ +function stubAndroidPublisher(handlers: { + get?: () => unknown; + convert?: () => unknown; + patchError?: () => unknown; +}) { + const requests: Common.GaxiosOptions[] = []; + const androidpublisher = google.androidpublisher({ + version: "v3", + adapter: async ( + request: Common.gaxios.GaxiosOptionsPrepared, + ): Promise> => { + requests.push(request); + const url = new URL(String(request.url)); + let data: unknown = {}; + + if (url.pathname.endsWith("/pricing:convertRegionPrices")) { + data = handlers.convert?.() ?? {}; + } else if (request.method === "PATCH" && handlers.patchError) { + throw handlers.patchError(); + } else if (request.method === "GET") { + const result = handlers.get?.(); + if (result === undefined) + throw Object.assign(new Error("no"), { code: 404 }); + data = result; + } + + return Object.assign(new Response(null, { status: 200 }), { + config: request, + data: data as T, + }); + }, + }); + return { androidpublisher, requests }; +} + +const BASE_ARGS = { + packageName: "com.example.moonlit", + productId: "hero.sage", + title: "Moon Sage", + description: "Unlock Moon Sage", + priceAmountMicros: 24_990_000, + currency: "USD", +}; + +function patchRequest(requests: Common.GaxiosOptions[]) { + return requests.find((request) => request.method === "PATCH"); +} + +function regionalConfigs(request: Common.GaxiosOptions | undefined) { + const data = request?.data as + | { + purchaseOptions?: Array<{ + regionalPricingAndAvailabilityConfigs?: Array<{ + regionCode?: string; + availability?: string; + price?: unknown; + }>; + newRegionsConfig?: unknown; + }>; + } + | undefined; + return data?.purchaseOptions?.[0]; +} + describe("upsertModernAndroidOneTimeProduct", () => { it("uses the generated lowercase one-time-product PATCH route", async () => { - let capturedRequest: Common.GaxiosOptions | undefined; - const androidpublisher = google.androidpublisher({ - version: "v3", - adapter: async ( - request: Common.gaxios.GaxiosOptionsPrepared, - ): Promise> => { - capturedRequest = request; - return Object.assign(new Response(null, { status: 200 }), { - config: request, - data: {} as T, - }); - }, - }); + const { androidpublisher, requests } = stubAndroidPublisher({}); - await upsertModernAndroidOneTimeProduct( - androidpublisher, - { - packageName: "com.example.moonlit", - productId: "hero.sage", - title: "Moon Sage", - description: "Unlock Moon Sage", - priceAmountMicros: 24_990_000, - currency: "USD", - }, - { allowCreate: true }, - ); + await upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: true, + }); - expect(capturedRequest).toBeDefined(); - const requestUrl = new URL(String(capturedRequest?.url)); + const request = patchRequest(requests); + expect(request).toBeDefined(); + const requestUrl = new URL(String(request?.url)); expect(requestUrl.pathname).toBe( "/androidpublisher/v3/applications/com.example.moonlit/onetimeproducts/hero.sage", ); @@ -82,7 +140,7 @@ describe("upsertModernAndroidOneTimeProduct", () => { expect(requestUrl.searchParams.get("regionsVersion.version")).toBe( "2022/01", ); - expect(capturedRequest?.data).toMatchObject({ + expect(request?.data).toMatchObject({ packageName: "com.example.moonlit", productId: "hero.sage", listings: [ @@ -92,23 +150,329 @@ describe("upsertModernAndroidOneTimeProduct", () => { description: "Unlock Moon Sage", }, ], - purchaseOptions: [ + }); + }); + + // Issue #288: the push wrote a single hardcoded `regionCode: "US"` + // config, so products were silently unbuyable in every other market. + it("publishes every region Play converts the base price into", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + convert: () => ({ + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + KR: { + regionCode: "KR", + price: { currencyCode: "KRW", units: "33000", nanos: 0 }, + }, + JP: { + regionCode: "JP", + price: { currencyCode: "JPY", units: "3800", nanos: 0 }, + }, + }, + convertedOtherRegionsPrice: { + usdPrice: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + eurPrice: { currencyCode: "EUR", units: "22", nanos: 990_000_000 }, + }, + }), + }); + + const outcome = await upsertModernAndroidOneTimeProduct( + androidpublisher, + BASE_ARGS, + { allowCreate: true }, + ); + + const option = regionalConfigs(patchRequest(requests)); + expect(option?.regionalPricingAndAvailabilityConfigs).toEqual([ + { + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + { + regionCode: "KR", + availability: "AVAILABLE", + price: { currencyCode: "KRW", units: "33000", nanos: 0 }, + }, + { + regionCode: "JP", + availability: "AVAILABLE", + price: { currencyCode: "JPY", units: "3800", nanos: 0 }, + }, + ]); + // Markets Play launches later must be covered too, or the product + // silently stops being available as Play expands. + expect(option?.newRegionsConfig).toEqual({ + availability: "AVAILABLE", + usdPrice: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + eurPrice: { currencyCode: "EUR", units: "22", nanos: 990_000_000 }, + }); + expect(outcome.manualAction).toBeUndefined(); + }); + + // The same replace semantics apply to the purchase-option list itself: + // kit only models `buy`, so anything the operator added in Play Console + // has to be echoed back or the push deletes it. + it("never drops a purchase option kit doesn't model", async () => { + const rentOption = { + purchaseOptionId: "rent-48h", + rentOption: { rentalPeriod: "P2D" }, + regionalPricingAndAvailabilityConfigs: [ { - purchaseOptionId: "buy", - regionalPricingAndAvailabilityConfigs: [ - { - regionCode: "US", - availability: "AVAILABLE", - price: { - currencyCode: "USD", - units: "24", - nanos: 990_000_000, - }, - }, - ], + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "4", nanos: 990_000_000 }, }, ], + }; + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [{ purchaseOptionId: "buy" }, rentOption], + }), + convert: () => ({ + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + }, + }), + }); + + await upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: false, + }); + + const data = patchRequest(requests)?.data as { + purchaseOptions?: Array<{ purchaseOptionId?: string }>; + }; + expect(data.purchaseOptions?.map((o) => o.purchaseOptionId)).toEqual([ + "buy", + "rent-48h", + ]); + expect(data.purchaseOptions?.[1]).toEqual(rentOption); + }); + + // `updateMask: "purchaseOptions"` REPLACES the repeated field, so an + // update that didn't read first would delete every region it omits — + // silently un-selling a live product outside the converted set. + it("never drops a region the product already had", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + newRegionsConfig: { + availability: "NO_LONGER_AVAILABLE", + usdPrice: { currencyCode: "USD", units: "19", nanos: 0 }, + eurPrice: { currencyCode: "EUR", units: "17", nanos: 0 }, + }, + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "19", nanos: 0 }, + }, + { + regionCode: "BR", + availability: "AVAILABLE", + price: { currencyCode: "BRL", units: "99", nanos: 0 }, + }, + { + regionCode: "RU", + availability: "NO_LONGER_AVAILABLE", + price: { currencyCode: "RUB", units: "1500", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => ({ + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + }, + }), }); + + await upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: false, + }); + + const configs = + regionalConfigs(patchRequest(requests)) + ?.regionalPricingAndAvailabilityConfigs ?? []; + const byRegion = new Map(configs.map((c) => [c.regionCode, c])); + + // Converted region takes the new price. + expect(byRegion.get("US")?.price).toEqual({ + currencyCode: "USD", + units: "24", + nanos: 990_000_000, + }); + // Unconverted regions survive untouched rather than being deleted. + expect(byRegion.get("BR")?.price).toEqual({ + currencyCode: "BRL", + units: "99", + nanos: 0, + }); + // A market the operator deliberately withdrew stays withdrawn. + expect(byRegion.get("RU")?.availability).toBe("NO_LONGER_AVAILABLE"); + }); + + it("preserves an operator's per-region availability while repricing", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "KR", + availability: "NO_LONGER_AVAILABLE", + price: { currencyCode: "KRW", units: "1000", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => ({ + convertedRegionPrices: { + KR: { + regionCode: "KR", + price: { currencyCode: "KRW", units: "33000", nanos: 0 }, + }, + }, + }), + }); + + await upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: false, + }); + + expect( + regionalConfigs(patchRequest(requests)) + ?.regionalPricingAndAvailabilityConfigs, + ).toEqual([ + { + regionCode: "KR", + availability: "NO_LONGER_AVAILABLE", + price: { currencyCode: "KRW", units: "33000", nanos: 0 }, + }, + ]); + }); + + it("reports a manual action instead of silently shipping a US-only product", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + convert: () => { + throw Object.assign(new Error("conversion unavailable"), { code: 500 }); + }, + }); + + const outcome = await upsertModernAndroidOneTimeProduct( + androidpublisher, + BASE_ARGS, + { allowCreate: true }, + ); + + expect( + regionalConfigs(patchRequest(requests)) + ?.regionalPricingAndAvailabilityConfigs, + ).toEqual([ + { + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + ]); + expect(outcome.manualAction).toMatchObject({ + productId: "hero.sage", + code: "regional_pricing_incomplete", + }); + expect(outcome.manualAction?.message).toContain("Play Console"); + }); + + it("refuses to publish a non-USD price to the US fallback region", async () => { + const { androidpublisher } = stubAndroidPublisher({ + convert: () => { + throw Object.assign(new Error("conversion unavailable"), { code: 500 }); + }, + }); + + // Play pairs each region with its own currency, so a KRW amount on + // regionCode "US" would 400. Fail with an actionable message instead. + await expect( + upsertModernAndroidOneTimeProduct( + androidpublisher, + { ...BASE_ARGS, currency: "KRW", priceAmountMicros: 700_000_000 }, + { allowCreate: true }, + ), + ).rejects.toThrow(/could not convert KRW/); + }); +}); + +describe("buildSubscriptionRegionalConfigs", () => { + it("maps every converted region and opens it to new subscribers", () => { + expect( + buildSubscriptionRegionalConfigs( + { + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "9", nanos: 990_000_000 }, + }, + KR: { + regionCode: "KR", + price: { currencyCode: "KRW", units: "13000", nanos: 0 }, + }, + }, + }, + { currencyCode: "USD", units: "9", nanos: 990_000_000 }, + "premium_monthly", + ), + ).toEqual([ + { + regionCode: "US", + price: { currencyCode: "USD", units: "9", nanos: 990_000_000 }, + newSubscriberAvailability: true, + }, + { + regionCode: "KR", + price: { currencyCode: "KRW", units: "13000", nanos: 0 }, + newSubscriberAvailability: true, + }, + ]); + }); + + it("falls back to the US base price when conversion is unavailable", () => { + expect( + buildSubscriptionRegionalConfigs( + undefined, + { currencyCode: "USD", units: "9", nanos: 990_000_000 }, + "premium_monthly", + ), + ).toEqual([ + { + regionCode: "US", + price: { currencyCode: "USD", units: "9", nanos: 990_000_000 }, + newSubscriberAvailability: true, + }, + ]); + }); + + it("rejects a non-USD fallback rather than emitting an invalid config", () => { + expect(() => + buildSubscriptionRegionalConfigs( + undefined, + { currencyCode: "KRW", units: "13000", nanos: 0 }, + "premium_monthly", + ), + ).toThrow(/could not convert KRW/); }); }); @@ -272,3 +636,1661 @@ describe("basePlanIdForPeriod", () => { expect(basePlanIdForPeriod("P9X")).toBe("monthly"); }); }); + +describe("localized listings", () => { + it("keeps every legacy pull locale and its non-English default", () => { + expect( + splitLegacyPlayListings({ + sku: "hero.sage", + defaultLanguage: "ko-KR", + listings: { + "en-US": { title: "Moon Sage", description: "Unlock" }, + "ko-KR": { title: "문 세이지", description: "전체 해금" }, + "ja-JP": { title: "ムーンセージ" }, + }, + }), + ).toEqual({ + title: "문 세이지", + description: "전체 해금", + baseLocale: "ko-KR", + localizations: [ + { locale: "en-US", title: "Moon Sage", description: "Unlock" }, + { locale: "ja-JP", title: "ムーンセージ" }, + ], + }); + }); + + it("publishes the base listing plus every operator locale", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + convert: () => ({ + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + }, + }), + }); + + await upsertModernAndroidOneTimeProduct( + androidpublisher, + { + ...BASE_ARGS, + localizations: [ + { locale: "ko-KR", title: "문 세이지", description: "전체 해금" }, + { locale: "ja-JP", title: "ムーンセージ" }, + ], + }, + { allowCreate: true }, + ); + + const data = patchRequest(requests)?.data as { + listings?: Array<{ + languageCode?: string; + title?: string; + description?: string; + }>; + }; + expect(data.listings).toEqual([ + { + languageCode: "en-US", + title: "Moon Sage", + description: "Unlock Moon Sage", + }, + { languageCode: "ko-KR", title: "문 세이지", description: "전체 해금" }, + // Play requires a description, so a locale that omits one reuses + // its title rather than sending null. + { + languageCode: "ja-JP", + title: "ムーンセージ", + description: "ムーンセージ", + }, + ]); + }); + + it("sends exactly the pre-localization listing when none are set", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({}); + + await upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: true, + }); + + expect( + (patchRequest(requests)?.data as { listings?: unknown[] }).listings, + ).toEqual([ + { + languageCode: "en-US", + title: "Moon Sage", + description: "Unlock Moon Sage", + }, + ]); + }); + + // `updateMask` replaces the listings array too, so a locale added + // directly in Play Console would be deleted by a kit push. + it("never drops a locale the operator added in Play Console", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + listings: [ + { languageCode: "en-US", title: "Old", description: "Old" }, + { languageCode: "de-DE", title: "Mondweiser", description: "Alles" }, + ], + purchaseOptions: [{ purchaseOptionId: "buy" }], + }), + convert: () => ({ + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + }, + }), + }); + + await upsertModernAndroidOneTimeProduct( + androidpublisher, + { + ...BASE_ARGS, + localizations: [{ locale: "ko-KR", title: "문 세이지" }], + }, + { allowCreate: false }, + ); + + const listings = ( + patchRequest(requests)?.data as { + listings?: Array<{ languageCode?: string; title?: string }>; + } + ).listings; + const byLocale = new Map(listings?.map((l) => [l.languageCode, l.title])); + // kit's own locales win… + expect(byLocale.get("en-US")).toBe("Moon Sage"); + expect(byLocale.get("ko-KR")).toBe("문 세이지"); + // …and the Play-Console-only locale survives. + expect(byLocale.get("de-DE")).toBe("Mondweiser"); + expect(listings?.[0]?.languageCode).toBe("en-US"); + }); +}); + +// Issue #288 follow-up found in review: conversion failure on an UPDATE +// preserved every existing region verbatim, which silently threw away +// the price change the operator had just made. +describe("conversion failure on an update", () => { + it("still applies the new amount to regions using the base currency", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "19", nanos: 0 }, + }, + { + regionCode: "KR", + availability: "AVAILABLE", + price: { currencyCode: "KRW", units: "25000", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => { + throw Object.assign(new Error("nope"), { code: 500 }); + }, + }); + + const outcome = await upsertModernAndroidOneTimeProduct( + androidpublisher, + BASE_ARGS, + { allowCreate: false }, + ); + + const configs = + regionalConfigs(patchRequest(requests)) + ?.regionalPricingAndAvailabilityConfigs ?? []; + const byRegion = new Map(configs.map((c) => [c.regionCode, c])); + // USD region takes the operator's new $24.99… + expect(byRegion.get("US")?.price).toEqual({ + currencyCode: "USD", + units: "24", + nanos: 990_000_000, + }); + // …and the KRW region keeps its old price rather than being given + // a dollar amount Play would reject. + expect(byRegion.get("KR")?.price).toEqual({ + currencyCode: "KRW", + units: "25000", + nanos: 0, + }); + // The operator is told the rest did not move. + expect(outcome.manualAction?.message).toContain("1 region(s)"); + expect(outcome.manualAction?.message).toContain( + "kept their previous price", + ); + }); +}); + +describe("existing purchase-option fields", () => { + it("preserves offer tags and tax settings on the buy option", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + state: "ACTIVE", + offerTags: [{ tag: "launch" }], + taxAndComplianceSettings: { withdrawalRightType: "DIGITAL" }, + }, + ], + }), + convert: () => ({ + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + }, + }), + }); + + await upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: false, + }); + + const option = regionalConfigs(patchRequest(requests)) as unknown as { + offerTags?: unknown; + taxAndComplianceSettings?: unknown; + state?: unknown; + }; + expect(option.offerTags).toEqual([{ tag: "launch" }]); + expect(option.taxAndComplianceSettings).toEqual({ + withdrawalRightType: "DIGITAL", + }); + // `state` is output-only; echoing it back would 400. + expect(option.state).toBeUndefined(); + }); +}); + +// CodeRabbit caught this: `convertedRegionPrices` is an object, so a +// bare truthiness check treats `{}` — Play answering with no +// conversions — as success. The product would ship US-only while the +// sync reported a clean push with no manual action. +describe("empty conversion response", () => { + it("is treated as a failed conversion, not a silent success", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + convert: () => ({ convertedRegionPrices: {} }), + }); + + const outcome = await upsertModernAndroidOneTimeProduct( + androidpublisher, + BASE_ARGS, + { allowCreate: true }, + ); + + expect( + regionalConfigs(patchRequest(requests)) + ?.regionalPricingAndAvailabilityConfigs, + ).toEqual([ + { + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + ]); + expect(outcome.manualAction?.code).toBe("regional_pricing_incomplete"); + }); + + it("also enables base-currency repricing on an update", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "19", nanos: 0 }, + }, + ], + }, + ], + }), + // A region map with only price-less entries is equally empty. + convert: () => ({ convertedRegionPrices: { KR: { regionCode: "KR" } } }), + }); + + const outcome = await upsertModernAndroidOneTimeProduct( + androidpublisher, + BASE_ARGS, + { allowCreate: false }, + ); + + const byRegion = new Map( + ( + regionalConfigs(patchRequest(requests)) + ?.regionalPricingAndAvailabilityConfigs ?? [] + ).map((c) => [c.regionCode, c]), + ); + expect(byRegion.get("US")?.price).toEqual({ + currencyCode: "USD", + units: "24", + nanos: 990_000_000, + }); + expect(outcome.manualAction?.code).toBe("regional_pricing_incomplete"); + }); +}); + +// Found by live Play E2E, not by review: `convertRegionPrices` always +// converts using Play's CURRENT region definitions, so pinning an older +// regions version on the write makes Play reject any region whose +// currency changed since — "Invalid currency for region code BG … +// Expected BGN but got EUR". +describe("regions version alignment", () => { + it("writes at the version the conversion was computed at", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + convert: () => ({ + regionVersion: { version: "2026/02" }, + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + }, + }), + }); + + await upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: true, + }); + + expect( + new URL(String(patchRequest(requests)?.url)).searchParams.get( + "regionsVersion.version", + ), + ).toBe("2026/02"); + }); + + it("falls back to the historical pin when there is no conversion", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + convert: () => { + throw Object.assign(new Error("nope"), { code: 500 }); + }, + }); + + await upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: true, + }); + + expect( + new URL(String(patchRequest(requests)?.url)).searchParams.get( + "regionsVersion.version", + ), + ).toBe("2022/01"); + }); +}); + +// Play refuses to drop a region once a purchase option has it ("Cannot +// remove region once it has been added"), so an explicit footprint has +// to withdraw the others rather than omit them. +describe("explicit sales regions", () => { + const converted = { + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + KR: { + regionCode: "KR", + price: { currencyCode: "KRW", units: "33000", nanos: 0 }, + }, + DE: { + regionCode: "DE", + price: { currencyCode: "EUR", units: "22", nanos: 0 }, + }, + }, + convertedOtherRegionsPrice: { + usdPrice: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + eurPrice: { currencyCode: "EUR", units: "22", nanos: 0 }, + }, + }; + + it("adds only the listed regions on a create", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + convert: () => converted, + }); + + await upsertModernAndroidOneTimeProduct( + androidpublisher, + { ...BASE_ARGS, regions: ["US", "KR"] }, + { allowCreate: true }, + ); + + const option = regionalConfigs(patchRequest(requests)); + expect( + option?.regionalPricingAndAvailabilityConfigs?.map((c) => c.regionCode), + ).toEqual(["US", "KR"]); + // An explicit footprint must not opt into markets Play adds later. + expect(option?.newRegionsConfig).toBeUndefined(); + }); + + it("withdraws an excluded region instead of removing it", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + newRegionsConfig: { + availability: "AVAILABLE", + usdPrice: { currencyCode: "USD", units: "19", nanos: 0 }, + eurPrice: { currencyCode: "EUR", units: "17", nanos: 0 }, + }, + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "19", nanos: 0 }, + }, + { + regionCode: "DE", + availability: "AVAILABLE", + price: { currencyCode: "EUR", units: "17", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => converted, + }); + + await upsertModernAndroidOneTimeProduct( + androidpublisher, + { ...BASE_ARGS, regions: ["US", "KR"] }, + { allowCreate: false }, + ); + + const option = regionalConfigs(patchRequest(requests)); + const byRegion = new Map( + (option?.regionalPricingAndAvailabilityConfigs ?? []).map((c) => [ + c.regionCode, + c, + ]), + ); + expect(byRegion.get("US")?.availability).toBe("AVAILABLE"); + expect(byRegion.get("KR")?.availability).toBe("AVAILABLE"); + // DE stays in the list — Play won't accept its removal — but stops + // being sellable. + expect(byRegion.get("DE")?.availability).toBe("NO_LONGER_AVAILABLE"); + // An already-enabled "other regions" config is spread forward from + // the existing option, so it has to be actively withdrawn. + expect( + (option?.newRegionsConfig as { availability?: string } | undefined) + ?.availability, + ).toBe("NO_LONGER_AVAILABLE"); + }); + + it("withdraws new regions when Play omits their available state", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + newRegionsConfig: { + usdPrice: { currencyCode: "USD", units: "19", nanos: 0 }, + eurPrice: { currencyCode: "EUR", units: "17", nanos: 0 }, + }, + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "US", + price: { currencyCode: "USD", units: "19", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => converted, + }); + + await upsertModernAndroidOneTimeProduct( + androidpublisher, + { ...BASE_ARGS, regions: ["US"] }, + { allowCreate: false }, + ); + + expect(regionalConfigs(patchRequest(requests))?.newRegionsConfig).toEqual({ + availability: "NO_LONGER_AVAILABLE", + usdPrice: { currencyCode: "USD", units: "19", nanos: 0 }, + eurPrice: { currencyCode: "EUR", units: "17", nanos: 0 }, + }); + }); + + it("fails instead of leaving an excluded pre-release region sellable", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "DE", + availability: "AVAILABLE_IF_RELEASED", + price: { currencyCode: "EUR", units: "17", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => converted, + }); + + await expect( + upsertModernAndroidOneTimeProduct( + androidpublisher, + { ...BASE_ARGS, regions: ["US"] }, + { allowCreate: false }, + ), + ).rejects.toThrow(/cannot withdraw region DE.*AVAILABLE_IF_RELEASED/); + expect(requests.some((request) => request.method === "PATCH")).toBe(false); + }); +}); + +// Both pull-ranking fixes shipped without coverage. The KRW case is the +// one the fix was for; the USD cases are the regression it originally +// caused — Play prices several non-US regions in USD, so matching on +// currency alone resolved a plain USD row to whichever Play listed first. +describe("pickPlayRegionalPrice", () => { + // Ordered the way Play returns them: US is NOT first. + const candidates = [ + { regionCode: "EC", currencyCode: "USD" }, + { regionCode: "JP", currencyCode: "JPY" }, + { regionCode: "KR", currencyCode: "KRW" }, + { regionCode: "US", currencyCode: "USD" }, + ]; + + it("prefers US within the authored currency", () => { + expect(pickPlayRegionalPrice(candidates, "USD")?.regionCode).toBe("US"); + }); + + it("falls back to any region in the authored currency", () => { + expect(pickPlayRegionalPrice(candidates, "KRW")?.regionCode).toBe("KR"); + expect(pickPlayRegionalPrice(candidates, "JPY")?.regionCode).toBe("JP"); + }); + + it("uses US, then any USD region, for a row kit has not priced", () => { + expect(pickPlayRegionalPrice(candidates)?.regionCode).toBe("US"); + expect( + pickPlayRegionalPrice(candidates.filter((c) => c.regionCode !== "US")) + ?.regionCode, + ).toBe("EC"); + }); + + it("ignores an authored currency no region offers", () => { + expect(pickPlayRegionalPrice(candidates, "GBP")?.regionCode).toBe("US"); + }); + + it("is safe on an empty candidate list", () => { + expect(pickPlayRegionalPrice([], "USD")).toBeUndefined(); + }); +}); + +describe("pickSubBasePlanPrice", () => { + const sub = { + basePlans: [ + { + basePlanId: "monthly", + regionalConfigs: [ + { price: { currencyCode: "USD", units: "9", nanos: 990_000_000 } }, + ], + }, + { + basePlanId: "yearly", + regionalConfigs: [ + { price: { currencyCode: "KRW", units: "13000", nanos: 0 } }, + ], + }, + ], + }; + + it("prefers the authored currency over the USD default", () => { + expect(pickSubBasePlanPrice(sub, "KRW").currency).toBe("KRW"); + }); + + it("still defaults to USD for a row kit has not priced", () => { + expect(pickSubBasePlanPrice(sub).currency).toBe("USD"); + }); + + it("keeps the basePlanId paired with the price it picked", () => { + expect(pickSubBasePlanPrice(sub, "KRW").basePlanId).toBe("yearly"); + }); +}); + +// Round 4: the regions-version fix only covered the success branch. On +// the degraded path the write still echoes configs Play generated at a +// NEWER version, so pinning the historical one reproduces the very +// BG/BGN rejection the fix was for. +describe("regions version on the degraded path", () => { + it("follows the version the preserved configs came from", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + regionsVersion: { version: "2026/02" }, + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "BG", + availability: "AVAILABLE", + price: { currencyCode: "EUR", units: "17", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => { + throw Object.assign(new Error("nope"), { code: 500 }); + }, + }); + + await upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: false, + }); + + expect( + new URL(String(patchRequest(requests)?.url)).searchParams.get( + "regionsVersion.version", + ), + ).toBe("2026/02"); + }); +}); + +// The footprint has to survive a trip through the PUBLIC entry point, +// not just the inner builder. It once shipped completely dead — the +// legacy guard sat at the top of the wrapper, so every footprint threw +// before Play was contacted — while every test here stayed green +// because every one of them called the inner function directly. +describe("sales regions through the public entry point", () => { + function stubWrapper(handlers: { + get?: () => unknown; + convert?: () => unknown; + modernError?: () => unknown; + }) { + const requests: Common.GaxiosOptions[] = []; + const androidpublisher = google.androidpublisher({ + version: "v3", + adapter: async ( + request: Common.gaxios.GaxiosOptionsPrepared, + ): Promise> => { + requests.push(request); + const url = new URL(String(request.url)); + let data: unknown = {}; + if (url.pathname.endsWith("/pricing:convertRegionPrices")) { + data = handlers.convert?.() ?? {}; + } else if (url.pathname.includes("/onetimeproducts")) { + if (handlers.modernError && request.method === "PATCH") { + throw handlers.modernError(); + } + if (request.method === "GET") { + const result = handlers.get?.(); + if (result === undefined) { + throw Object.assign(new Error("no"), { code: 404 }); + } + data = result; + } + } + return Object.assign(new Response(null, { status: 200 }), { + config: request, + data: data as T, + }); + }, + }); + // The wrapper activates the purchase option after a successful + // modern upsert, through the raw auth client rather than the + // generated surface. + const activations: unknown[] = []; + const auth = { + getClient: async () => ({ + request: async (options: unknown) => { + activations.push(options); + return { data: {} }; + }, + }), + } as unknown as Parameters[1]; + return { androidpublisher, auth, requests, activations }; + } + + it("reaches Play with the footprint applied", async () => { + const { androidpublisher, auth, requests, activations } = stubWrapper({ + convert: () => ({ + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990000000 }, + }, + KR: { + regionCode: "KR", + price: { currencyCode: "KRW", units: "33000", nanos: 0 }, + }, + JP: { + regionCode: "JP", + price: { currencyCode: "JPY", units: "3800", nanos: 0 }, + }, + DE: { + regionCode: "DE", + price: { currencyCode: "EUR", units: "22", nanos: 0 }, + }, + }, + convertedOtherRegionsPrice: { + usdPrice: { currencyCode: "USD", units: "24", nanos: 990000000 }, + eurPrice: { currencyCode: "EUR", units: "22", nanos: 0 }, + }, + }), + }); + + await upsertAndroidOneTimeProduct( + androidpublisher, + auth, + { ...BASE_ARGS, regions: ["US", "KR", "JP"] }, + { allowCreate: true }, + ); + + const option = regionalConfigs(patchRequest(requests)); + expect( + option?.regionalPricingAndAvailabilityConfigs?.map((c) => c.regionCode), + ).toEqual(["US", "KR", "JP"]); + // DE was priced by Play and deliberately not published. + expect( + option?.regionalPricingAndAvailabilityConfigs?.some( + (c) => c.regionCode === "DE", + ), + ).toBe(false); + // An explicit footprint must not opt the product into markets Play + // launches later, even though the conversion offered a price. + expect(option?.newRegionsConfig).toBeUndefined(); + expect(activations).toHaveLength(1); + }); + + it("still sells everywhere when no footprint is given", async () => { + const { androidpublisher, auth, requests } = stubWrapper({ + convert: () => ({ + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990000000 }, + }, + DE: { + regionCode: "DE", + price: { currencyCode: "EUR", units: "22", nanos: 0 }, + }, + }, + convertedOtherRegionsPrice: { + usdPrice: { currencyCode: "USD", units: "24", nanos: 990000000 }, + eurPrice: { currencyCode: "EUR", units: "22", nanos: 0 }, + }, + }), + }); + + await upsertAndroidOneTimeProduct(androidpublisher, auth, BASE_ARGS, { + allowCreate: true, + }); + + const option = regionalConfigs(patchRequest(requests)); + expect( + option?.regionalPricingAndAvailabilityConfigs?.map((c) => c.regionCode), + ).toEqual(["US", "DE"]); + expect(option?.newRegionsConfig).toBeDefined(); + }); + + it("refuses a footprint only on the legacy fallback, and says why", async () => { + const { androidpublisher, auth, requests } = stubWrapper({ + modernError: () => + Object.assign(new Error("Please use the InAppProducts API"), { + code: 400, + }), + }); + + await expect( + upsertAndroidOneTimeProduct( + androidpublisher, + auth, + { ...BASE_ARGS, regions: ["US", "KR"] }, + { allowCreate: true }, + ), + ).rejects.toThrow(/specifies sales regions/); + // The modern failure has to survive into the message, or the + // operator sees "remove your regions" for what was really a + // permissions or schema error. + await expect( + upsertAndroidOneTimeProduct( + androidpublisher, + auth, + { ...BASE_ARGS, regions: ["US", "KR"] }, + { allowCreate: true }, + ), + ).rejects.toThrow(/InAppProducts API/); + // And it must never fire before Play was actually contacted. + expect( + requests.some((r) => String(r.url).includes("/onetimeproducts")), + ).toBe(true); + }); + + it("lets a product without a footprint use the legacy fallback", async () => { + const { androidpublisher, auth, requests } = stubWrapper({ + modernError: () => + Object.assign(new Error("Please use the InAppProducts API"), { + code: 400, + }), + }); + + await upsertAndroidOneTimeProduct(androidpublisher, auth, BASE_ARGS, { + allowCreate: true, + }); + + expect(requests.some((r) => String(r.url).includes("/inappproducts"))).toBe( + true, + ); + }); +}); + +describe("sales regions edge cases", () => { + it("re-enables a region added back to the list", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "KR", + availability: "NO_LONGER_AVAILABLE", + price: { currencyCode: "KRW", units: "1000", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => ({ + convertedRegionPrices: { + KR: { + regionCode: "KR", + price: { currencyCode: "KRW", units: "33000", nanos: 0 }, + }, + }, + }), + }); + + await upsertModernAndroidOneTimeProduct( + androidpublisher, + { ...BASE_ARGS, regions: ["KR"] }, + { allowCreate: false }, + ); + + // Without this the footprint is a one-way door: a region kit + // withdrew could never be sold in again. + expect( + regionalConfigs(patchRequest(requests)) + ?.regionalPricingAndAvailabilityConfigs?.[0]?.availability, + ).toBe("AVAILABLE"); + }); + + it("refuses to publish the US fallback to a footprint that excludes it", async () => { + const { androidpublisher } = stubAndroidPublisher({ + convert: () => { + throw Object.assign(new Error("nope"), { code: 500 }); + }, + }); + + await expect( + upsertModernAndroidOneTimeProduct( + androidpublisher, + { ...BASE_ARGS, regions: ["KR", "JP"] }, + { allowCreate: true }, + ), + ).rejects.toThrow(/exclude the US fallback/); + }); + + it("does not withdraw the last live region when conversion fails", async () => { + const { androidpublisher } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "19", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => { + throw Object.assign(new Error("nope"), { code: 500 }); + }, + }); + + // With no KRW conversion or existing KR price, applying this footprint + // would only withdraw US and leave the product unavailable everywhere. + await expect( + upsertModernAndroidOneTimeProduct( + androidpublisher, + { ...BASE_ARGS, regions: ["KR"] }, + { allowCreate: false }, + ), + ).rejects.toThrow(/exclude the US fallback/); + }); + + it("does not count withdrawn regions as prices left stale", async () => { + const { androidpublisher } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "19", nanos: 0 }, + }, + { + regionCode: "DE", + availability: "AVAILABLE", + price: { currencyCode: "EUR", units: "17", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => { + throw Object.assign(new Error("nope"), { code: 500 }); + }, + }); + + const outcome = await upsertModernAndroidOneTimeProduct( + androidpublisher, + { ...BASE_ARGS, regions: ["US"] }, + { allowCreate: false }, + ); + + // DE was withdrawn on purpose; calling it a stale price would be + // alarming and wrong. + expect(outcome.manualAction).toBeDefined(); + expect(outcome.manualAction?.message).toContain("applied to 1 region(s)"); + expect(outcome.manualAction?.message).not.toContain( + "kept their previous price", + ); + }); +}); + +// Round 5: the round-4 stale filter narrowed the numerator but left +// `repriced` counting a different set, so a withdrawn-but-repriced +// region made `stale` negative and silently dropped the whole warning. +describe("manual-action arithmetic", () => { + it("reports stale regions when a withdrawn region was also repriced", async () => { + const { androidpublisher } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "19", nanos: 0 }, + }, + // Same currency as the base price, so it gets repriced, + // but it is not live — it must not offset the stale count. + { + regionCode: "EC", + availability: "NO_LONGER_AVAILABLE", + price: { currencyCode: "USD", units: "19", nanos: 0 }, + }, + { + regionCode: "DE", + availability: "AVAILABLE", + price: { currencyCode: "EUR", units: "17", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => { + throw Object.assign(new Error("nope"), { code: 500 }); + }, + }); + + const outcome = await upsertModernAndroidOneTimeProduct( + androidpublisher, + BASE_ARGS, + { allowCreate: false }, + ); + + expect(outcome.manualAction?.message).toContain("applied to 1 region(s)"); + expect(outcome.manualAction?.message).toContain( + "1 region(s) kept their previous price", + ); + }); + + it("treats a config with no availability as live", async () => { + const { androidpublisher } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "DE", + price: { currencyCode: "EUR", units: "17", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => { + throw Object.assign(new Error("nope"), { code: 500 }); + }, + }); + + const outcome = await upsertModernAndroidOneTimeProduct( + androidpublisher, + BASE_ARGS, + { allowCreate: false }, + ); + + expect(outcome.manualAction?.message).toContain( + "1 region(s) kept their previous price", + ); + }); + + // Every regions test called `upsertModernAndroidOneTimeProduct` + // directly, so a guard hoisted into the WRAPPER broke the feature + // outright while the suite stayed green. These go through the wrapper. + it("reaches the modern API for a product with a region footprint", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + convert: () => ({ + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + }, + }), + }); + const auth = { + getClient: async () => ({ request: async () => ({ data: {} }) }), + } as never; + + await upsertAndroidOneTimeProduct( + androidpublisher, + auth, + { ...BASE_ARGS, regions: ["US"] }, + { allowCreate: true }, + ); + + expect(patchRequest(requests)).toBeDefined(); + }); + + it("explains a legacy fallback without hiding the modern failure", async () => { + const { androidpublisher } = stubAndroidPublisher({ + patchError: () => + Object.assign(new Error("Please use the InAppProducts API"), { + code: 400, + }), + }); + const auth = { + getClient: async () => ({ request: async () => ({ data: {} }) }), + } as never; + + await expect( + upsertAndroidOneTimeProduct( + androidpublisher, + auth, + { ...BASE_ARGS, regions: ["US"] }, + { allowCreate: true }, + ), + ).rejects.toThrow(/InAppProducts API/); + }); + + it("refuses the legacy path for a product with a region footprint", () => { + // Raised before the modern attempt, so a genuine modern failure is + // never replaced by this message. + expect(() => + assertLegacyPathUsableFor({ ...BASE_ARGS, regions: ["US"] }), + ).toThrow(/legacy in-app-products API/); + expect(() => assertLegacyPathUsableFor(BASE_ARGS)).not.toThrow(); + expect(() => + assertLegacyPathUsableFor({ ...BASE_ARGS, regions: "all" }), + ).not.toThrow(); + }); +}); + +// This merge is the only thing stopping a subscription patch deleting +// locales an operator added in Play Console — `updateMask: "listings"` +// replaces the array. It shipped with no coverage. +describe("mergedSubscriptionListings", () => { + function stubSubscriptions(handler: () => unknown) { + return google.androidpublisher({ + version: "v3", + retryConfig: { retry: 0, noResponseRetries: 0 }, + adapter: async ( + request: Common.gaxios.GaxiosOptionsPrepared, + ): Promise> => { + const data = handler(); + return Object.assign(new Response(null, { status: 200 }), { + config: request, + data: data as T, + }); + }, + }); + } + + it("keeps a Play-Console locale and puts the base listing first", async () => { + const listings = await mergedSubscriptionListings( + stubSubscriptions(() => ({ + listings: [ + { languageCode: "de-DE", title: "Alt", description: "Alt" }, + { languageCode: "en-US", title: "Old", description: "Old" }, + ], + })), + "com.example.app", + "premium_monthly", + { + title: "Premium", + description: "All features", + localizations: [{ locale: "ko-KR", title: "프리미엄" }], + }, + ); + + expect(listings[0]?.languageCode).toBe("en-US"); + const byLocale = new Map(listings.map((l) => [l.languageCode, l.title])); + expect(byLocale.get("en-US")).toBe("Premium"); + expect(byLocale.get("ko-KR")).toBe("프리미엄"); + // The locale kit knows nothing about survives the patch. + expect(byLocale.get("de-DE")).toBe("Alt"); + }); + + it("preserves fields kit does not model on a locale it overwrites", async () => { + const listings = await mergedSubscriptionListings( + stubSubscriptions(() => ({ + listings: [ + { + languageCode: "en-US", + title: "Old", + description: "Old", + benefits: ["Ad-free", "Offline"], + }, + ], + })), + "com.example.app", + "premium_monthly", + { title: "Premium", description: "All features" }, + ); + + expect(listings[0]?.benefits).toEqual(["Ad-free", "Offline"]); + }); + + it("propagates a read failure rather than replacing the listing set", async () => { + const failing = google.androidpublisher({ + version: "v3", + retryConfig: { retry: 0, noResponseRetries: 0 }, + adapter: async () => { + throw Object.assign(new Error("forbidden"), { code: 403 }); + }, + }); + + // Falling back to kit's own set here would delete every upstream + // locale — the exact outcome the read exists to prevent. + await expect( + mergedSubscriptionListings( + failing, + "com.example.app", + "premium_monthly", + { + title: "Premium", + }, + ), + ).rejects.toThrow(); + }); +}); + +// Unset is deliberately different for new and existing products. A new +// product still fixes #288 by going to every converted region, while a +// product already in Play keeps its current footprint until the operator +// explicitly selects `"all"`. +describe("existing-product region inheritance", () => { + it("keeps a US-only footprint and reports the available expansion", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "19", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => ({ + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + KR: { + regionCode: "KR", + price: { currencyCode: "KRW", units: "33000", nanos: 0 }, + }, + JP: { + regionCode: "JP", + price: { currencyCode: "JPY", units: "3800", nanos: 0 }, + }, + }, + }), + }); + + const outcome = await upsertModernAndroidOneTimeProduct( + androidpublisher, + BASE_ARGS, + { allowCreate: false }, + ); + + const configs = + regionalConfigs(patchRequest(requests)) + ?.regionalPricingAndAvailabilityConfigs ?? []; + expect(configs.map((config) => config.regionCode)).toEqual(["US"]); + expect(configs[0]?.price).toEqual({ + currencyCode: "USD", + units: "24", + nanos: 990_000_000, + }); + expect(outcome.manualAction).toMatchObject({ + code: "regional_expansion_available", + }); + expect(outcome.manualAction?.message).toContain( + "remains available in 1 of the 3 regions", + ); + expect(outcome.manualAction?.message).toContain('sales regions to "all"'); + }); + + it('expands an existing product when the operator selects "all"', async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "US", + availability: "AVAILABLE", + price: { currencyCode: "USD", units: "19", nanos: 0 }, + }, + { + regionCode: "KR", + availability: "NO_LONGER_AVAILABLE", + price: { currencyCode: "KRW", units: "25000", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => ({ + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + KR: { + regionCode: "KR", + price: { currencyCode: "KRW", units: "33000", nanos: 0 }, + }, + JP: { + regionCode: "JP", + price: { currencyCode: "JPY", units: "3800", nanos: 0 }, + }, + }, + convertedOtherRegionsPrice: { + usdPrice: { + currencyCode: "USD", + units: "24", + nanos: 990_000_000, + }, + eurPrice: { currencyCode: "EUR", units: "22", nanos: 0 }, + }, + }), + }); + + const outcome = await upsertModernAndroidOneTimeProduct( + androidpublisher, + { ...BASE_ARGS, regions: "all" }, + { allowCreate: false }, + ); + + const option = regionalConfigs(patchRequest(requests)); + const configs = option?.regionalPricingAndAvailabilityConfigs ?? []; + expect(configs.map((config) => config.regionCode)).toEqual([ + "US", + "KR", + "JP", + ]); + expect( + configs.find((config) => config.regionCode === "KR")?.availability, + ).toBe("AVAILABLE"); + expect(option?.newRegionsConfig).toMatchObject({ + availability: "AVAILABLE", + }); + expect(outcome.manualAction).toBeUndefined(); + }); + + it("does not reinstate a region the operator withdrew in Play Console", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "RU", + availability: "NO_LONGER_AVAILABLE", + price: { currencyCode: "RUB", units: "1500", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => ({ + convertedRegionPrices: { + RU: { + regionCode: "RU", + price: { currencyCode: "RUB", units: "2500", nanos: 0 }, + }, + }, + }), + }); + + await upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: false, + }); + + expect( + regionalConfigs(patchRequest(requests)) + ?.regionalPricingAndAvailabilityConfigs?.[0]?.availability, + ).toBe("NO_LONGER_AVAILABLE"); + }); + + it("keeps a withdrawn other-regions config withdrawn", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + purchaseOptions: [ + { + purchaseOptionId: "buy", + newRegionsConfig: { + availability: "NO_LONGER_AVAILABLE", + usdPrice: { currencyCode: "USD", units: "19", nanos: 0 }, + eurPrice: { currencyCode: "EUR", units: "17", nanos: 0 }, + }, + }, + ], + }), + convert: () => ({ + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + }, + convertedOtherRegionsPrice: { + usdPrice: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + eurPrice: { currencyCode: "EUR", units: "22", nanos: 0 }, + }, + }), + }); + + await upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: false, + }); + + // "Do not follow Play into new markets" is an operator decision a + // reprice must not quietly undo. + const cfg = regionalConfigs(patchRequest(requests))?.newRegionsConfig as + | { availability?: string } + | undefined; + expect(cfg?.availability).toBe("NO_LONGER_AVAILABLE"); + }); +}); + +describe("regionsVersionFor precedence", () => { + it("prefers the conversion's version over the product's", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + regionsVersion: { version: "2024/01" }, + purchaseOptions: [{ purchaseOptionId: "buy" }], + }), + convert: () => ({ + regionVersion: { version: "2026/02" }, + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + }, + }), + }); + + await upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: false, + }); + + // Inverting this precedence is what made every push fail with + // "Expected BGN but got EUR". + expect( + new URL(String(patchRequest(requests)?.url)).searchParams.get( + "regionsVersion.version", + ), + ).toBe("2026/02"); + }); + + it("rewrites an excluded region price before withdrawing under a new version", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + regionsVersion: { version: "2024/01" }, + purchaseOptions: [ + { + purchaseOptionId: "buy", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "BG", + availability: "AVAILABLE", + price: { currencyCode: "BGN", units: "40", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => ({ + regionVersion: { version: "2026/02" }, + convertedRegionPrices: { + BG: { + regionCode: "BG", + price: { currencyCode: "EUR", units: "20", nanos: 0 }, + }, + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + }, + }), + }); + + await upsertModernAndroidOneTimeProduct( + androidpublisher, + { ...BASE_ARGS, regions: ["US"] }, + { allowCreate: false }, + ); + + const bg = regionalConfigs( + patchRequest(requests), + )?.regionalPricingAndAvailabilityConfigs?.find( + (config) => config.regionCode === "BG", + ) as { availability?: string; price?: { currencyCode?: string } }; + expect(bg).toMatchObject({ + availability: "NO_LONGER_AVAILABLE", + price: { currencyCode: "EUR" }, + }); + }); + + it("fails safely when another purchase option still carries old-version prices", async () => { + const { androidpublisher, requests } = stubAndroidPublisher({ + get: () => ({ + regionsVersion: { version: "2024/01" }, + purchaseOptions: [ + { purchaseOptionId: "buy" }, + { + purchaseOptionId: "rent", + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "BG", + availability: "AVAILABLE", + price: { currencyCode: "BGN", units: "10", nanos: 0 }, + }, + ], + }, + ], + }), + convert: () => ({ + regionVersion: { version: "2026/02" }, + convertedRegionPrices: { + US: { + regionCode: "US", + price: { currencyCode: "USD", units: "24", nanos: 990_000_000 }, + }, + }, + }), + }); + + await expect( + upsertModernAndroidOneTimeProduct(androidpublisher, BASE_ARGS, { + allowCreate: false, + }), + ).rejects.toThrow(/purchase options other than buy/); + expect(patchRequest(requests)).toBeUndefined(); + }); +}); + +// The legacy `inappproducts` fallback is the other half of issue #288: +// apps whose Play catalog predates the modern one-time-product API never +// reach the conversion code above, so if this path forgets +// `autoConvertMissingPrices` it publishes a merchant-currency-only SKU — +// exactly the bug, just via a different door. Driven through the +// exported wrapper so the fallback decision is exercised too, not just +// the request builder. +describe("legacy inappproducts fallback", () => { + function stubLegacyFallback(modernError: { code: number; message?: string }) { + const requests: Common.GaxiosOptions[] = []; + const androidpublisher = google.androidpublisher({ + version: "v3", + adapter: async ( + request: Common.gaxios.GaxiosOptionsPrepared, + ): Promise> => { + requests.push(request); + const url = new URL(String(request.url)); + if (url.pathname.includes("/onetimeproducts")) { + throw Object.assign(new Error(modernError.message ?? "no"), { + code: modernError.code, + }); + } + if ( + url.pathname.includes("/inappproducts/") && + request.method === "GET" + ) { + return Object.assign(new Response(null, { status: 200 }), { + config: request, + data: { + defaultLanguage: "en-US", + listings: { + "fr-FR": { title: "Sage lunaire", description: "Débloquer" }, + }, + } as T, + }); + } + return Object.assign(new Response(null, { status: 200 }), { + config: request, + data: {} as T, + }); + }, + }); + const auth = {} as never; + return { androidpublisher, auth, requests }; + } + + const legacyRequest = (requests: Common.GaxiosOptions[]) => + requests.find( + (request) => + String(request.url).includes("/inappproducts") && + request.method !== "GET", + ); + + const localizedArgs = { + ...BASE_ARGS, + localizations: [ + { locale: "ko-KR", title: "문 세이지", description: "전체 해금" }, + { locale: "ja-JP", title: "ムーンセージ" }, + ], + }; + + // A create only reaches the legacy catalog when Play says so outright: + // `allowMissing=true` means a bare 404 cannot mean "product absent", so + // that case stays visible. An update falls back on a plain 404, since + // the SKU may exist only in the old catalog. + const FALLBACK_SIGNALS = [ + { + allowCreate: true, + error: { code: 400, message: "Please use the InAppProducts API" }, + }, + { allowCreate: false, error: { code: 404 } }, + ] as const; + + for (const { allowCreate, error } of FALLBACK_SIGNALS) { + it(`prices every region and writes every locale (allowCreate=${allowCreate})`, async () => { + const { androidpublisher, auth, requests } = stubLegacyFallback(error); + + await upsertAndroidOneTimeProduct(androidpublisher, auth, localizedArgs, { + allowCreate, + }); + + const request = legacyRequest(requests); + expect(request).toBeDefined(); + expect(request?.method).toBe(allowCreate ? "POST" : "PATCH"); + // Without this the legacy API prices the SKU in the merchant + // currency only and leaves every other region unbuyable. + expect( + new URL(String(request?.url)).searchParams.get( + "autoConvertMissingPrices", + ), + ).toBe("true"); + + const body = request?.data as { + defaultLanguage?: string; + listings?: Record; + }; + expect(body.listings?.["en-US"]).toEqual({ + title: "Moon Sage", + description: "Unlock Moon Sage", + }); + expect(body.listings?.["ko-KR"]).toEqual({ + title: "문 세이지", + description: "전체 해금", + }); + // Play requires a description; a locale that omits one repeats its + // title rather than sending an empty string the API rejects. + expect(body.listings?.["ja-JP"]).toEqual({ + title: "ムーンセージ", + description: "ムーンセージ", + }); + if (!allowCreate) { + expect(body.listings?.["fr-FR"]).toEqual({ + title: "Sage lunaire", + description: "Débloquer", + }); + } + }); + } + + it("declares the base locale as the SKU default on create", async () => { + const { androidpublisher, auth, requests } = stubLegacyFallback({ + code: 400, + message: "Please use the InAppProducts API", + }); + + await upsertAndroidOneTimeProduct(androidpublisher, auth, localizedArgs, { + allowCreate: true, + }); + + expect( + (legacyRequest(requests)?.data as { defaultLanguage?: string }) + .defaultLanguage, + ).toBe("en-US"); + }); + + it("does not swallow a modern error that is not a legacy-catalog signal", async () => { + // A 403 means the credential can't write, not "this SKU lives in the + // old catalog". Falling back would turn a permissions failure into a + // second failing call and hide the real cause. + const { androidpublisher, auth, requests } = stubLegacyFallback({ + code: 403, + message: "The caller does not have permission", + }); + + await expect( + upsertAndroidOneTimeProduct(androidpublisher, auth, BASE_ARGS, { + allowCreate: true, + }), + ).rejects.toThrow(/permission/i); + expect(legacyRequest(requests)).toBeUndefined(); + }); +}); diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts index 2b942837f..cdf58ae18 100644 --- a/packages/kit/convex/products/play.ts +++ b/packages/kit/convex/products/play.ts @@ -5,6 +5,13 @@ import type { androidpublisher_v3 } from "googleapis"; import { internalAction, type ActionCtx } from "../_generated/server"; import { internal } from "../_generated/api"; +import { + BASE_LISTING_LOCALE, + listingRowsForProduct, + splitStoreListings, + type ProductLocalization, +} from "./localizations"; +import type { ProductRegions } from "./regions"; import { coerceBillingPeriod } from "./sync"; class ProductSyncCancelledError extends Error { @@ -120,6 +127,7 @@ export const runProductSyncAndroid = internalAction({ deleted: result.deleted, failures: result.failures, plannedWrites: result.plannedWrites, + manualActions: result.manualActions, }); } catch (error) { const cancelled = error instanceof ProductSyncCancelledError; @@ -163,6 +171,22 @@ interface AndroidSyncResult { deleted?: number; failures: ProductSyncFailure[]; plannedWrites?: Array<{ productId: string; step: string; detail?: string }>; + // Same operator-must-finish concept as the iOS path's + // AscManualReviewAction — the push succeeded but left upstream state + // that needs a human in Play Console (issue #288: regional prices + // couldn't be auto-converted, so the product is US-only until the + // operator sets them). The productSyncJobs schema and dashboard + // banner are already platform-agnostic, so this flows end-to-end. + manualActions?: AndroidManualAction[]; +} + +interface AndroidManualAction { + productId: string; + code: + | "regional_pricing_incomplete" + | "regional_expansion_available" + | "product_type_assumed"; + message: string; } async function performAndroidSync( @@ -230,6 +254,7 @@ async function performAndroidSync( step: string; detail?: string; }> = []; + const manualActions: AndroidManualAction[] = []; let pulled = 0; let pushed = 0; let deleted = 0; @@ -273,6 +298,11 @@ async function performAndroidSync( const existingTypesByProductId = new Map( existingTypeRows.map((row) => [row.productId, row.type]), ); + const existingCurrencyByProductId = new Map( + existingTypeRows + .filter((row) => row.currency) + .map((row) => [row.productId, row.currency as string]), + ); try { // Defensive guard: the new monetization API isn't surfaced in // any typed shape by `googleapis` yet, so we cast through @@ -347,7 +377,6 @@ async function performAndroidSync( if (!product.productId) continue; if (seenOneTimeSkus.has(product.productId)) continue; seenOneTimeSkus.add(product.productId); - const listing = product.listings?.[0]; // Walk every purchaseOption × regionalPricingAndAvailabilityConfig // (pricing lives on the option, not inside buyOption). // Two filters before ranking: @@ -385,10 +414,19 @@ async function performAndroidSync( priceCandidates.sort((a, b) => (a.currencyCode ?? "").localeCompare(b.currencyCode ?? ""), ); - const preferred = - priceCandidates.find((p) => p.regionCode === "US") ?? - priceCandidates.find((p) => p.currencyCode === "USD") ?? - priceCandidates[0]; + // Prefer the currency the kit row already carries. Pushing + // converts the operator's base price into every region, so + // a US-first ranking would read back the converted dollar + // amount and overwrite an authored KRW/JPY row with it — + // and the next push would then convert from that already + // converted number. First imports keep the US/USD ranking. + const authoredCurrency = existingCurrencyByProductId.get( + product.productId, + ); + const preferred = pickPlayRegionalPrice( + priceCandidates, + authoredCurrency, + ); const priceAmountMicros = preferred ? moneyToMicros({ units: preferred.units, @@ -398,6 +436,23 @@ async function performAndroidSync( const existingType = existingTypesByProductId.get( product.productId, ); + if (existingType === undefined) { + // First import through the modern endpoint, which carries + // no consumable flag — so the type below is a guess. + // NonConsumable is the safe guess (consuming a + // non-consumable would destroy a permanent entitlement), + // but a guessed Consumable verifies as + // PENDING_ACKNOWLEDGMENT instead of READY_TO_CONSUME, and + // a client gating on that state reads it as a rejection + // (issue #289). Say so rather than deciding silently. + manualActions.push({ + productId: product.productId, + code: "product_type_assumed", + message: + `Imported "${product.productId}" as NonConsumable — Play's one-time-product API doesn't report whether a product is consumable. ` + + `If it is a consumable, set its type in the dashboard; until then it verifies as pending-acknowledgment rather than ready-to-consume.`, + }); + } await ctx.runMutation(internal.products.sync.upsertFromStore, { projectId: project._id, productId: product.productId, @@ -408,8 +463,14 @@ async function performAndroidSync( // pull-sync doesn't turn consumables into // non-consumables, and default only for first imports. type: preservePlayOneTimeType(existingType, "NonConsumable"), - title: listing?.title ?? product.productId, - description: listing?.description ?? undefined, + ...splitStoreListings( + (product.listings ?? []).map((entry) => ({ + locale: entry.languageCode, + title: entry.title, + description: entry.description, + })), + product.productId, + ), priceAmountMicros, currency: preferred?.currencyCode ?? undefined, storeRef: product.productId, @@ -456,8 +517,7 @@ async function performAndroidSync( existingType, mapPlayOneTimeType(product), ), - title: pickPlayTitle(product) ?? product.sku, - description: pickPlayDescription(product), + ...splitLegacyPlayListings(product), priceAmountMicros: parsePlayPriceMicros(product), currency: pickPlayCurrency(product), storeRef: product.sku, @@ -497,8 +557,14 @@ async function performAndroidSync( for (const sub of subs.data.subscriptions ?? []) { if (!sub.productId) continue; const { priceAmountMicros, currency, basePlanId } = - pickSubBasePlanPrice(sub); - const offers = collectPlaySubscriptionOffers(sub); + pickSubBasePlanPrice( + sub, + existingCurrencyByProductId.get(sub.productId ?? ""), + ); + const offers = collectPlaySubscriptionOffers( + sub, + existingCurrencyByProductId.get(sub.productId ?? ""), + ); // Pick the billingPeriod from the *same* base plan whose // price we just selected (`basePlanId` returned by // pickSubBasePlanPrice). If we can't find that exact plan @@ -518,8 +584,14 @@ async function performAndroidSync( productId: sub.productId, platform: "Android", type: "Subscription", - title: sub.listings?.[0]?.title ?? sub.productId, - description: sub.listings?.[0]?.description ?? undefined, + ...splitStoreListings( + (sub.listings ?? []).map((entry) => ({ + locale: entry.languageCode, + title: entry.title, + description: entry.description, + })), + sub.productId, + ), priceAmountMicros, currency, storeRef: sub.productId, @@ -640,8 +712,8 @@ async function performAndroidSync( let patchOk = true; if (row.type === "Subscription") { // Subscriptions: patch the listing via - // monetization.subscriptions.patch (en-US listing only — - // multi-language sync is a future feature). Base-plan + // monetization.subscriptions.patch (base listing plus every + // locale on the row, merged over what Play has). Base-plan // price changes have to go through a separate // monetization.subscriptions.basePlans endpoint, so we // intentionally don't try to mutate price here; that @@ -654,7 +726,7 @@ async function performAndroidSync( plannedWrites.push({ productId: row.productId, step: "patch subscription listing", - detail: `${row.title} (en-US, storeRef=${row.storeRef})`, + detail: `${row.title} (storeRef=${row.storeRef}) · ${describePlayListingPlan(row, { withRegions: false })}`, }); } else { try { @@ -670,16 +742,18 @@ async function performAndroidSync( // (https://github.com/hyodotdev/openiap/pull/124) // review. The googleapis SDK exposes this as a flat // querystring param (`regionsVersion.version`). - "regionsVersion.version": "2022/01", + // This patch masks `listings` only and sends no + // prices, so no conversion has to be aligned with and + // the historical pin stays correct. + "regionsVersion.version": FALLBACK_REGIONS_VERSION, requestBody: { productId: row.storeRef, - listings: [ - { - languageCode: "en-US", - title: row.title, - description: row.description ?? row.title, - }, - ], + listings: await mergedSubscriptionListings( + androidpublisher, + packageName, + row.storeRef, + row, + ), }, }); } catch (error) { @@ -704,25 +778,30 @@ async function performAndroidSync( productId: row.productId, step: "patch in-app product", detail: - `${row.title} (en-US, storeRef=${row.storeRef})` + + `${row.title} (storeRef=${row.storeRef}) · ${describePlayListingPlan(row)}` + (row.priceAmountMicros !== undefined && row.currency ? ` · ${row.currency} ${(row.priceAmountMicros / 1_000_000).toFixed(2)}` : ""), }); } else { try { - await upsertAndroidOneTimeProduct( - androidpublisher, - auth, - { - packageName, - productId: row.storeRef, - title: row.title, - description: row.description ?? row.title, - priceAmountMicros: row.priceAmountMicros, - currency: row.currency, - }, - { allowCreate: false }, + manualActions.push( + ...(await upsertAndroidOneTimeProduct( + androidpublisher, + auth, + { + packageName, + productId: row.storeRef, + title: row.title, + description: row.description ?? row.title, + baseLocale: row.baseLocale, + localizations: row.localizations, + regions: row.regions, + priceAmountMicros: row.priceAmountMicros, + currency: row.currency, + }, + { allowCreate: false }, + )), ); } catch (error) { patchOk = false; @@ -761,27 +840,20 @@ async function performAndroidSync( "Subscription requires priceAmountMicros + currency to mint a Play base plan; otherwise the product will not be purchasable.", ); } - // Play's `regionalConfigs` requires the `currencyCode` to - // be the local currency of the `regionCode` it's paired - // with — pushing `regionCode: "US"` with a non-USD price - // returns a generic 400 from the API and leaves the - // operator chasing a confusing error message. Match the - // iOS path's currency-validation pattern (asc.ts intro - // offer push) and surface an actionable failure here so - // the row stays in Draft and the dashboard reports - // exactly which SKU failed and why (Gemini review on - // PR #127). - if (row.currency !== "USD") { - throw new Error( - `Subscription "${row.productId}" has currency "${row.currency}" but the kit→Play push currently only supports the US region (USD). Set the price in USD on the dashboard, or pre-create the subscription in Play Console with your preferred regional pricing and let the next pull-sync mirror it back into kit.`, - ); - } + // Play's `regionalConfigs` requires the `currencyCode` to be + // the local currency of the `regionCode` it's paired with, so + // the base price can't simply be replicated across regions. + // Ask Play to convert it (same mechanism the one-time path + // uses) and write every region it returns — a base plan + // created with a lone US config is unbuyable everywhere else + // (issue #288). Conversion failure degrades to the base + // region plus a manual action rather than a hard failure. const basePlanId = basePlanIdForPeriod(row.billingPeriod); if (dryRun) { plannedWrites.push({ productId: row.productId, step: "create subscription", - detail: `${row.title} · base plan ${basePlanId} · ${row.billingPeriod ?? "P1M"} · ${row.currency} ${(row.priceAmountMicros / 1_000_000).toFixed(2)} (US)`, + detail: `${row.title} · base plan ${basePlanId} · ${row.billingPeriod ?? "P1M"} · ${row.currency} ${(row.priceAmountMicros / 1_000_000).toFixed(2)} · ${describePlayListingPlan(row, { withRegions: false })}`, }); plannedWrites.push({ productId: row.productId, @@ -789,6 +861,39 @@ async function performAndroidSync( detail: basePlanId, }); } else { + // Conversion (and therefore the USD-fallback guard) runs + // only on the real write path — a dry run must never fail a + // non-USD subscription for a price it isn't going to send. + const subscriptionBasePrice = microsToGoogleMoney( + row.priceAmountMicros, + row.currency, + ); + const subscriptionConversion = await convertAndroidRegionPrices( + androidpublisher, + packageName, + subscriptionBasePrice, + ); + const subscriptionConverted = subscriptionConversion.response; + const subscriptionRegionalConfigs = + buildSubscriptionRegionalConfigs( + subscriptionConverted, + subscriptionBasePrice, + row.productId, + ); + const subscriptionOtherRegions = + subscriptionConverted?.convertedOtherRegionsPrice; + if (convertedRegionCount(subscriptionConverted) === 0) { + manualActions.push({ + productId: row.productId, + code: "regional_pricing_incomplete", + message: + `Play could not convert ${row.currency} ${(row.priceAmountMicros / 1_000_000).toFixed(2)} into regional prices, so base plan "${basePlanId}" of "${row.productId}" ` + + `is available in ${subscriptionRegionalConfigs.length} region(s) only. Set the remaining regions in Play Console → the subscription's base plan → Set prices.` + + (subscriptionConversion.error + ? ` Play reported: ${subscriptionConversion.error}` + : ""), + }); + } await androidpublisher.monetization.subscriptions.create({ packageName, productId: row.productId, @@ -798,16 +903,16 @@ async function performAndroidSync( // shape changed). The request 400s without it. The // googleapis SDK exposes this as a flat querystring // param (`regionsVersion.version`). - "regionsVersion.version": "2022/01", + "regionsVersion.version": regionsVersionFor( + subscriptionConverted, + ), requestBody: { productId: row.productId, - listings: [ - { - languageCode: "en-US", - title: row.title, - description: row.description ?? row.title, - }, - ], + listings: listingRowsForProduct(row).map((listing) => ({ + languageCode: listing.locale, + title: listing.title, + description: listing.description ?? listing.title, + })), // Auto-renewing base plan. Period from the catalog row; // defaults to monthly when the operator hasn't picked // one. The base-plan id mirrors the duration so a row @@ -819,18 +924,19 @@ async function performAndroidSync( autoRenewingBasePlanType: { billingPeriodDuration: row.billingPeriod ?? "P1M", }, - regionalConfigs: [ - { - regionCode: "US", - price: { - currencyCode: row.currency, - units: String( - Math.trunc(row.priceAmountMicros / 1_000_000), - ), - nanos: (row.priceAmountMicros % 1_000_000) * 1_000, - }, - }, - ], + regionalConfigs: subscriptionRegionalConfigs, + // Markets Play launches later. Requires both USD and + // EUR, so it only goes out when conversion gave both. + ...(subscriptionOtherRegions?.usdPrice && + subscriptionOtherRegions.eurPrice + ? { + otherRegionsConfig: { + usdPrice: subscriptionOtherRegions.usdPrice, + eurPrice: subscriptionOtherRegions.eurPrice, + newSubscriberAvailability: true, + }, + } + : {}), }, ], }, @@ -861,24 +967,29 @@ async function performAndroidSync( productId: row.productId, step: "create in-app product", detail: - `${row.title} · ${row.type}` + + `${row.title} · ${row.type} · ${describePlayListingPlan(row)}` + (row.priceAmountMicros !== undefined && row.currency ? ` · ${row.currency} ${(row.priceAmountMicros / 1_000_000).toFixed(2)}` : " · no price set"), }); } else { - await upsertAndroidOneTimeProduct( - androidpublisher, - auth, - { - packageName, - productId: row.productId, - title: row.title, - description: row.description ?? row.title, - priceAmountMicros: row.priceAmountMicros, - currency: row.currency, - }, - { allowCreate: true }, + manualActions.push( + ...(await upsertAndroidOneTimeProduct( + androidpublisher, + auth, + { + packageName, + productId: row.productId, + title: row.title, + description: row.description ?? row.title, + baseLocale: row.baseLocale, + localizations: row.localizations, + regions: row.regions, + priceAmountMicros: row.priceAmountMicros, + currency: row.currency, + }, + { allowCreate: true }, + )), ); } } @@ -920,9 +1031,94 @@ async function performAndroidSync( ...(deleted > 0 ? { deleted } : {}), failures, plannedWrites: dryRun ? plannedWrites : undefined, + manualActions: manualActions.length > 0 ? manualActions : undefined, }; } +/** + * Listings for a subscription patch, merged over what Play already has. + * + * `updateMask: "listings"` replaces the array, so a locale the operator + * added in Play Console would be deleted by a push that sent only kit's + * own set. Read errors propagate — the caller turns them into a per-row + * failure and the row stays Draft for the next sync — because a partial + * write here is destructive, not merely incomplete. + */ +export async function mergedSubscriptionListings( + androidpublisher: androidpublisher_v3.Androidpublisher, + packageName: string, + productId: string, + row: { + title: string; + description?: string; + baseLocale?: string; + localizations?: ProductLocalization[]; + }, +): Promise { + const byLocale = new Map< + string, + androidpublisher_v3.Schema$SubscriptionListing + >(); + + // A read failure must NOT fall through to kit's own set: the patch + // replaces the listings array, so writing an unmerged set would delete + // exactly the upstream locales this read exists to protect. Surface it + // and let the caller record a failure instead. + const response = await androidpublisher.monetization.subscriptions.get({ + packageName, + productId, + }); + for (const listing of response.data.listings ?? []) { + if (listing.languageCode) byLocale.set(listing.languageCode, listing); + } + + for (const listing of listingRowsForProduct(row)) { + byLocale.set(listing.locale, { + // Preserve `benefits` and anything else already on this locale. + ...byLocale.get(listing.locale), + languageCode: listing.locale, + title: listing.title, + description: listing.description ?? listing.title, + }); + } + + const baseLocale = row.baseLocale ?? BASE_LISTING_LOCALE; + const base = byLocale.get(baseLocale); + const rest = Array.from(byLocale.entries()) + .filter(([locale]) => locale !== baseLocale) + .map(([, listing]) => listing); + return base ? [base, ...rest] : rest; +} + +/** + * Human summary of what a push would publish. + * + * `withRegions` is false on paths that write listings only (the + * subscription patch), so the preview never advertises a footprint that + * write does not touch. + */ +function describePlayListingPlan( + row: { + baseLocale?: string; + localizations?: ProductLocalization[]; + regions?: ProductRegions; + }, + options: { withRegions: boolean } = { withRegions: true }, +): string { + const locales = [ + row.baseLocale ?? BASE_LISTING_LOCALE, + ...(row.localizations ?? []).map((l) => l.locale), + ].join(", "); + if (!options.withRegions) return `locales ${locales}`; + const regions = + row.regions === "all" + ? "all Play regions (converted)" + : row.regions?.length + ? row.regions.join(", ") + : "the regions Play already sells it in (all, if it is new)"; + return `locales ${locales} · regions ${regions}`; +} + function googleErrorStatus(error: unknown): number | undefined { if (!error || typeof error !== "object") return undefined; const candidate = error as { @@ -951,35 +1147,102 @@ interface AndroidOneTimeProductUpsertArgs { productId: string; title: string; description: string; + baseLocale?: string; + localizations?: ProductLocalization[]; + regions?: ProductRegions; priceAmountMicros?: number; currency?: string; } -async function upsertAndroidOneTimeProduct( +/** + * Play listing rows for a product: the base en-US listing plus every + * locale the operator added, merged over whatever is already upstream. + * + * `updateMask` makes Play REPLACE the whole `listings` array, so a + * locale an operator added directly in Play Console would be deleted by + * a push that only knows kit's own set. Kit-authored locales win; the + * rest are carried through untouched. (Consequence: removing a + * localization in kit does not remove it from Play — delete it in Play + * Console. Same trade the regional configs make.) + */ +function listingsForAndroidProduct( + args: { + title: string; + description: string; + baseLocale?: string; + localizations?: ProductLocalization[]; + }, + existingListings: Array<{ + languageCode?: string | null; + title?: string | null; + description?: string | null; + }> = [], +): androidpublisher_v3.Schema$OneTimeProductListing[] { + const byLocale = new Map< + string, + androidpublisher_v3.Schema$OneTimeProductListing + >(); + + for (const listing of existingListings) { + if (listing.languageCode) byLocale.set(listing.languageCode, listing); + } + for (const row of listingRowsForProduct(args)) { + byLocale.set(row.locale, { + languageCode: row.locale, + title: row.title, + description: row.description ?? row.title, + }); + } + + // Base locale first: Play's legacy path needs `defaultLanguage` to + // match a listing, and a store that treats the first entry as default + // should get the language the operator actually authored. + const baseLocale = args.baseLocale ?? BASE_LISTING_LOCALE; + const base = byLocale.get(baseLocale); + const rest = Array.from(byLocale.entries()) + .filter(([locale]) => locale !== baseLocale) + .map(([, listing]) => listing); + return base ? [base, ...rest] : rest; +} + +export async function upsertAndroidOneTimeProduct( androidpublisher: androidpublisher_v3.Androidpublisher, auth: Auth.GoogleAuth, args: AndroidOneTimeProductUpsertArgs, options: { allowCreate: boolean }, -): Promise { +): Promise { validateAndroidOneTimePrice(args); + const manualActions: AndroidManualAction[] = []; try { - await upsertModernAndroidOneTimeProduct(androidpublisher, args, options); + const outcome = await upsertModernAndroidOneTimeProduct( + androidpublisher, + args, + options, + ); + if (outcome.manualAction) manualActions.push(outcome.manualAction); } catch (error) { if (!shouldFallbackToLegacyOneTimeProduct(error, options)) throw error; + // Only the LEGACY path cannot honour a region footprint, so the + // check belongs here — hoisting it above the modern attempt made + // every footprint fail before Play was ever contacted. Round 5's + // concern (a regions message hiding the real modern failure) is met + // by carrying that failure into the text instead. + assertLegacyPathUsableFor(args, error); if (options.allowCreate) { await insertLegacyAndroidOneTimeProduct(androidpublisher, args); - return; + return manualActions; } await patchLegacyAndroidOneTimeProduct(androidpublisher, args); - return; + return manualActions; } // Activation errors describe the modern product we just upserted and must // not be reclassified as evidence that the product belongs to the legacy // catalog. await activateAndroidOneTimePurchaseOption(auth, args); + return manualActions; } function validateAndroidOneTimePrice( @@ -990,55 +1253,554 @@ function validateAndroidOneTimePrice( "One-time product requires priceAmountMicros + currency to mint a Play purchase option; otherwise the product will not be purchasable.", ); } - if (args.currency !== "USD") { +} + +/** + * Asks Play to convert one base price into every region it sells in. + * + * Play has no `autoConvertMissingPrices` equivalent on the modern + * one-time-product API, so the only way to publish a product that is + * buyable outside the base region is to call this first and write every + * returned region explicitly (issue #288). A failure degrades to a + * single-region write plus a manual action rather than failing the whole + * push, so the reason is carried out for the operator — "conversion + * unavailable" and "your service account lacks pricing permission" need + * very different responses. + */ +/** + * Number of regions Play actually returned a usable price for. + * + * `convertedRegionPrices` is an object, so a bare truthiness check + * treats `{}` — Play answering with no conversions at all — as success + * and ships the product US-only while reporting a clean sync. Every + * decision that depends on "did conversion work" must go through this. + */ +/** + * Regions version to write a resource at. + * + * `convertRegionPrices` always converts using Play's CURRENT region + * definitions, but a write is validated against whatever version the + * request pins. Pinning an older version than the conversion used makes + * Play reject the write for any region whose currency changed since — + * e.g. Bulgaria moved from BGN to EUR, and a 2022/01 write of a + * freshly-converted EUR price fails with "Expected BGN but got EUR". + * So the write follows the conversion's own version, falling back to the + * historical pin when there is no conversion to align with. + */ +const FALLBACK_REGIONS_VERSION = "2022/01"; + +function regionsVersionFor( + converted: androidpublisher_v3.Schema$ConvertRegionPricesResponse | undefined, + existingVersion?: string, +): string { + // The conversion's own version wins. Failing that, the write still + // echoes the configs Play generated at `existingVersion`, and pinning + // anything older makes Play reject them for the same reason a + // freshly-converted price fails at 2022/01 — a region whose currency + // changed since. The historical pin is only for a product Play has + // never priced. + return ( + converted?.regionVersion?.version ?? + existingVersion ?? + FALLBACK_REGIONS_VERSION + ); +} + +function convertedRegionCount( + converted: androidpublisher_v3.Schema$ConvertRegionPricesResponse | undefined, +): number { + return Object.values(converted?.convertedRegionPrices ?? {}).filter( + (region) => region.price, + ).length; +} + +async function convertAndroidRegionPrices( + androidpublisher: androidpublisher_v3.Androidpublisher, + packageName: string, + price: androidpublisher_v3.Schema$Money, +): Promise<{ + response?: androidpublisher_v3.Schema$ConvertRegionPricesResponse; + error?: string; +}> { + try { + const response = await androidpublisher.monetization.convertRegionPrices({ + packageName, + requestBody: { price }, + }); + return { response: response.data }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + +/** + * Builds the regional pricing rows for a purchase option. + * + * `existingByRegion` carries the product's current configs on an update. + * In inherit mode, only those regions are repriced and their availability + * stays unchanged. An explicit list or `"all"` may reactivate a withdrawn + * region because that is an operator-authored footprint change. + * + * When conversion is unavailable there is no legal price for a foreign + * region (Play pairs each region with its own currency), so the base + * amount is written to the regions that already use the base currency + * and every other region keeps its previous price. `repriced` reports + * how many regions actually took the new amount so the caller can say + * plainly that the rest did not. + */ +/** + * Marks a region unavailable while keeping its config. + * + * `NO_LONGER_AVAILABLE` is only legal for a region that is currently + * `AVAILABLE`, so anything already withdrawn (or never released) is left + * exactly as Play has it. + */ +function withdrawRegion( + existing: androidpublisher_v3.Schema$OneTimeProductPurchaseOptionRegionalPricingAndAvailabilityConfig, +): androidpublisher_v3.Schema$OneTimeProductPurchaseOptionRegionalPricingAndAvailabilityConfig { + if (existing.availability === "NO_LONGER_AVAILABLE") { + return existing; + } + if (existing.availability && existing.availability !== "AVAILABLE") { throw new Error( - `One-time product "${args.productId}" has currency "${args.currency}" but the kit→Play push currently only supports the US region (USD). Set the price in USD on the dashboard, or pre-create the product in Play Console with your preferred regional pricing and let the next pull-sync mirror it back into kit.`, + `Play cannot withdraw region ${existing.regionCode ?? "(unknown)"} while its availability is ${existing.availability}. Google only permits NO_LONGER_AVAILABLE after AVAILABLE; change the pre-release or offer-only state in Play Console, then retry the sync.`, ); } + return { ...existing, availability: "NO_LONGER_AVAILABLE" }; } -function buildAndroidOneTimeProduct( - args: AndroidOneTimeProductUpsertArgs, -): androidpublisher_v3.Schema$OneTimeProduct { - if (args.priceAmountMicros === undefined || !args.currency) { +function buildRegionalPricingConfigs( + converted: androidpublisher_v3.Schema$ConvertRegionPricesResponse | undefined, + basePrice: androidpublisher_v3.Schema$Money, + productId: string, + existingByRegion: Map< + string, + androidpublisher_v3.Schema$OneTimeProductPurchaseOptionRegionalPricingAndAvailabilityConfig + >, + allowedRegions?: Set, + reactivateIncludedRegions = false, +): { + configs: androidpublisher_v3.Schema$OneTimeProductPurchaseOptionRegionalPricingAndAvailabilityConfig[]; + /** Region codes that actually took the new amount. */ + repricedRegions: Set; +} { + const configs = new Map< + string, + androidpublisher_v3.Schema$OneTimeProductPurchaseOptionRegionalPricingAndAvailabilityConfig + >(); + const repricedRegions = new Set(); + + for (const [regionCode, regionPrice] of Object.entries( + converted?.convertedRegionPrices ?? {}, + )) { + if (!regionPrice.price) continue; + const existing = existingByRegion.get(regionCode); + // Play refuses to drop a region once a purchase option has it + // ("Cannot remove region once it has been added"), so an excluded + // region can only be withdrawn, never omitted — and a region the + // product doesn't have yet is simply not added. + if (allowedRegions && !allowedRegions.has(regionCode)) { + if (!existing) continue; + configs.set(regionCode, { + ...withdrawRegion(existing), + // The PATCH is pinned to the conversion's current regions version. + // Echoing an old config's price can pair a retired currency (for + // example BGN) with the new version that now requires EUR, even + // though the region is being withdrawn. + price: regionPrice.price, + }); + continue; + } + configs.set(regionCode, { + regionCode, + // Play rejects a config that pairs a region with a currency that + // isn't its own, so the converted Money is the only safe price + // here — never the operator's base-currency amount. + price: regionPrice.price, + // A region kit itself withdrew must come back when the operator + // explicitly adds it to a list or selects `"all"`; otherwise the + // footprint would be a one-way door. In inherit mode, preserve the + // availability Play returned so a price-only push cannot reopen a + // market the operator withdrew in Play Console. + availability: reactivateIncludedRegions + ? "AVAILABLE" + : (existing?.availability ?? "AVAILABLE"), + }); + repricedRegions.add(regionCode); + } + + for (const [regionCode, existing] of existingByRegion) { + if (configs.has(regionCode)) continue; + if (allowedRegions && !allowedRegions.has(regionCode)) { + configs.set(regionCode, withdrawRegion(existing)); + continue; + } + // Without a conversion the new amount is still legal in any region + // already denominated in the base currency. Writing it there keeps + // a price edit from being silently dropped on the degraded path. + if ( + convertedRegionCount(converted) === 0 && + existing.price?.currencyCode === basePrice.currencyCode + ) { + configs.set(regionCode, { + ...existing, + price: basePrice, + ...(reactivateIncludedRegions ? { availability: "AVAILABLE" } : {}), + }); + repricedRegions.add(regionCode); + continue; + } + configs.set(regionCode, existing); + } + + const hasAvailableIncludedRegion = Array.from(configs.values()).some( + (config) => + (!allowedRegions || allowedRegions.has(config.regionCode ?? "")) && + (config.availability ?? "AVAILABLE") !== "NO_LONGER_AVAILABLE", + ); + + if ( + configs.size === 0 || + (reactivateIncludedRegions && !hasAvailableIncludedRegion) + ) { + // Nothing to preserve and no conversion — fall back to the base + // region so the product is at least purchasable somewhere. The + // caller reports this as a manual action rather than a silent + // success. An operator who named their regions and left US out must + // not have it published anyway; there is simply nothing legal to + // write for them, so the push fails and says why. + if (allowedRegions && !allowedRegions.has("US")) { + throw new Error( + `Play could not convert ${basePrice.currencyCode} into regional prices for "${productId}", and its sales regions (${[...allowedRegions].join(", ")}) exclude the US fallback. Retry the sync, or set the prices in Play Console.`, + ); + } + configs.set(assertUsdFallbackRegion(basePrice, productId), { + regionCode: "US", + availability: "AVAILABLE", + price: basePrice, + }); + repricedRegions.add("US"); + } + + return { configs: Array.from(configs.values()), repricedRegions }; +} + +/** + * Guards the single-region fallback used when Play's price conversion is + * unavailable. + * + * Play requires a region's config to carry that region's own currency, + * so only a USD price may be published to the US fallback. A non-USD + * price would 400 with a generic message; fail here instead with one + * that says what to do. (Before issue #288 this constraint was enforced + * by rejecting every non-USD product outright — now it only applies on + * the degraded path, because conversion normally supplies each region's + * local currency.) + */ +function assertUsdFallbackRegion( + basePrice: androidpublisher_v3.Schema$Money, + productId: string, +): string { + if (basePrice.currencyCode !== "USD") { throw new Error( - "One-time product requires priceAmountMicros + currency to mint a Play purchase option; otherwise the product will not be purchasable.", + `Play could not convert ${basePrice.currencyCode} into regional prices for "${productId}", and a non-USD amount cannot be published to the US fallback region. Retry the sync, or set this product's regional prices in Play Console and let the next pull-sync mirror them back.`, ); } + return "US"; +} + +/** + * Regional base-plan configs for a subscription create. + * + * Same contract as {@link buildRegionalPricingConfigs} minus the merge + * arm: `subscriptions.create` only ever runs for a subscription that + * doesn't exist upstream yet, so there is nothing to preserve. + */ +export function buildSubscriptionRegionalConfigs( + converted: androidpublisher_v3.Schema$ConvertRegionPricesResponse | undefined, + basePrice: androidpublisher_v3.Schema$Money, + productId: string, + allowedRegions?: Set, +): androidpublisher_v3.Schema$RegionalBasePlanConfig[] { + const configs: androidpublisher_v3.Schema$RegionalBasePlanConfig[] = []; + + for (const [regionCode, regionPrice] of Object.entries( + converted?.convertedRegionPrices ?? {}, + )) { + if (!regionPrice.price) continue; + if (allowedRegions && !allowedRegions.has(regionCode)) continue; + configs.push({ + regionCode, + price: regionPrice.price, + newSubscriberAvailability: true, + }); + } + + if (configs.length === 0) { + configs.push({ + regionCode: assertUsdFallbackRegion(basePrice, productId), + price: basePrice, + newSubscriberAvailability: true, + }); + } + + return configs; +} + +function buildAndroidOneTimeProduct( + args: AndroidOneTimeProductUpsertArgs, + regionalPricingAndAvailabilityConfigs: androidpublisher_v3.Schema$OneTimeProductPurchaseOptionRegionalPricingAndAvailabilityConfig[], + newRegionsConfig: + | androidpublisher_v3.Schema$OneTimeProductPurchaseOptionNewRegionsConfig + | undefined, + existing: Pick< + ExistingOneTimeProductState, + "buyOption" | "otherPurchaseOptions" | "listings" + >, +): androidpublisher_v3.Schema$OneTimeProduct { return { packageName: args.packageName, productId: args.productId, - listings: [ - { - languageCode: "en-US", - title: args.title, - description: args.description, - }, - ], + listings: listingsForAndroidProduct(args, existing.listings), purchaseOptions: [ { + // Spread the upstream option first so fields kit doesn't model + // — offerTags, taxAndComplianceSettings, an operator-configured + // newRegionsConfig — survive; the keys below then assert what + // kit does own. `state` is output-only and must not be echoed. + ...stripReadOnlyPurchaseOptionFields(existing.buyOption), purchaseOptionId: "buy", buyOption: { legacyCompatible: true, multiQuantityEnabled: false, }, - regionalPricingAndAvailabilityConfigs: [ - { - regionCode: "US", - availability: "AVAILABLE", - price: microsToGoogleMoney(args.priceAmountMicros, args.currency), - }, - ], + regionalPricingAndAvailabilityConfigs, + ...(newRegionsConfig ? { newRegionsConfig } : {}), }, + // kit only models the single `buy` option, but `updateMask: + // "purchaseOptions"` replaces the whole list — so anything the + // operator added in Play Console (a rent option, a second buy + // option, a pre-order offer) has to be echoed back or the push + // deletes it. Same replace-semantics trap as the regional configs. + ...existing.otherPurchaseOptions.map((option) => + stripReadOnlyPurchaseOptionFields(option), + ), ], }; } +/** + * Drops output-only fields Play rejects on write. + * + * `state` is documented as output-only ("This field cannot be changed by + * updating the resource"), so echoing a read-back option verbatim would + * turn a preservation write into a 400. + */ +function stripReadOnlyPurchaseOptionFields( + option: androidpublisher_v3.Schema$OneTimeProductPurchaseOption | undefined, +): androidpublisher_v3.Schema$OneTimeProductPurchaseOption { + if (!option) return {}; + const { state: _state, ...writable } = option; + return writable; +} + +interface ExistingOneTimeProductState { + /** Regional configs on the `buy` option, keyed by region code. */ + regionsByCode: Map< + string, + androidpublisher_v3.Schema$OneTimeProductPurchaseOptionRegionalPricingAndAvailabilityConfig + >; + /** The existing `buy` option, so its non-pricing fields survive. */ + buyOption?: androidpublisher_v3.Schema$OneTimeProductPurchaseOption; + /** Every purchase option kit doesn't own, echoed back on write. */ + otherPurchaseOptions: androidpublisher_v3.Schema$OneTimeProductPurchaseOption[]; + /** Upstream listings, so locales kit doesn't model survive. */ + listings: androidpublisher_v3.Schema$OneTimeProductListing[]; + /** Output-only version Play generated the preserved configs at. */ + regionsVersion?: string; +} + +/** + * Reads the product's current purchase options. + * + * `updateMask: "purchaseOptions"` makes Play REPLACE the repeated field, + * so an update that doesn't first read what's there wipes both the + * regions and the purchase options it omits. Returns empty state when + * the product doesn't exist yet — the caller then treats it as a create. + */ +async function readExistingOneTimeProduct( + androidpublisher: androidpublisher_v3.Androidpublisher, + args: AndroidOneTimeProductUpsertArgs, +): Promise { + const empty: ExistingOneTimeProductState = { + regionsByCode: new Map(), + otherPurchaseOptions: [], + listings: [], + }; + + let product: androidpublisher_v3.Schema$OneTimeProduct | undefined; + try { + const response = await androidpublisher.monetization.onetimeproducts.get({ + packageName: args.packageName, + productId: args.productId, + }); + product = response.data; + } catch (error) { + if (isGoogleNotFoundError(error)) return empty; + throw error; + } + + const options = product.purchaseOptions ?? []; + const buyOption = options.find((option) => option.purchaseOptionId === "buy"); + for (const config of buyOption?.regionalPricingAndAvailabilityConfigs ?? []) { + if (config.regionCode) empty.regionsByCode.set(config.regionCode, config); + } + return { + regionsByCode: empty.regionsByCode, + buyOption, + otherPurchaseOptions: options.filter( + (option) => option.purchaseOptionId !== "buy", + ), + listings: product.listings ?? [], + regionsVersion: product.regionsVersion?.version ?? undefined, + }; +} + export async function upsertModernAndroidOneTimeProduct( androidpublisher: androidpublisher_v3.Androidpublisher, args: AndroidOneTimeProductUpsertArgs, options: { allowCreate: boolean }, -): Promise { +): Promise<{ manualAction?: AndroidManualAction }> { + if (args.priceAmountMicros === undefined || !args.currency) { + throw new Error( + "One-time product requires priceAmountMicros + currency to mint a Play purchase option; otherwise the product will not be purchasable.", + ); + } + const basePrice = microsToGoogleMoney(args.priceAmountMicros, args.currency); + + // Read before write: `updateMask: "purchaseOptions"` replaces the + // repeated field wholesale, so an update that skipped this would strip + // every region the operator has configured in Play Console. The read + // also runs on the create path — `allowMissing` upserts, so a "create" + // can land on a product that already exists (retry after a partial + // sync) and must not flatten it either. + const existing = await readExistingOneTimeProduct(androidpublisher, args); + + const conversion = await convertAndroidRegionPrices( + androidpublisher, + args.packageName, + basePrice, + ); + const converted = conversion.response; + const convertedVersion = converted?.regionVersion?.version; + if ( + convertedVersion && + existing.regionsVersion && + convertedVersion !== existing.regionsVersion + ) { + const convertedCodes = new Set( + Object.entries(converted.convertedRegionPrices ?? {}) + .filter(([, value]) => value.price) + .map(([regionCode]) => regionCode), + ); + const staleBuyRegions = [...existing.regionsByCode.keys()].filter( + (regionCode) => !convertedCodes.has(regionCode), + ); + const preservedOtherOptions = existing.otherPurchaseOptions.some( + (option) => + (option.regionalPricingAndAvailabilityConfigs?.length ?? 0) > 0, + ); + if (staleBuyRegions.length > 0 || preservedOtherOptions) { + throw new Error( + `Play moved "${args.productId}" from regions version ${existing.regionsVersion} to ${convertedVersion}, but kit cannot safely translate ` + + (staleBuyRegions.length > 0 + ? `the preserved ${staleBuyRegions.join(", ")} price config${staleBuyRegions.length === 1 ? "" : "s"}` + : "prices on purchase options other than buy") + + ". Update those prices in Play Console, then run sync again so kit can read the current-version configs.", + ); + } + } + // Three states, and the difference between the last two is the whole + // point of this block: + // + // ["US","KR"] — an explicit footprint. Everything else is withdrawn. + // "all" — sell wherever Play prices. Expands on purpose. + // unset — inherit. A product Play has never seen goes out + // everywhere (Play Console's own default, and the fix + // for #288); one that already exists keeps the exact + // regions it has and is only repriced. + // + // That last case is why `unset` is not simply "all": pushing an + // existing US-only product would otherwise expand it to every market + // Play prices, on a sync the operator ran to change a price. Nothing + // is withdrawn here — the excluded regions have no config to withdraw, + // so the footprint branch below skips them. + const explicitFootprint = Array.isArray(args.regions) + ? new Set(args.regions) + : undefined; + const explicitlyManagedFootprint = + explicitFootprint !== undefined || args.regions === "all"; + const inheritedFootprint = + args.regions === undefined && existing.regionsByCode.size > 0 + ? new Set(existing.regionsByCode.keys()) + : undefined; + const allowedRegions = explicitFootprint ?? inheritedFootprint; + const { configs: regionalConfigs, repricedRegions } = + buildRegionalPricingConfigs( + converted, + basePrice, + args.productId, + existing.regionsByCode, + allowedRegions, + explicitlyManagedFootprint, + ); + + // "Other regions" pricing covers markets Play launches later. Play + // requires both USD and EUR here, so it only goes out when the + // conversion supplied both. + // Two rules meet here. Without a footprint, per-region availability is + // preserved, so this has to be too: an operator who withdrew "other + // regions" in Play Console has said "do not follow Play into new + // markets", and re-pricing must not silently opt them back in — only + // the amounts are refreshed. With an explicit footprint it has to be + // OFF, and merely omitting it is not enough, because the existing + // purchase option is spread into the write and would carry a + // previously-enabled config forward. It has to be actively withdrawn. + const existingNewRegions = existing.buyOption?.newRegionsConfig; + const otherRegions = explicitFootprint + ? undefined + : // Inheriting: refresh the amounts if Play already follows new + // markets for this product, but never switch that on. Creating it + // here would opt an existing product into every market Play + // launches from now on — the same silent expansion the inherited + // footprint exists to prevent, just deferred. + inheritedFootprint && !existingNewRegions + ? undefined + : converted?.convertedOtherRegionsPrice; + const newRegionsConfig = + otherRegions?.usdPrice && otherRegions.eurPrice + ? { + // `"all"` is an explicit request to follow Play into future + // markets, so it reactivates a previously withdrawn config. + // Inherit mode preserves the operator's Play Console choice. + availability: + args.regions === "all" + ? "AVAILABLE" + : (existingNewRegions?.availability ?? "AVAILABLE"), + usdPrice: otherRegions.usdPrice, + eurPrice: otherRegions.eurPrice, + } + : explicitFootprint && + existingNewRegions && + (existingNewRegions.availability ?? "AVAILABLE") === "AVAILABLE" + ? { ...existingNewRegions, availability: "NO_LONGER_AVAILABLE" } + : // Explicit, not omitted: `undefined` would rely on the spread + // carrying the old value, which is the same reasoning that + // makes the withdrawal above necessary. Only an explicit + // footprint withdraws — inheriting leaves the operator's own + // Play Console setting exactly as it is. + existingNewRegions; + // The generated method owns the PATCH route. In googleapis v157 the // upsert route is the lowercase `/onetimeproducts/{productId}` path, // which differs from the camel-case routes used by sibling methods. @@ -1047,9 +1809,178 @@ export async function upsertModernAndroidOneTimeProduct( productId: args.productId, allowMissing: options.allowCreate, updateMask: "listings,purchaseOptions", - "regionsVersion.version": "2022/01", - requestBody: buildAndroidOneTimeProduct(args), + "regionsVersion.version": regionsVersionFor( + converted, + existing.regionsVersion, + ), + requestBody: buildAndroidOneTimeProduct( + args, + regionalConfigs, + newRegionsConfig, + existing, + ), }); + + // Availability is optional in Play's schema and absent means + // AVAILABLE, so both the stale count and the unpriced check go through + // one predicate rather than testing the string directly. It is a + // NOT-withdrawn test, not an equals-AVAILABLE one: a region priced + // ahead of release comes back as AVAILABLE_IF_RELEASED and is still a + // region the product is sold in. Reading it as "not live" made the + // stale count disagree with the configs it was counting. + const isLive = ( + config: androidpublisher_v3.Schema$OneTimeProductPurchaseOptionRegionalPricingAndAvailabilityConfig, + ) => + (config.availability ?? "AVAILABLE") !== "NO_LONGER_AVAILABLE" && + !(allowedRegions && !allowedRegions.has(config.regionCode ?? "")); + + // A requested region Play returned no price for is silently absent + // from the write, so say so. This backstops the region-code validator: + // a code that is well-formed and assigned but not a Play sales region + // reaches here rather than disappearing. + const unpriced = explicitFootprint + ? [...explicitFootprint].filter( + (region) => + !regionalConfigs.some( + (config) => config.regionCode === region && isLive(config), + ), + ) + : []; + const unpricedNote = + unpriced.length > 0 + ? ` Play does not sell "${args.productId}" in ${unpriced.join(", ")}, so ${unpriced.length === 1 ? "that region was" : "those regions were"} skipped — remove ${unpriced.length === 1 ? "it" : "them"} from the product's sales regions, or check the code.` + : ""; + + // Both numbers must come from the SAME set of final configs. Mixing a + // filtered numerator with an unfiltered counter made `stale` go + // negative when a withdrawn region happened to be repriced, which + // silently dropped the whole warning. + const live = regionalConfigs.filter(isLive); + const applied = live.filter((config) => + repricedRegions.has(config.regionCode ?? ""), + ).length; + const stale = live.length - applied; + + // Inheriting a narrow footprint is the safe choice, not necessarily + // the intended one — an operator whose product is US-only because of + // the bug this release fixes would otherwise never find out. Say how + // many markets are being left on the table, once the numbers are known + // to be real (a failed conversion knows nothing about availability). + const convertedRegions = Object.entries( + converted?.convertedRegionPrices ?? {}, + ) + .filter(([, value]) => value.price) + .map(([region]) => region); + const inheritedLiveRegionCount = inheritedFootprint + ? convertedRegions.filter((region) => { + const config = existing.regionsByCode.get(region); + return ( + config !== undefined && + (config.availability ?? "AVAILABLE") !== "NO_LONGER_AVAILABLE" + ); + }).length + : 0; + const expandable = inheritedFootprint + ? convertedRegions.length - inheritedLiveRegionCount + : 0; + const expansionNote = + expandable > 0 + ? ` "${args.productId}" remains available in ${inheritedLiveRegionCount} of the ${convertedRegions.length} regions Play returned prices for. Its current footprint was preserved — set the product's sales regions to "all" to publish the other ${expandable}.` + : ""; + + if (convertedRegionCount(converted) > 0) { + // Conversion worked; what is left worth saying is whether a + // requested region has no Play price, and whether kit declined to + // expand. Reported as its own action rather than short-circuiting + // the conversion-failure report below. + const note = `${unpricedNote}${expansionNote}`.trim(); + return note + ? { + manualAction: { + productId: args.productId, + code: expansionNote + ? "regional_expansion_available" + : "regional_pricing_incomplete", + message: note, + }, + } + : {}; + } + + // Conversion failed. The write still went out, but only `applied` of + // the product's live regions could legally take the new amount — the + // rest kept their previous prices. Report exactly that; "pushed, no + // failures" would read as "the new price is live everywhere". + const amount = `${args.currency} ${(args.priceAmountMicros / 1_000_000).toFixed(2)}`; + return { + manualAction: { + productId: args.productId, + code: "regional_pricing_incomplete", + message: + `Play could not convert ${amount} into regional prices for "${args.productId}", so it applied to ${applied} region(s)` + + (stale > 0 ? ` and ${stale} region(s) kept their previous price` : "") + + `. Set the remaining prices in Play Console → the product's purchase option → Set prices, or re-run the sync.` + + (conversion.error ? ` Play reported: ${conversion.error}` : "") + + unpricedNote, + }, + }; +} + +/** + * Legacy `inappproducts` keeps listings as a locale-keyed map rather + * than an array. `defaultLanguage` must name one of these keys, which + * the base locale always satisfies. + */ +function legacyListingsMap( + args: AndroidOneTimeProductUpsertArgs, + existing: { + [locale: string]: androidpublisher_v3.Schema$InAppProductListing; + } = {}, +): { + [locale: string]: androidpublisher_v3.Schema$InAppProductListing; +} { + const listings: { + [locale: string]: androidpublisher_v3.Schema$InAppProductListing; + } = { ...existing }; + for (const row of listingRowsForProduct(args)) { + listings[row.locale] = { + title: row.title, + description: row.description ?? row.title, + }; + } + return listings; +} + +/** + * The legacy `inappproducts` API has no region concept — it prices a SKU + * from `defaultPrice` and, with `autoConvertMissingPrices`, everywhere + * else. An operator who named their sales regions cannot be served by + * it, and silently publishing everywhere would be the opposite of what + * they asked for. Raised before the modern attempt so a genuine modern + * failure is reported as itself. + */ +/** Message for an unknown upstream throwable, without "[object Object]". */ +function describeUpstreamError(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + const message = (error as { message?: unknown } | null)?.message; + return typeof message === "string" ? message : "an unknown error"; +} + +export function assertLegacyPathUsableFor( + args: AndroidOneTimeProductUpsertArgs, + modernError?: unknown, +): void { + // "all" is not a footprint the legacy API cannot honour — it prices + // every region, which is exactly what "all" asks for. Only an explicit + // list has no legal expression there. + if (args.regions === "all" || !args.regions?.length) return; + throw new Error( + `"${args.productId}" specifies sales regions, but this app fell back to Play's legacy in-app-products API, which prices every region or none. Remove the region list, or migrate the app to Play's one-time products model.` + + (modernError + ? ` The one-time-products API reported: ${describeUpstreamError(modernError)}` + : ""), + ); } async function insertLegacyAndroidOneTimeProduct( @@ -1058,18 +1989,17 @@ async function insertLegacyAndroidOneTimeProduct( ): Promise { await androidpublisher.inappproducts.insert({ packageName: args.packageName, + // Without this the legacy API prices the SKU in the merchant + // currency only and leaves every other region unbuyable — the + // legacy-path half of issue #288. + autoConvertMissingPrices: true, requestBody: { packageName: args.packageName, sku: args.productId, purchaseType: "managedUser", status: "active", - defaultLanguage: "en-US", - listings: { - "en-US": { - title: args.title, - description: args.description, - }, - }, + defaultLanguage: args.baseLocale ?? BASE_LISTING_LOCALE, + listings: legacyListingsMap(args), defaultPrice: { priceMicros: String(args.priceAmountMicros), currency: args.currency, @@ -1082,19 +2012,23 @@ async function patchLegacyAndroidOneTimeProduct( androidpublisher: androidpublisher_v3.Androidpublisher, args: AndroidOneTimeProductUpsertArgs, ): Promise { + // PATCH replaces the locale-keyed map. Read first so a locale authored in + // Play Console is not deleted when kit updates only its own listings. + const existing = await androidpublisher.inappproducts.get({ + packageName: args.packageName, + sku: args.productId, + }); await androidpublisher.inappproducts.patch({ packageName: args.packageName, sku: args.productId, + autoConvertMissingPrices: true, requestBody: { packageName: args.packageName, sku: args.productId, purchaseType: "managedUser", - listings: { - "en-US": { - title: args.title, - description: args.description, - }, - }, + defaultLanguage: + args.baseLocale ?? existing.data.defaultLanguage ?? BASE_LISTING_LOCALE, + listings: legacyListingsMap(args, existing.data.listings ?? {}), defaultPrice: { priceMicros: String(args.priceAmountMicros), currency: args.currency, @@ -1258,20 +2192,6 @@ export function mapModernPlayOneTimeState( return "Removed"; } -function pickPlayTitle( - product: androidpublisher_v3.Schema$InAppProduct, -): string | undefined { - const def = product.defaultLanguage ?? "en-US"; - return product.listings?.[def]?.title ?? undefined; -} - -function pickPlayDescription( - product: androidpublisher_v3.Schema$InAppProduct, -): string | undefined { - const def = product.defaultLanguage ?? "en-US"; - return product.listings?.[def]?.description ?? undefined; -} - export function playPriceMicrosToNumber( raw: string | undefined | null, ): number | undefined { @@ -1281,6 +2201,21 @@ export function playPriceMicrosToNumber( return Number.isSafeInteger(n) && n >= 0 ? n : undefined; } +/** Preserve every legacy Play listing and its declared default locale. */ +export function splitLegacyPlayListings( + product: androidpublisher_v3.Schema$InAppProduct, +): ReturnType { + return splitStoreListings( + Object.entries(product.listings ?? {}).map(([locale, listing]) => ({ + locale, + title: listing.title, + description: listing.description, + })), + product.sku ?? "product", + product.defaultLanguage ?? BASE_LISTING_LOCALE, + ); +} + function parsePlayPriceMicros( product: androidpublisher_v3.Schema$InAppProduct, ): number | undefined { @@ -1312,7 +2247,39 @@ function pickPlayCurrency( // if any region offers it, otherwise return the first region with a // readable price. Currency + price come from the SAME regionalConfig // so they're always consistent. -function pickSubBasePlanPrice(sub: androidpublisher_v3.Schema$Subscription): { +/** + * Chooses which region's price represents a pulled one-time product. + * + * Preference order, and why each step exists: + * 1. the authored currency in the US region, then anywhere — pushing + * converts the operator's base price into every region, so a + * US-first rule would read a KRW/JPY row back as its converted + * dollar amount and the next push would convert from that already + * converted number; + * 2. US, then any USD region — Play prices several non-US regions in + * USD (EC, SV, TL, ZW…), so matching on currency alone would + * resolve a plain USD row to whichever of those Play listed first; + * 3. whatever is left, for a first import kit has never priced. + */ +export function pickPlayRegionalPrice< + T extends { regionCode?: string | null; currencyCode?: string | null }, +>(candidates: T[], authoredCurrency?: string): T | undefined { + return ( + (authoredCurrency + ? (candidates.find( + (c) => c.regionCode === "US" && c.currencyCode === authoredCurrency, + ) ?? candidates.find((c) => c.currencyCode === authoredCurrency)) + : undefined) ?? + candidates.find((c) => c.regionCode === "US") ?? + candidates.find((c) => c.currencyCode === "USD") ?? + candidates[0] + ); +} + +export function pickSubBasePlanPrice( + sub: androidpublisher_v3.Schema$Subscription, + preferredCurrency?: string, +): { priceAmountMicros?: number; currency?: string; // The basePlanId of the plan whose price we picked, so the caller @@ -1335,11 +2302,18 @@ function pickSubBasePlanPrice(sub: androidpublisher_v3.Schema$Subscription): { } } if (candidates.length === 0) return {}; - // Prefer USD when any region offers it — it's the most universally - // recognizable in a dashboard. The operator can edit per-region - // prices in Play Console; this just picks a stable display value. + // Prefer the currency the kit row already carries. Pushing converts + // the operator's base price into every region, so a USD-first rule + // would read a KRW/JPY-authored row back as its converted dollar + // amount and the next push would convert from that already-converted + // number. Falls back to USD — the most universally recognizable + // dashboard value — for rows kit hasn't priced yet. const preferred = - candidates.find((c) => c.price.currencyCode === "USD") ?? candidates[0]; + (preferredCurrency + ? candidates.find((c) => c.price.currencyCode === preferredCurrency) + : undefined) ?? + candidates.find((c) => c.price.currencyCode === "USD") ?? + candidates[0]; return { priceAmountMicros: moneyToMicros(preferred.price), currency: preferred.price.currencyCode ?? undefined, @@ -1351,11 +2325,13 @@ function pickSubBasePlanPrice(sub: androidpublisher_v3.Schema$Subscription): { // into kit's uniform `offers[]` shape. Each base plan becomes a // `kind: "BasePlan"` row carrying its billing period + USD price; each // associated subscription offer (free trial / intro discount, set up -// in Play Console) becomes a Free-Trial / IntroPay* row. Prefers USD -// regional price when present (mirrors `pickSubBasePlanPrice`'s -// rationale) so the dashboard shows a stable currency. +// in Play Console) becomes a Free-Trial / IntroPay* row. Prefers the +// currency the kit row already carries, then USD, so a KRW/JPY-authored +// subscription doesn't show its base plan in one currency and its +// offers in another. function collectPlaySubscriptionOffers( sub: androidpublisher_v3.Schema$Subscription, + preferredCurrency?: string, ): Array<{ id: string; kind: @@ -1405,6 +2381,10 @@ function collectPlaySubscriptionOffers( if (!plan.basePlanId) continue; const planRegions = plan.regionalConfigs ?? []; const planPrice = + (preferredCurrency + ? planRegions.find((r) => r.price?.currencyCode === preferredCurrency) + ?.price + : undefined) ?? planRegions.find((r) => r.price?.currencyCode === "USD")?.price ?? planRegions[0]?.price; out.push({ @@ -1427,6 +2407,11 @@ function collectPlaySubscriptionOffers( phases.forEach((phase, i) => { const phaseRegions = phase.regionalConfigs ?? []; const phasePrice = + (preferredCurrency + ? phaseRegions.find( + (r) => r.price?.currencyCode === preferredCurrency, + )?.price + : undefined) ?? phaseRegions.find((r) => r.price?.currencyCode === "USD")?.price ?? phaseRegions[0]?.price; // Phase with no price = free trial; with `recurrenceCount > 1` diff --git a/packages/kit/convex/products/query.ts b/packages/kit/convex/products/query.ts index 4d9507ac0..11db3ffcc 100644 --- a/packages/kit/convex/products/query.ts +++ b/packages/kit/convex/products/query.ts @@ -1,3 +1,5 @@ +import { productLocalizationsValidator } from "./localizations"; +import { productRegionsValidator } from "./regions"; import { query, type QueryCtx } from "../_generated/server"; import { ConvexError, v, type Infer } from "convex/values"; import type { Doc, Id } from "../_generated/dataModel"; @@ -67,6 +69,9 @@ const productShape = v.object({ ), title: v.string(), description: v.optional(v.string()), + baseLocale: v.optional(v.string()), + localizations: v.optional(productLocalizationsValidator), + regions: v.optional(productRegionsValidator), priceAmountMicros: v.optional(v.number()), currency: v.optional(v.string()), state: v.union( @@ -121,6 +126,22 @@ function shape( type: product.type, title: product.title, description: product.description, + baseLocale: product.baseLocale, + // Coerce the nullable column to optional: "cleared" and "never set" + // read the same, and the dashboard form needs this to prefill rather + // than silently discarding stored locales on the next save. + localizations: product.localizations ?? undefined, + // Hide stale values written before region support was limited to + // Android one-time products. Returning them would make REST/MCP clients + // believe an iOS or subscription footprint is active even though no + // store push can apply it. + regions: + product.platform === "Android" && + product.type !== "Subscription" && + (product.regions === "all" || + (Array.isArray(product.regions) && product.regions.length > 0)) + ? product.regions + : undefined, priceAmountMicros: product.priceAmountMicros, currency: product.currency, state: product.state, diff --git a/packages/kit/convex/products/regions.test.ts b/packages/kit/convex/products/regions.test.ts new file mode 100644 index 000000000..53e411187 --- /dev/null +++ b/packages/kit/convex/products/regions.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeProductRegions } from "./regions"; + +describe("normalizeProductRegions", () => { + it("upper-cases, trims, de-duplicates, and sorts", () => { + expect(normalizeProductRegions([" kr ", "us", "KR", "jp"])).toEqual([ + "JP", + "KR", + "US", + ]); + }); + + it("treats absent or empty as the unset inheritance mode", () => { + expect(normalizeProductRegions(undefined)).toBeUndefined(); + expect(normalizeProductRegions([])).toBeUndefined(); + }); + + it('preserves the explicit "all" expansion mode', () => { + expect(normalizeProductRegions("all")).toBe("all"); + }); + + it("rejects anything that is not an ISO 3166-1 alpha-2 code", () => { + for (const code of ["USA", "u", "", "12", "en-US", "K R"]) { + expect(() => normalizeProductRegions([code])).toThrow( + /Invalid sales region/, + ); + } + }); +}); + +// CodeRabbit round 4: the format check alone accepted reserved codes +// like ZZ, which upsertProduct stored and the Android sync then silently +// dropped. +describe("assigned-region validation", () => { + it("accepts current ISO territories plus XK", () => { + expect(normalizeProductRegions(["QA", "XK", "GB"])).toEqual([ + "GB", + "QA", + "XK", + ]); + }); + + it("rejects macroregions and CLDR compatibility aliases", () => { + for (const code of ["EU", "EZ", "UN", "UK"]) { + expect(() => normalizeProductRegions([code])).toThrow( + /Invalid sales region/, + ); + } + }); + + it("rejects deleted country assignments", () => { + for (const code of ["AN", "BU", "CS", "SU", "TP", "YU", "ZR"]) { + expect(() => normalizeProductRegions([code])).toThrow( + /Invalid sales region/, + ); + } + }); + + it("rejects CLDR pseudo-regions", () => { + for (const code of ["AC", "CP", "DG", "EA", "IC", "TA"]) { + expect(() => normalizeProductRegions([code])).toThrow( + /Invalid sales region/, + ); + } + }); + + it("rejects reserved and unassigned codes", () => { + for (const code of ["ZZ", "AA", "QQ", "QM", "XA"]) { + expect(() => normalizeProductRegions([code])).toThrow( + /Invalid sales region/, + ); + } + }); +}); + +// Round 6: the reserved-range branch returned early for QA–QL, waving +// unassigned codes past the CLDR check that would have caught them. +describe("reserved-range handling", () => { + it("still rejects unassigned codes below the reserved span", () => { + for (const code of ["QB", "QL"]) { + expect(() => normalizeProductRegions([code])).toThrow( + /Invalid sales region/, + ); + } + }); + + it("keeps QA, which is Qatar", () => { + expect(normalizeProductRegions(["QA"])).toEqual(["QA"]); + }); +}); diff --git a/packages/kit/convex/products/regions.ts b/packages/kit/convex/products/regions.ts new file mode 100644 index 000000000..c4260a853 --- /dev/null +++ b/packages/kit/convex/products/regions.ts @@ -0,0 +1,108 @@ +import { ConvexError, v } from "convex/values"; + +// Where a product is sold. Leaving this unset uses Play's sell-everywhere +// default for a new product, while an existing product inherits its +// current footprint. An explicit list lets an operator who only ships to +// a few markets say so, and `"all"` requests a deliberate expansion. +// +// Note this is product-level. An app is only installable in the +// countries it is distributed to, so regions beyond that are inert +// either way; the list matters for operators who want the catalog to +// state their footprint rather than inherit Play's whole map, and for +// keeping a product out of regions Play adds in future. + +// The current ISO 3166-1 alpha-2 assignment table, plus XK (Kosovo), +// which Play and CLDR commonly expose even though ISO reserves it for +// user assignment. Keep this explicit: Intl.DisplayNames also recognizes +// macroregions, compatibility aliases, deleted assignments, and CLDR +// pseudo-regions that Play does not accept as country sales regions. +const ASSIGNED_REGION_CODES = new Set( + ` +AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ +BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ +CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ +DE DJ DK DM DO DZ +EC EE EG EH ER ES ET +FI FJ FK FM FO FR +GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY +HK HM HN HR HT HU +ID IE IL IM IN IO IQ IR IS IT +JE JM JO JP +KE KG KH KI KM KN KP KR KW KY KZ +LA LB LC LI LK LR LS LT LU LV LY +MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ +NA NC NE NF NG NI NL NO NP NR NU NZ +OM +PA PE PF PG PH PK PL PM PN PR PS PT PW PY +QA +RE RO RS RU RW +SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ +TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ +UA UG UM US UY UZ +VA VC VE VG VI VN VU +WF WS +XK +YE YT +ZA ZM ZW + ` + .trim() + .split(/\s+/), +); + +/** + * Whether a code names a current country or territory Play may price. + */ +function isAssignedRegion(code: string): boolean { + return ASSIGNED_REGION_CODES.has(code); +} + +/** + * A product's sales footprint, as three distinct states: + * + * - `["US","KR"]` — exactly these regions; anything else is withdrawn. + * - `"all"` — wherever Play prices the product, including markets it + * launches later. An expansion the operator asked for. + * - unset — inherit. New products go everywhere (Play Console's own + * default); a product Play already knows keeps the regions it has. + * + * The third state exists because "unset" used to mean "all", which + * turned a price edit on a US-only product into a push to 173 markets. + */ +export const productRegionsValidator = v.union( + v.literal("all"), + v.array(v.string()), +); + +export type ProductRegions = "all" | string[]; + +/** + * Normalizes and validates an operator-supplied sales-region list. + * + * @param regions Raw codes from the dashboard / MCP / REST. + * @returns Sorted, de-duplicated codes, or undefined when unset. + * @throws When a code is not a two-letter ISO 3166-1 alpha-2 region. + */ +export function normalizeProductRegions( + regions: ProductRegions | undefined, +): ProductRegions | undefined { + if (regions === "all") return "all"; + // An empty list is not a footprint of zero regions — Play has no way + // to express "sold nowhere", and a product that reaches the write with + // one would be silently unbuyable. Treat it as unset, the same way a + // cleared field in the dashboard means "stop restricting". + if (!regions || regions.length === 0) return undefined; + + const seen = new Set(); + for (const raw of regions) { + const code = raw.trim().toUpperCase(); + if (!isAssignedRegion(code)) { + // Structured so REST/MCP surface a 400 rather than a generic 500. + throw new ConvexError({ + code: "INVALID_INPUT", + message: `Invalid sales region "${raw}". Use an assigned two-letter ISO 3166-1 country code such as "US" or "KR".`, + }); + } + seen.add(code); + } + return Array.from(seen).sort(); +} diff --git a/packages/kit/convex/products/sync.test.ts b/packages/kit/convex/products/sync.test.ts index 7b9d09e02..98ae3be0e 100644 --- a/packages/kit/convex/products/sync.test.ts +++ b/packages/kit/convex/products/sync.test.ts @@ -5,6 +5,7 @@ import { deletePlatformCatalog as registeredDeletePlatformCatalog, deleteRemovedProductRow as registeredDeleteRemovedProductRow, isSafePriceAmountMicros, + listDraftAndroidProducts as registeredListDraftAndroidProducts, listDraftIosProducts as registeredListDraftIosProducts, listRemovedAndroidProducts as registeredListRemovedAndroidProducts, markPushed as registeredMarkPushed, @@ -18,6 +19,9 @@ const deleteRemovedProductRow = testableFunction( registeredDeleteRemovedProductRow, ); const upsertFromStore = testableFunction(registeredUpsertFromStore); +const listDraftAndroidProducts = testableFunction( + registeredListDraftAndroidProducts, +); const listDraftIosProducts = testableFunction(registeredListDraftIosProducts); const listRemovedAndroidProducts = testableFunction( registeredListRemovedAndroidProducts, @@ -401,6 +405,84 @@ describe("listDraftIosProducts review resumption", () => { }); }); +describe("draft product region worker boundaries", () => { + const base = { + projectId: "project_a", + state: "Draft", + title: "Title", + origin: "kit", + }; + + it("never forwards stale region metadata to the iOS worker", async () => { + const db = new TestDb({ + products: [ + { + _id: "ios_product", + ...base, + platform: "IOS", + productId: "premium.ios", + type: "Consumable", + regions: ["US"], + }, + ], + }); + + await expect( + listDraftIosProducts._handler( + { db }, + { projectId: "project_a" as never }, + ), + ).resolves.toEqual([ + expect.not.objectContaining({ regions: expect.anything() }), + ]); + }); + + it("drops empty and subscription footprints before the Play worker", async () => { + const db = new TestDb({ + products: [ + { + _id: "empty_product", + ...base, + platform: "Android", + productId: "empty", + type: "Consumable", + regions: [], + }, + { + _id: "subscription_product", + ...base, + platform: "Android", + productId: "subscription", + type: "Subscription", + regions: "all", + }, + { + _id: "restricted_product", + ...base, + platform: "Android", + productId: "restricted", + type: "NonConsumable", + regions: ["US", "KR"], + }, + ], + }); + + const rows = await listDraftAndroidProducts._handler( + { db }, + { projectId: "project_a" as never }, + ); + expect(rows.find((row) => row.productId === "empty")?.regions).toBe( + undefined, + ); + expect(rows.find((row) => row.productId === "subscription")?.regions).toBe( + undefined, + ); + expect(rows.find((row) => row.productId === "restricted")).toMatchObject({ + regions: ["US", "KR"], + }); + }); +}); + describe("catalog deletion client-payload retention", () => { it("keeps client metadata after a pushed Removed row is hard-deleted", async () => { const db = new TestDb({ @@ -547,3 +629,70 @@ describe("pending-deletion sync write guards", () => { }); } }); + +describe("upsertFromStore localization preservation", () => { + const seeded = { + _id: "product_a", + projectId: "project_a", + platform: "IOS", + productId: "premium", + type: "Subscription", + title: "Premium", + state: "Active", + origin: "kit", + storeRef: "store_ref", + localizations: [{ locale: "ko-KR", title: "프리미엄" }], + updatedAt: 1, + }; + + function db() { + return new TestDb({ + organizations: [{ _id: "organization_a", pendingDeletion: false }], + projects: [ + { + _id: "project_a", + organizationId: "organization_a", + pendingDeletion: false, + }, + ], + products: [{ ...seeded }], + }); + } + + const pull = (extra: Record) => ({ + projectId: "project_a" as never, + productId: "premium", + platform: "IOS" as const, + type: "Subscription" as const, + title: "Premium", + storeRef: "store_ref", + state: "Active" as const, + ...extra, + }); + + it("keeps kit-authored locales when the pull reports none", async () => { + // ASC omits the field entirely (its localizations live on version + // sub-resources), and Play omits it for a product whose only listing + // is the base locale. Overwriting here would delete a locale the + // operator authored in kit, and the push side — which merges rather + // than replaces — would then have nothing to republish. + const store = db(); + await upsertFromStore._handler({ db: store }, pull({})); + + expect(store.tables.products[0].localizations).toEqual([ + { locale: "ko-KR", title: "프리미엄" }, + ]); + }); + + it("adopts locales the store does report", async () => { + const store = db(); + await upsertFromStore._handler( + { db: store }, + pull({ localizations: [{ locale: "ja-JP", title: "プレミアム" }] }), + ); + + expect(store.tables.products[0].localizations).toEqual([ + { locale: "ja-JP", title: "プレミアム" }, + ]); + }); +}); diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts index ebe3c7833..b357cdb54 100644 --- a/packages/kit/convex/products/sync.ts +++ b/packages/kit/convex/products/sync.ts @@ -1,5 +1,8 @@ import { internalMutation, internalQuery } from "../_generated/server"; import { v } from "convex/values"; + +import { productLocalizationsValidator } from "./localizations"; +import { productRegionsValidator } from "./regions"; import type { Doc, Id } from "../_generated/dataModel"; import { assertProjectWritable } from "../projects/writable"; @@ -94,6 +97,13 @@ export const upsertFromStore = internalMutation({ type: typeValidator, title: v.string(), description: v.optional(v.string()), + baseLocale: v.optional(v.string()), + // No `v.null()` here, unlike the author-facing `upsertProduct`: on + // the pull path a store read never deletes a kit-authored locale + // (see the ASC call site), so `null` would be accepted and then + // silently coalesce back to the existing value. Rejecting it keeps + // the no-op from looking like a clear. + localizations: v.optional(productLocalizationsValidator), priceAmountMicros: v.optional(v.number()), currency: v.optional(v.string()), storeRef: v.string(), @@ -173,6 +183,8 @@ export const upsertFromStore = internalMutation({ type: args.type, title: args.title || existing.title, description: args.description ?? existing.description, + baseLocale: args.baseLocale ?? existing.baseLocale, + localizations: args.localizations ?? existing.localizations, priceAmountMicros: args.priceAmountMicros ?? existing.priceAmountMicros, currency: args.currency ?? existing.currency, storeRef: args.storeRef, @@ -209,6 +221,8 @@ export const upsertFromStore = internalMutation({ type: args.type, title: args.title, description: args.description, + baseLocale: args.baseLocale, + localizations: args.localizations, priceAmountMicros: args.priceAmountMicros, currency: args.currency, storeRef: args.storeRef, @@ -329,6 +343,11 @@ export const listExistingProductTypes = internalQuery({ v.object({ productId: v.string(), type: typeValidator, + // Lets the pull rank Play's regional prices by the currency the + // operator authored rather than always collapsing to US/USD, + // which would overwrite a KRW row with its converted dollar + // amount on the first sync after a push. + currency: v.optional(v.string()), }), ), handler: async (ctx, args) => { @@ -341,6 +360,7 @@ export const listExistingProductTypes = internalQuery({ return rows.map((row) => ({ productId: row.productId, type: row.type, + currency: row.currency, })); }, }); @@ -370,6 +390,8 @@ export const listDraftIosProducts = internalQuery({ type: typeValidator, title: v.string(), description: v.optional(v.string()), + baseLocale: v.optional(v.string()), + localizations: v.optional(productLocalizationsValidator), priceAmountMicros: v.optional(v.number()), currency: v.optional(v.string()), billingPeriod: v.optional( @@ -430,6 +452,11 @@ export const listDraftIosProducts = internalQuery({ type: row.type, title: row.title, description: row.description, + baseLocale: row.baseLocale, + // Coerce the nullable columns to optional at the worker + // boundary: "cleared" and "never set" are the same thing to a + // store push, and null would trip the validator. + localizations: row.localizations ?? undefined, priceAmountMicros: row.priceAmountMicros, currency: row.currency, billingPeriod: row.billingPeriod, @@ -455,6 +482,9 @@ export const listDraftAndroidProducts = internalQuery({ type: typeValidator, title: v.string(), description: v.optional(v.string()), + baseLocale: v.optional(v.string()), + localizations: v.optional(productLocalizationsValidator), + regions: v.optional(productRegionsValidator), priceAmountMicros: v.optional(v.number()), currency: v.optional(v.string()), billingPeriod: v.optional( @@ -503,6 +533,21 @@ export const listDraftAndroidProducts = internalQuery({ type: row.type, title: row.title, description: row.description, + baseLocale: row.baseLocale, + // Coerce the nullable columns to optional at the worker + // boundary: "cleared" and "never set" are the same thing to a + // store push, and null would trip the validator. + localizations: row.localizations ?? undefined, + // Only Android one-time products have a writable product-level + // footprint. Old development rows can contain an empty list or a + // value left behind before this guard existed; never let either + // reach the Play worker as an explicit "withdraw everything" order. + regions: + row.type !== "Subscription" && + (row.regions === "all" || + (Array.isArray(row.regions) && row.regions.length > 0)) + ? row.regions + : undefined, priceAmountMicros: row.priceAmountMicros, currency: row.currency, billingPeriod: row.billingPeriod, diff --git a/packages/kit/convex/purchases/android.test.ts b/packages/kit/convex/purchases/android.test.ts index bce30309a..b827a15a4 100644 --- a/packages/kit/convex/purchases/android.test.ts +++ b/packages/kit/convex/purchases/android.test.ts @@ -1,7 +1,11 @@ +import { google, type Common } from "googleapis"; import { describe, expect, it } from "vitest"; import { isProductNotFoundError, mapProductResponseToReceiptData, + mapGoogleTokenNoLongerValidResponse, + selectProductLineItem, + verifyPurchaseWithGooglePlay, mapSubscriptionResponseToReceiptData, parseTimeToMillis, recordGooglePlayVerifiedSubscription, @@ -34,6 +38,16 @@ describe("parseTimeToMillis", () => { }); }); +describe("revoked Google token response", () => { + it("marks the ambiguous UNKNOWN state as an explicit stable rejection", () => { + expect(mapGoogleTokenNoLongerValidResponse()).toEqual({ + isValid: false, + state: HarmonizedPurchaseState.UNKNOWN, + stableRejection: true, + }); + }); +}); + describe("Google Play v2 mappings", () => { it("maps productsv2.getproductpurchasev2 PURCHASED + acknowledged + not consumed to ENTITLED", () => { const fixtures = [ @@ -183,6 +197,37 @@ describe("Google Play v2 mappings", () => { expect(receipt.priceAmountMicros).toBe(49_990_000); }); + it("selects the expected subscription before expiry ranking", () => { + const expectedExpiry = "2099-01-01T00:00:00.000Z"; + const laterOtherExpiry = "2099-02-01T00:00:00.000Z"; + const receipt = mapSubscriptionResponseToReceiptData({ + packageName, + purchaseToken: "multi-sub-token", + expectedProductId: "premium_monthly", + subscriptionResponse: { + subscriptionState: "SUBSCRIPTION_STATE_ACTIVE", + acknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED", + lineItems: [ + { + productId: "premium_monthly", + expiryTime: expectedExpiry, + latestSuccessfulOrderId: "GPA.monthly", + }, + { + productId: "premium_yearly", + expiryTime: laterOtherExpiry, + latestSuccessfulOrderId: "GPA.yearly", + }, + ], + }, + }); + + expect(receipt.productId).toBe("premium_monthly"); + expect(receipt.orderId).toBe("GPA.monthly"); + expect(receipt.expiryTime).toBe(Date.parse(expectedExpiry)); + expect(mapToGooglePlayReceiptResponse(receipt).isValid).toBe(true); + }); + it("maps productsv2.get purchased consumable that has been consumed to CONSUMED", () => { const productPurchaseV2Response = { kind: "androidpublisher#productPurchaseV2", @@ -563,3 +608,285 @@ describe("isProductNotFoundError", () => { expect(isProductNotFoundError(undefined)).toBe(false); }); }); + +// Issue #289: a token that covers more than one line item resolved to +// whichever item Google listed first, so `expectedProductId` could be +// compared against the wrong product and reject a valid purchase. +describe("selectProductLineItem", () => { + const bulbs = { productId: "dev.hyo.martie.10bulbs" }; + const premium = { productId: "dev.hyo.martie.premium" }; + + it("prefers the line item the caller expects", () => { + expect( + selectProductLineItem([bulbs, premium], "dev.hyo.martie.premium"), + ).toBe(premium); + }); + + it("falls back to the first item when the expectation doesn't match", () => { + expect( + selectProductLineItem([bulbs, premium], "dev.hyo.martie.absent"), + ).toBe(bulbs); + }); + + it("keeps the historical first-item behaviour when nothing is expected", () => { + expect(selectProductLineItem([bulbs, premium])).toBe(bulbs); + }); + + it("is safe on empty and missing line items", () => { + expect(selectProductLineItem([])).toBeUndefined(); + expect(selectProductLineItem(undefined)).toBeUndefined(); + expect(selectProductLineItem(null)).toBeUndefined(); + }); + + it("resolves a multi-item token to the expected product end to end", () => { + const receipt = mapProductResponseToReceiptData({ + packageName, + purchaseToken: "token-multi", + productResponse: { + purchaseStateContext: { purchaseState: "PURCHASED" }, + acknowledgementState: "ACKNOWLEDGEMENT_STATE_PENDING", + productLineItem: [ + { + productId: "dev.hyo.martie.10bulbs", + productOfferDetails: { + quantity: 1, + consumptionState: "CONSUMPTION_STATE_YET_TO_BE_CONSUMED", + }, + }, + { + productId: "dev.hyo.martie.premium", + productOfferDetails: { + quantity: 3, + consumptionState: "CONSUMPTION_STATE_YET_TO_BE_CONSUMED", + }, + }, + ], + }, + expectedProductId: "dev.hyo.martie.premium", + }); + + expect(receipt.productId).toBe("dev.hyo.martie.premium"); + expect(receipt.quantity).toBe(3); + // Would previously have been INAUTHENTIC: productId resolved to the + // first line item and then failed the expectedProductId comparison. + expect(mapToGooglePlayReceiptResponse(receipt).isValid).toBe(true); + }); +}); + +// Issue #289: productsv2/subscriptionsv2 are eventually consistent, so a +// token seconds old can 404 in both. 4xx is excluded from +// `retryOnTransient`, so that became a hard failure on the first attempt +// — the app then never acknowledged, and Google voided the purchase at +// ~301s. +describe("verifyPurchaseWithGooglePlay fresh-token retry", () => { + function stubPublisher(responder: (attempt: number) => unknown) { + let calls = 0; + const androidpublisher = google.androidpublisher({ + version: "v3", + // gaxios adds its own retry on top of every call. A thrown + // adapter error looks like a network failure to it, which would + // triple each count and hide what this test measures — kit's own + // retry depth. Production 404s arrive as HTTP responses and are + // not gaxios-retried, so disabling it here matches reality. + retryConfig: { retry: 0, noResponseRetries: 0 }, + adapter: async ( + request: Common.gaxios.GaxiosOptionsPrepared, + ): Promise> => { + calls += 1; + const data = responder(calls); + if (data === undefined) { + throw Object.assign(new Error("not found"), { code: 404 }); + } + return Object.assign(new Response(null, { status: 200 }), { + config: request, + data: data as T, + }); + }, + }); + return { androidpublisher, callCount: () => calls }; + } + + const freshPurchase = { + purchaseStateContext: { purchaseState: "PURCHASED" }, + acknowledgementState: "ACKNOWLEDGEMENT_STATE_PENDING", + productLineItem: [ + { + productId: "dev.hyo.martie.10bulbs", + productOfferDetails: { + quantity: 1, + consumptionState: "CONSUMPTION_STATE_YET_TO_BE_CONSUMED", + }, + }, + ], + }; + + it("recovers a token that has not propagated yet", async () => { + // Attempts 1-2 are the product+subscription pair for a token Google + // doesn't know about yet; the product lookup then succeeds. + const { androidpublisher, callCount } = stubPublisher((attempt) => + attempt <= 2 ? undefined : freshPurchase, + ); + + const result = await verifyPurchaseWithGooglePlay(androidpublisher, { + packageName, + purchaseToken: "fresh-token", + expectedProductId: undefined, + }); + + expect(result.receiptData.productId).toBe("dev.hyo.martie.10bulbs"); + expect(mapToGooglePlayReceiptResponse(result.receiptData).isValid).toBe( + true, + ); + expect(callCount()).toBe(3); + }); + + it("gives up quickly on a token that genuinely does not exist", async () => { + // Every attempt 404s. The retry must stay shallow: each attempt + // costs TWO Play calls, so a bogus-token probe would otherwise + // multiply upstream cost and hold a request open. + const { androidpublisher, callCount } = stubPublisher(() => undefined); + + await expect( + verifyPurchaseWithGooglePlay(androidpublisher, { + packageName, + purchaseToken: "bogus-token", + expectedProductId: undefined, + }), + ).rejects.toThrow(); + // 3 attempts x (product + subscription). Each extra attempt would + // double the upstream cost of a token that will never resolve. + expect(callCount()).toBe(6); + }); + + it("fails fast on a permission error instead of retrying it", async () => { + // The predicate is the whole point of the retry: only "Google has + // never heard of this token" is transient. Widening it to every + // error would sit on an operator's revoked service account for + // three rounds of two calls, and would do the same for a package + // mismatch that is never going to start working. + const { androidpublisher, callCount } = stubPublisher(() => { + throw Object.assign(new Error("The caller does not have permission"), { + code: 403, + }); + }); + + await expect( + verifyPurchaseWithGooglePlay(androidpublisher, { + packageName, + purchaseToken: "any-token", + expectedProductId: undefined, + }), + ).rejects.toThrow(/permission/i); + // One product call. Not even the subscription fallback: a 403 is not + // "this token isn't a product", it's "this credential can't ask". + expect(callCount()).toBe(1); + }); +}); + +// The multi-item fix has two independent wires: the action must pass +// `expectedProductId` down to the lookup, and the lookup must pass it +// into the mapper. Either can be cut without a unit test on +// `selectProductLineItem` noticing. +describe("expectedProductId hand-off", () => { + const multiItem = { + purchaseStateContext: { purchaseState: "PURCHASED" }, + acknowledgementState: "ACKNOWLEDGEMENT_STATE_PENDING", + productLineItem: [ + { + productId: "dev.hyo.martie.10bulbs", + productOfferDetails: { + quantity: 1, + consumptionState: "CONSUMPTION_STATE_YET_TO_BE_CONSUMED", + }, + }, + { + productId: "dev.hyo.martie.premium", + productOfferDetails: { + quantity: 2, + consumptionState: "CONSUMPTION_STATE_YET_TO_BE_CONSUMED", + }, + }, + ], + }; + + function stub(responder: () => unknown) { + return google.androidpublisher({ + version: "v3", + retryConfig: { retry: 0, noResponseRetries: 0 }, + adapter: async ( + request: Common.gaxios.GaxiosOptionsPrepared, + ): Promise> => + Object.assign(new Response(null, { status: 200 }), { + config: request, + data: responder() as T, + }), + }); + } + + it("resolves the expected item through verifyPurchaseWithGooglePlay", async () => { + const result = await verifyPurchaseWithGooglePlay( + stub(() => multiItem), + { + packageName, + purchaseToken: "multi", + expectedProductId: "dev.hyo.martie.premium", + }, + ); + + // Severing the hand-off resolves to the first item instead, which + // then fails applyExpectedProductId and reports INAUTHENTIC. + expect(result.receiptData.productId).toBe("dev.hyo.martie.premium"); + expect(result.receiptData.quantity).toBe(2); + }); + + it("keeps first-item behaviour when nothing is expected", async () => { + const result = await verifyPurchaseWithGooglePlay( + stub(() => multiItem), + { packageName, purchaseToken: "multi", expectedProductId: undefined }, + ); + + expect(result.receiptData.productId).toBe("dev.hyo.martie.10bulbs"); + }); + + it("forwards the expected product to a multi-item subscription", async () => { + let calls = 0; + const publisher = google.androidpublisher({ + version: "v3", + retryConfig: { retry: 0, noResponseRetries: 0 }, + adapter: async ( + request: Common.gaxios.GaxiosOptionsPrepared, + ): Promise> => { + calls += 1; + if (calls === 1) { + throw Object.assign(new Error("not found"), { code: 404 }); + } + return Object.assign(new Response(null, { status: 200 }), { + config: request, + data: { + subscriptionState: "SUBSCRIPTION_STATE_ACTIVE", + acknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED", + lineItems: [ + { + productId: "premium_monthly", + expiryTime: "2099-01-01T00:00:00.000Z", + }, + { + productId: "premium_yearly", + expiryTime: "2099-02-01T00:00:00.000Z", + }, + ], + } as T, + }); + }, + }); + + const result = await verifyPurchaseWithGooglePlay(publisher, { + packageName, + purchaseToken: "multi-sub", + expectedProductId: "premium_monthly", + }); + + expect(result.receiptData.productId).toBe("premium_monthly"); + expect(calls).toBe(2); + }); +}); diff --git a/packages/kit/convex/purchases/android.ts b/packages/kit/convex/purchases/android.ts index f25e0aab0..c90db5a4b 100644 --- a/packages/kit/convex/purchases/android.ts +++ b/packages/kit/convex/purchases/android.ts @@ -108,6 +108,7 @@ export const verifyGooglePlayReceiptInternalV1 = action({ await verifyPurchaseWithGooglePlay(androidpublisher, { packageName, purchaseToken: args.purchaseToken, + expectedProductId: args.expectedProductId, }); // The Play API cannot mark an inapp purchase as consumable, so consult @@ -200,17 +201,14 @@ export const verifyGooglePlayReceiptInternalV1 = action({ // have no way to differentiate between the two situations. isPlayStoreTokenNoLongerValidError(error) ) { - const harmonizedState = HarmonizedPurchaseState.UNKNOWN; + const receiptResponse = mapGoogleTokenNoLongerValidResponse(); await persistFailedGoogleReceipt(ctx, { ...buildFailedReceiptParams(error), - state: harmonizedState, + state: receiptResponse.state, }); - return { - isValid: false, - state: harmonizedState, - }; + return receiptResponse; } if ( @@ -228,6 +226,17 @@ export const verifyGooglePlayReceiptInternalV1 = action({ }, }); +export function mapGoogleTokenNoLongerValidResponse() { + return { + isValid: false, + state: HarmonizedPurchaseState.UNKNOWN, + // UNKNOWN normally remains retryable because successfully fetched + // future states also map there. Google's explicit 410 is different: + // the token is revoked and replaying it cannot change the verdict. + stableRejection: true, + } as const; +} + export async function recordGooglePlayVerifiedSubscription( ctx: Pick, params: { @@ -356,9 +365,11 @@ export function mapSubscriptionResponseToReceiptData(args: { packageName: string; purchaseToken: string; subscriptionResponse: androidpublisher_v3.Schema$SubscriptionPurchaseV2; + expectedProductId?: string; }): GooglePlayReceiptData { const lineItem = selectSubscriptionLineItem( args.subscriptionResponse.lineItems ?? [], + args.expectedProductId, ); const purchaseDate = parseTimeToMillis(args.subscriptionResponse.startTime) ?? Date.now(); @@ -392,7 +403,15 @@ function selectSubscriptionLineItem( lineItems: NonNullable< androidpublisher_v3.Schema$SubscriptionPurchaseV2["lineItems"] >, + expectedProductId?: string, ): androidpublisher_v3.Schema$SubscriptionPurchaseLineItem | undefined { + if (expectedProductId !== undefined) { + const expected = lineItems.find( + (lineItem) => lineItem.productId === expectedProductId, + ); + if (expected) return expected; + } + return ( lineItems.reduce< androidpublisher_v3.Schema$SubscriptionPurchaseLineItem | undefined @@ -412,8 +431,12 @@ export function mapProductResponseToReceiptData(args: { packageName: string; purchaseToken: string; productResponse: androidpublisher_v3.Schema$ProductPurchaseV2; + expectedProductId?: string; }): GooglePlayReceiptData { - const lineItem = args.productResponse.productLineItem?.[0]; + const lineItem = selectProductLineItem( + args.productResponse.productLineItem, + args.expectedProductId, + ); const purchaseDate = parseTimeToMillis(args.productResponse.purchaseCompletionTime) ?? Date.now(); @@ -433,13 +456,47 @@ export function mapProductResponseToReceiptData(args: { acknowledgementState: args.productResponse.acknowledgementState || undefined, consumptionState: - lineItem?.productOfferDetails?.consumptionState || - args.productResponse.productLineItem?.[0]?.productOfferDetails - ?.consumptionState || - undefined, + lineItem?.productOfferDetails?.consumptionState || undefined, }; } +/** + * Picks the line item a verification is about. + * + * Reading `productLineItem[0]` unconditionally is wrong once a token + * covers more than one item — Play's newer one-time-product model lets a + * single purchase carry several purchase options, and a multi-item token + * would resolve to whichever item Google happened to list first. When + * the caller told us which product it expects, honour that; otherwise + * keep the historical first-item behaviour. + */ +export function selectProductLineItem( + lineItems: androidpublisher_v3.Schema$ProductLineItem[] | undefined | null, + expectedProductId?: string, +): androidpublisher_v3.Schema$ProductLineItem | undefined { + if (!lineItems?.length) return undefined; + if (expectedProductId) { + const match = lineItems.find( + (item) => item.productId === expectedProductId, + ); + if (match) return match; + } + return lineItems[0]; +} + +/** + * True when Google says it has never heard of this purchase token. + * + * Right after a purchase completes, `productsv2` / `subscriptionsv2` can + * still 404 for a few hundred milliseconds — the write hasn't propagated + * yet. Clients verify immediately (the reporter in issue #289 measured + * t≈1s), so treating that 404 as final rejects a perfectly good purchase + * the app then refuses to acknowledge, and Google voids it at ~301s. + */ +function isFreshTokenNotYetPropagated(error: unknown): boolean { + return error instanceof PlayStorePurchaseNotFoundError; +} + export function isProductNotFoundError(error: unknown): boolean { if ((error as { code?: number } | null)?.code === 404) { return true; @@ -453,11 +510,47 @@ export function isProductNotFoundError(error: unknown): boolean { return message.toLowerCase().includes("not found"); } -async function verifyPurchaseWithGooglePlay( +export async function verifyPurchaseWithGooglePlay( + androidpublisher: androidpublisher_v3.Androidpublisher, + args: { + packageName: string; + purchaseToken: string; + // Required key, nullable value, on purpose: with `?` a caller that + // simply forgets to forward it still compiles, and the multi-line-item + // fix silently reverts to "first item wins". Making the key mandatory + // turns that omission into a type error. + expectedProductId: string | undefined; + }, +): Promise { + // Neither catalog knowing the token can simply mean the purchase is + // seconds old and hasn't propagated yet, so retry the product → + // subscription pair before calling it unknown (issue #289). Only the + // "not found in either" outcome retries; auth, permission, and + // package-mismatch errors still fail fast. + return retryOnTransient( + () => lookUpGooglePlayPurchase(androidpublisher, args), + { + shouldRetry: isFreshTokenNotYetPropagated, + // Deliberately shallow. Each attempt costs TWO Play calls + // (product then subscription), so every extra attempt also + // multiplies the upstream cost of a token that genuinely doesn't + // exist — a bogus-token probe must not become an 8-call, 2-second + // hold. Propagation after a real purchase is sub-second, so three + // attempts inside ~750ms covers it while capping the abuse cost + // at 3x, against Google's ~301s window to acknowledge. + maxAttempts: 3, + baseDelayMs: 250, + maxDelayMs: 500, + }, + ); +} + +async function lookUpGooglePlayPurchase( androidpublisher: androidpublisher_v3.Androidpublisher, args: { packageName: string; purchaseToken: string; + expectedProductId: string | undefined; }, ): Promise { let receiptData: GooglePlayReceiptData; @@ -485,6 +578,7 @@ async function verifyPurchaseWithGooglePlay( packageName: args.packageName, purchaseToken: args.purchaseToken, productResponse: productResponse.data, + expectedProductId: args.expectedProductId, }); remoteResponse = JSON.stringify(productResponse.data ?? null); @@ -516,6 +610,7 @@ async function verifyPurchaseWithGooglePlay( packageName: args.packageName, purchaseToken: args.purchaseToken, subscriptionResponse: subResponse.data, + expectedProductId: args.expectedProductId, }); remoteResponse = JSON.stringify(subResponse.data ?? null); diff --git a/packages/kit/convex/purchases/extract-order-id.test.ts b/packages/kit/convex/purchases/extract-order-id.test.ts index d8ae3c630..d883cb23d 100644 --- a/packages/kit/convex/purchases/extract-order-id.test.ts +++ b/packages/kit/convex/purchases/extract-order-id.test.ts @@ -94,6 +94,28 @@ describe("extractOrderIdFromRemoteResponse", () => { ); }); + it("uses the expected subscription line item order id", () => { + const raw = JSON.stringify({ + kind: "androidpublisher#subscriptionPurchaseV2", + latestOrderId: "GPA.sub-latest-top", + lineItems: [ + { + productId: "pro_monthly", + expiryTime: "2026-01-01T00:00:00.000Z", + latestSuccessfulOrderId: "GPA.sub-monthly", + }, + { + productId: "pro_yearly", + expiryTime: "2026-02-01T00:00:00.000Z", + latestSuccessfulOrderId: "GPA.sub-yearly", + }, + ], + }); + expect(extractOrderIdFromRemoteResponse("google", raw, "pro_monthly")).toBe( + "GPA.sub-monthly", + ); + }); + it("falls back to top-level latestOrderId when line items lack an order id", () => { const raw = JSON.stringify({ kind: "androidpublisher#subscriptionPurchaseV2", diff --git a/packages/kit/convex/purchases/extract-product-id.test.ts b/packages/kit/convex/purchases/extract-product-id.test.ts index 8030cb721..c00504eb7 100644 --- a/packages/kit/convex/purchases/extract-product-id.test.ts +++ b/packages/kit/convex/purchases/extract-product-id.test.ts @@ -104,6 +104,21 @@ describe("extractProductIdFromRemoteResponse", () => { ).toBe("untold_full"); }); + it("uses the expected google v2 product line item", () => { + expect( + extractProductIdFromRemoteResponse( + "google", + JSON.stringify({ + productLineItem: [ + { productId: "coins_100" }, + { productId: "premium_monthly" }, + ], + }), + "premium_monthly", + ), + ).toBe("premium_monthly"); + }); + it("reads productId from a google subscription lineItems array", () => { expect( extractProductIdFromRemoteResponse( @@ -135,6 +150,27 @@ describe("extractProductIdFromRemoteResponse", () => { ).toBe("pro_yearly"); }); + it("uses the expected subscription product before expiry ranking", () => { + expect( + extractProductIdFromRemoteResponse( + "google", + JSON.stringify({ + lineItems: [ + { + productId: "pro_monthly", + expiryTime: "2026-01-01T00:00:00.000Z", + }, + { + productId: "pro_yearly", + expiryTime: "2026-02-01T00:00:00.000Z", + }, + ], + }), + "pro_monthly", + ), + ).toBe("pro_monthly"); + }); + it("is null-safe when google line-item arrays are empty", () => { expect( extractProductIdFromRemoteResponse( diff --git a/packages/kit/convex/purchases/internal.ts b/packages/kit/convex/purchases/internal.ts index 9ecf467fa..d99057453 100644 --- a/packages/kit/convex/purchases/internal.ts +++ b/packages/kit/convex/purchases/internal.ts @@ -66,8 +66,18 @@ export async function savePurchaseInternal({ } const now = Date.now(); - const productId = extractProductIdFromRemoteResponse(store, remoteResponse); - const orderId = extractOrderIdFromRemoteResponse(store, remoteResponse); + const expectedProductId = + requestData.store === "google" ? requestData.expectedProductId : undefined; + const productId = extractProductIdFromRemoteResponse( + store, + remoteResponse, + expectedProductId, + ); + const orderId = extractOrderIdFromRemoteResponse( + store, + remoteResponse, + expectedProductId, + ); // Primary dedup: exact (projectId, remoteId) match. Most Apple and // Horizon flows — plus Google flows where the client replays the diff --git a/packages/kit/convex/purchases/query.ts b/packages/kit/convex/purchases/query.ts index e4f3296d5..0da96c8cb 100644 --- a/packages/kit/convex/purchases/query.ts +++ b/packages/kit/convex/purchases/query.ts @@ -171,6 +171,9 @@ export const getReceiptsByProject = query({ extractProductIdFromRemoteResponse( purchase.store, purchase.remoteResponse, + purchase.requestData.store === "google" + ? purchase.requestData.expectedProductId + : undefined, ), })); @@ -224,6 +227,9 @@ export const getPurchaseById = query({ extractProductIdFromRemoteResponse( purchase.store, purchase.remoteResponse, + purchase.requestData.store === "google" + ? purchase.requestData.expectedProductId + : undefined, ), }; }, diff --git a/packages/kit/convex/purchases/save-purchase-idempotency.test.ts b/packages/kit/convex/purchases/save-purchase-idempotency.test.ts index aecf74c82..d9f631a0e 100644 --- a/packages/kit/convex/purchases/save-purchase-idempotency.test.ts +++ b/packages/kit/convex/purchases/save-purchase-idempotency.test.ts @@ -208,7 +208,11 @@ function buildArgs(overrides: { store?: "apple" | "google" | "horizon"; applicationId?: string; requestData?: - | { store: "google"; purchaseToken: string } + | { + store: "google"; + purchaseToken: string; + expectedProductId?: string; + } | { store: "apple"; jws: string } | { store: "horizon"; userId: string; sku: string }; }) { @@ -250,6 +254,29 @@ describe("savePurchaseInternal — idempotency regression guard", () => { expect(db.purchaseCount()).toBe(1); }); + it("persists the verified expected item from a multi-item token", async () => { + await savePurchaseInternal({ + ctx, + ...buildArgs({ + remoteId: "multi-item-token", + requestData: { + store: "google", + purchaseToken: "multi-item-token", + expectedProductId: "premium_monthly", + }, + remoteResponse: JSON.stringify({ + productLineItem: [ + { productId: "coins_100" }, + { productId: "premium_monthly" }, + ], + }), + }), + }); + + const rows = await db.query("purchases").collect(); + expect(rows[0]?.productId).toBe("premium_monthly"); + }); + it("rejects writes while the project or organization deletion is pending", async () => { const project = await db.get(PROJECT_ID); expect(project).not.toBeNull(); diff --git a/packages/kit/convex/purchases/shared.ts b/packages/kit/convex/purchases/shared.ts index 410191cd5..f28aa51c0 100644 --- a/packages/kit/convex/purchases/shared.ts +++ b/packages/kit/convex/purchases/shared.ts @@ -100,6 +100,11 @@ export const receiptResponseValidator = v.object({ isValid: v.boolean(), state: harmonizedPurchaseStateValidator, productId: v.optional(v.string()), + // 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 + // retryable. The HTTP route consumes this without returning it publicly. + stableRejection: v.optional(v.boolean()), }); export async function getProjectByApiKey( @@ -475,6 +480,7 @@ export function isValidState(state: HarmonizedPurchaseState): boolean { export function extractOrderIdFromRemoteResponse( store: "apple" | "google" | "horizon" | "amazon", remoteResponse?: string | null, + expectedProductId?: string, ): string | null { if (store !== "google" || !remoteResponse) { return null; @@ -501,11 +507,14 @@ export function extractOrderIdFromRemoteResponse( // Subscriptions V2: the top-level identifier is `latestOrderId`, // with `lineItems[].latestSuccessfulOrderId` as the per-line // fallback. `mapSubscriptionResponseToReceiptData` in android.ts - // selects the longest-dated line item, so mirror that selection - // here to keep the write-time column and the receipt-derived id - // in sync. + // selects an expected product first, then the longest-dated line + // item, so mirror that selection here to keep the write-time + // columns and the verified receipt in sync. if ("lineItems" in parsed && Array.isArray(parsed.lineItems)) { - const lineItem = selectGoogleSubscriptionLineItem(parsed.lineItems); + const lineItem = selectGoogleSubscriptionLineItem( + parsed.lineItems, + expectedProductId, + ); if ( lineItem && "latestSuccessfulOrderId" in lineItem && @@ -544,6 +553,7 @@ export function extractOrderIdFromRemoteResponse( export function extractProductIdFromRemoteResponse( store: "apple" | "google" | "horizon" | "amazon", remoteResponse?: string | null, + expectedProductId?: string, ): string | null { if (!remoteResponse) { return null; @@ -566,7 +576,10 @@ export function extractProductIdFromRemoteResponse( "productLineItem" in parsed && Array.isArray(parsed.productLineItem) ) { - const productLineItem = parsed.productLineItem[0]; + const productLineItem = selectGoogleProductLineItem( + parsed.productLineItem, + expectedProductId, + ); if ( productLineItem && typeof productLineItem === "object" && @@ -578,7 +591,10 @@ export function extractProductIdFromRemoteResponse( } if ("lineItems" in parsed && Array.isArray(parsed.lineItems)) { - const lineItem = selectGoogleSubscriptionLineItem(parsed.lineItems); + const lineItem = selectGoogleSubscriptionLineItem( + parsed.lineItems, + expectedProductId, + ); if ( lineItem && "productId" in lineItem && @@ -617,8 +633,29 @@ export function extractProductIdFromRemoteResponse( return null; } +function selectGoogleProductLineItem( + lineItems: unknown[], + expectedProductId?: string, +): Record | null { + let fallback: Record | null = null; + + for (const lineItem of lineItems) { + if (!isRecord(lineItem)) continue; + fallback ??= lineItem; + if ( + expectedProductId !== undefined && + lineItem.productId === expectedProductId + ) { + return lineItem; + } + } + + return fallback; +} + function selectGoogleSubscriptionLineItem( lineItems: unknown[], + expectedProductId?: string, ): Record | null { let fallback: Record | null = null; let selected: Record | null = null; @@ -627,6 +664,12 @@ function selectGoogleSubscriptionLineItem( for (const lineItem of lineItems) { if (!isRecord(lineItem)) continue; fallback ??= lineItem; + if ( + expectedProductId !== undefined && + lineItem.productId === expectedProductId + ) { + return lineItem; + } if (typeof lineItem.expiryTime !== "string") continue; const score = Date.parse(lineItem.expiryTime); diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts index 4ebdd27b2..733388c5a 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -612,6 +612,11 @@ const schema = defineSchema({ rawSignedPayload: v.optional(v.string()), occurredAt: v.number(), receivedAt: v.number(), + // Set in the same mutation that applies the lifecycle transition and + // incremental stats delta. Unlike subscriptions.lastEventId, this remains + // attached to the retained event after newer events arrive, so an old + // Pub/Sub / ASN redelivery cannot replay its transition over current state. + appliedAt: v.optional(v.number()), }) .index("by_project", ["projectId"]) .index("by_purchase_token", ["purchaseToken"]) @@ -635,10 +640,9 @@ const schema = defineSchema({ // same messageId, and a project-less key would cross-pollute their // dedup state. (Apple's notificationUUID is globally unique so the // projectId scope is redundant for ASN, but matching one shape - // keeps the lookup path simple.) Duplicates detected here cause - // kit to silently ACK the upstream request with 200 without storing or - // reapplying the lifecycle transition, matching Apple's documented retry - // expectation and Google's at-least-once Pub/Sub contract. + // keeps the lookup path simple.) Duplicates detected here reuse the stored + // event while ingestion idempotently reapplies its lifecycle transition, + // allowing a retry to repair a partially completed first attempt. // `projectId` is optional during the rollout so already-written // rows still validate; new inserts always populate it. webhookIdempotencyKeys: defineTable({ @@ -878,6 +882,45 @@ const schema = defineSchema({ ), title: v.string(), description: v.optional(v.string()), + // Store-listing text in additional languages. `title` / + // `description` above stay the base listing (en-US for authored rows; + // store pulls may preserve another `baseLocale`); this only adds + // locales on top. Locale codes are BCP-47 + // ("ko-KR", "ja-JP"), which is what both Play `languageCode` and + // ASC localization `locale` accept. + // Widened to include `null` because Convex treats `undefined` in a + // patch as "leave unchanged" — without a null the last localization + // could never be removed and every push would keep republishing it. + // Same reason `subscriptionGroupId` above is nullable. + localizations: v.optional( + v.union( + v.array( + v.object({ + locale: v.string(), + title: v.string(), + description: v.optional(v.string()), + }), + ), + v.null(), + ), + ), + // Locale represented by the required `title` / `description` fields. + // Authored rows default to en-US; store pulls persist another locale + // when a catalog has no en-US listing so the next push cannot invent a + // mislabeled English listing. Optional for all historical rows. + baseLocale: v.optional(v.string()), + // Sales regions, in three states: a list restricts the product to + // exactly those markets; "all" sells wherever Play prices it, + // including markets Play launches later; unset inherits — a product + // Play has never seen goes out everywhere (the behaviour that fixes + // issue #288), and one that already exists keeps the regions it has + // rather than being expanded by a sync run to change its price. + // Nullable for the same reason `localizations` is — Convex treats + // `undefined` in a patch as "leave unchanged", so clearing needs an + // explicit null. + regions: v.optional( + v.union(v.literal("all"), v.array(v.string()), v.null()), + ), priceAmountMicros: v.optional(v.number()), currency: v.optional(v.string()), state: v.union( diff --git a/packages/kit/convex/subscriptions/internal.test.ts b/packages/kit/convex/subscriptions/internal.test.ts index 794898d13..92e841f25 100644 --- a/packages/kit/convex/subscriptions/internal.test.ts +++ b/packages/kit/convex/subscriptions/internal.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { HarmonizedPurchaseState } from "../purchases/purchaseState"; import { + applySubscriptionEventHandler, bindSubscriptionToUserHandler, buildVerifiedSubscriptionSnapshot, mergeVerifiedSubscriptionSnapshot, @@ -78,7 +79,7 @@ class MemDb { this.table(tableName).set(id, { ...doc, _id: id, - _creationTime: Date.now(), + _creationTime: Date.now() + this.counter / 1_000, }); return id; } @@ -129,6 +130,167 @@ function makeCtx(db: MemDb) { const PROJECT_ID = "projects_seed_1"; const TOKEN = "purchase_token_1"; +async function seedWebhookEvent( + db: MemDb, + args: { + type: "SubscriptionStarted" | "SubscriptionExpired"; + notificationId: string; + occurredAt: number; + }, +): Promise { + return await db.insert("webhookEvents", { + projectId: PROJECT_ID, + type: args.type, + source: "GooglePlayRealTimeDeveloperNotifications", + platform: "Android", + environment: "Sandbox", + purchaseToken: TOKEN, + sourceNotificationId: args.notificationId, + productId: "premium_monthly", + subscriptionState: + args.type === "SubscriptionExpired" ? "Expired" : "Active", + expiresAt: 1_800_000_000_000, + renewsAt: 1_800_000_000_000, + currency: "USD", + priceAmountMicros: 9_990_000, + occurredAt: args.occurredAt, + receivedAt: args.occurredAt, + }); +} + +describe("applySubscriptionEventHandler", () => { + beforeEach(() => { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("applies a recorded-but-unapplied event exactly once on redelivery", async () => { + const db = new MemDb(); + db.seedProduct({ + projectId: PROJECT_ID, + platform: "Android", + productId: "premium_monthly", + billingPeriod: "P1M", + }); + const eventId = await seedWebhookEvent(db, { + type: "SubscriptionStarted", + notificationId: "message-a", + occurredAt: 1_000, + }); + const args = { + projectId: PROJECT_ID as never, + eventId: eventId as never, + }; + + await expect( + applySubscriptionEventHandler(makeCtx(db), args), + ).resolves.toMatchObject({ transition: "Started", active: true }); + const appliedAt = db.rows("webhookEvents")[0]?.appliedAt; + + await expect( + applySubscriptionEventHandler(makeCtx(db), args), + ).resolves.toMatchObject({ transition: null, active: true }); + expect(db.rows("webhookEvents")[0]?.appliedAt).toBe(appliedAt); + expect(db.rows("subscriptions")).toMatchObject([ + { state: "Active", lastEventId: eventId }, + ]); + expect(db.rows("subscriptionStats")).toMatchObject([ + { activeSubs: 1, mrrMicros: 9_990_000 }, + ]); + }); + + it("applies distinct same-timestamp events without replaying the old one", async () => { + const db = new MemDb(); + db.seedProduct({ + projectId: PROJECT_ID, + platform: "Android", + productId: "premium_monthly", + billingPeriod: "P1M", + }); + const startedId = await seedWebhookEvent(db, { + type: "SubscriptionStarted", + notificationId: "message-a", + occurredAt: 1_000, + }); + const expiredId = await seedWebhookEvent(db, { + type: "SubscriptionExpired", + notificationId: "message-b", + occurredAt: 1_000, + }); + + await applySubscriptionEventHandler(makeCtx(db), { + projectId: PROJECT_ID as never, + eventId: startedId as never, + }); + await applySubscriptionEventHandler(makeCtx(db), { + projectId: PROJECT_ID as never, + eventId: expiredId as never, + }); + await expect( + applySubscriptionEventHandler(makeCtx(db), { + projectId: PROJECT_ID as never, + eventId: startedId as never, + }), + ).resolves.toMatchObject({ transition: null, active: false }); + + expect(db.rows("subscriptions")).toMatchObject([ + { state: "Expired", lastEventId: expiredId }, + ]); + expect(db.rows("subscriptionStats")).toMatchObject([ + { activeSubs: 0, mrrMicros: 0 }, + ]); + }); + + it("uses ingestion order to backfill a same-timestamp legacy event", async () => { + const db = new MemDb(); + db.seedProduct({ + projectId: PROJECT_ID, + platform: "Android", + productId: "premium_monthly", + billingPeriod: "P1M", + }); + const startedId = await seedWebhookEvent(db, { + type: "SubscriptionStarted", + notificationId: "legacy-a", + occurredAt: 1_000, + }); + const expiredId = await seedWebhookEvent(db, { + type: "SubscriptionExpired", + notificationId: "legacy-b", + occurredAt: 1_000, + }); + await applySubscriptionEventHandler(makeCtx(db), { + projectId: PROJECT_ID as never, + eventId: startedId as never, + }); + await applySubscriptionEventHandler(makeCtx(db), { + projectId: PROJECT_ID as never, + eventId: expiredId as never, + }); + await db.patch(startedId, { appliedAt: undefined }); + + await expect( + applySubscriptionEventHandler(makeCtx(db), { + projectId: PROJECT_ID as never, + eventId: startedId as never, + }), + ).resolves.toMatchObject({ transition: null, active: false }); + + expect( + db.rows("webhookEvents").find((row) => row._id === startedId), + ).toHaveProperty("appliedAt", Date.now()); + expect(db.rows("subscriptions")).toMatchObject([ + { state: "Expired", lastEventId: expiredId }, + ]); + expect(db.rows("subscriptionStats")).toMatchObject([ + { activeSubs: 0, mrrMicros: 0 }, + ]); + }); +}); + describe("buildVerifiedSubscriptionSnapshot", () => { it("bootstraps an active subscription from an entitled Google verification", () => { const snapshot = buildVerifiedSubscriptionSnapshot({ diff --git a/packages/kit/convex/subscriptions/internal.ts b/packages/kit/convex/subscriptions/internal.ts index d9ba0d572..9585540a9 100644 --- a/packages/kit/convex/subscriptions/internal.ts +++ b/packages/kit/convex/subscriptions/internal.ts @@ -1,5 +1,5 @@ import { internalMutation, type MutationCtx } from "../_generated/server"; -import { v, type Infer } from "convex/values"; +import { v } from "convex/values"; import type { Doc, Id } from "../_generated/dataModel"; import { HarmonizedPurchaseState } from "../purchases/purchaseState"; @@ -11,37 +11,24 @@ import { import { applyStatsTransition, statsContributionFor } from "./stats"; import { assertProjectWritable } from "../projects/writable"; -const subscriptionStateValidator = v.union( - v.literal("Active"), - v.literal("InGracePeriod"), - v.literal("InBillingRetry"), - v.literal("Expired"), - v.literal("Revoked"), - v.literal("Refunded"), - v.literal("Paused"), - v.literal("Unknown"), -); - const subscriptionPlatformValidator = v.union( v.literal("IOS"), v.literal("Android"), ); -const eventInputValidator = v.object({ - type: v.string(), - productId: v.optional(v.string()), - subscriptionState: v.optional(subscriptionStateValidator), - expiresAt: v.optional(v.number()), - renewsAt: v.optional(v.number()), - cancellationReason: v.optional(v.string()), - currency: v.optional(v.string()), - priceAmountMicros: v.optional(v.number()), - platform: subscriptionPlatformValidator, - purchaseToken: v.string(), -}); - -type RawEventInput = Infer; -type SubscriptionState = Infer; +type RawEventInput = Pick< + Doc<"webhookEvents">, + | "type" + | "productId" + | "subscriptionState" + | "expiresAt" + | "renewsAt" + | "cancellationReason" + | "currency" + | "priceAmountMicros" + | "platform" +> & { purchaseToken: string }; +type SubscriptionState = Doc<"subscriptions">["state"]; type SubscriptionCancellationReason = NonNullable< Doc<"subscriptions">["cancellationReason"] >; @@ -109,79 +96,147 @@ interface PersistSubscriptionSnapshotArgs { lastEventId?: Id<"webhookEvents">; } -// Apply a webhook event to the canonical `subscriptions` table. Idempotent -// with respect to `lastEventId` so a re-run of the same event (after a -// retry / replay) doesn't double-count metrics. +interface ApplySubscriptionEventArgs { + projectId: Id<"projects">; + eventId: Id<"webhookEvents">; +} + +interface ApplySubscriptionEventResult { + transition: string | null; + active: boolean; + subscriptionId?: Id<"subscriptions">; +} + +// Apply a webhook event to the canonical `subscriptions` table. The event's +// durable appliedAt marker is committed in the same Convex transaction as +// the subscription and stats writes, so both retry gaps are safe: a crash +// before this mutation can be repaired, while any event this mutation already +// processed can never be replayed after a newer lastEventId replaces it. export const applySubscriptionEvent = internalMutation({ args: { projectId: v.id("projects"), eventId: v.id("webhookEvents"), - event: eventInputValidator, }, returns: v.object({ transition: v.union(v.string(), v.null()), active: v.boolean(), subscriptionId: v.optional(v.id("subscriptions")), }), - handler: async (ctx, args) => { - await assertProjectWritable(ctx, args.projectId); - const existing = await findSubscriptionByToken( - ctx, - args.projectId, - args.event.purchaseToken, - ); - - if (existing && existing.lastEventId === args.eventId) { - return { - transition: null, - active: isActive(existing), - subscriptionId: existing._id, - }; + handler: async (ctx, args) => applySubscriptionEventHandler(ctx, args), +}); + +export async function applySubscriptionEventHandler( + ctx: MutationCtx, + args: ApplySubscriptionEventArgs, +): Promise { + await assertProjectWritable(ctx, args.projectId); + const storedEvent = await ctx.db.get(args.eventId); + if (!storedEvent || storedEvent.projectId !== args.projectId) { + throw new Error("Webhook event not found for project"); + } + + const now = Date.now(); + if (!storedEvent.purchaseToken) { + if (storedEvent.appliedAt === undefined) { + await ctx.db.patch(storedEvent._id, { appliedAt: now }); } + return { transition: null, active: false }; + } - const current: CurrentSubscription = existing - ? { - state: existing.state, - productId: existing.productId, - expiresAt: existing.expiresAt, - renewsAt: existing.renewsAt, - willRenew: existing.willRenew, - cancellationReason: existing.cancellationReason, - currency: existing.currency, - priceAmountMicros: existing.priceAmountMicros, - } - : null; - - const transition = applySubscriptionTransition( - current, - coerceEventInput(args.event), - ); - - if (!transition.next) { - return { - transition: transition.transition ?? null, - active: false, - subscriptionId: existing?._id, - }; + const existing = await findSubscriptionByToken( + ctx, + args.projectId, + storedEvent.purchaseToken, + ); + const noOpResult = (): ApplySubscriptionEventResult => ({ + transition: null, + active: existing ? isActive(existing) : false, + ...(existing ? { subscriptionId: existing._id } : {}), + }); + + if (storedEvent.appliedAt !== undefined) return noOpResult(); + + // Rollout compatibility for events written before appliedAt existed. The + // current last event proves itself applied; an event older than the current + // last event must be marked handled without being allowed to roll state + // backwards. Store timestamps are only millisecond-precision, so ingestion + // order breaks ties between distinct same-timestamp events. A recorded-but- + // unapplied newest event still falls through and repairs the original + // action/mutation gap. + if (existing?.lastEventId) { + if (existing.lastEventId === args.eventId) { + await ctx.db.patch(storedEvent._id, { appliedAt: now }); + return noOpResult(); + } + const lastEvent = await ctx.db.get(existing.lastEventId); + if ( + lastEvent?.projectId === args.projectId && + lastEvent.purchaseToken === storedEvent.purchaseToken && + lastEvent.platform === storedEvent.platform && + (lastEvent.occurredAt > storedEvent.occurredAt || + (lastEvent.occurredAt === storedEvent.occurredAt && + lastEvent._creationTime > storedEvent._creationTime)) + ) { + await ctx.db.patch(storedEvent._id, { appliedAt: now }); + return noOpResult(); } + } - const subscriptionId = await persistSubscriptionSnapshot(ctx, { - projectId: args.projectId, - platform: args.event.platform, - purchaseToken: args.event.purchaseToken, - existing, - next: transition.next, - now: Date.now(), - lastEventId: args.eventId, - }); + const current: CurrentSubscription = existing + ? { + state: existing.state, + productId: existing.productId, + expiresAt: existing.expiresAt, + renewsAt: existing.renewsAt, + willRenew: existing.willRenew, + cancellationReason: existing.cancellationReason, + currency: existing.currency, + priceAmountMicros: existing.priceAmountMicros, + } + : null; + const event: RawEventInput = { + type: storedEvent.type, + productId: storedEvent.productId, + subscriptionState: storedEvent.subscriptionState, + expiresAt: storedEvent.expiresAt, + renewsAt: storedEvent.renewsAt, + cancellationReason: storedEvent.cancellationReason, + currency: storedEvent.currency, + priceAmountMicros: storedEvent.priceAmountMicros, + platform: storedEvent.platform, + purchaseToken: storedEvent.purchaseToken, + }; + const transition = applySubscriptionTransition( + current, + coerceEventInput(event), + ); + if (!transition.next) { + await ctx.db.patch(storedEvent._id, { appliedAt: now }); return { transition: transition.transition ?? null, - active: transition.active, - subscriptionId, + active: false, + ...(existing ? { subscriptionId: existing._id } : {}), }; - }, -}); + } + + const subscriptionId = await persistSubscriptionSnapshot(ctx, { + projectId: args.projectId, + platform: event.platform, + purchaseToken: event.purchaseToken, + existing, + next: transition.next, + now, + lastEventId: args.eventId, + }); + await ctx.db.patch(storedEvent._id, { appliedAt: now }); + + return { + transition: transition.transition ?? null, + active: transition.active, + subscriptionId, + }; +} export function buildVerifiedSubscriptionSnapshot( input: VerifiedSubscriptionInput, @@ -510,14 +565,12 @@ async function fetchBillingPeriod( function coerceEventInput(raw: RawEventInput): SubscriptionEventInput { return { - type: raw.type as SubscriptionEventInput["type"], + type: raw.type, productId: raw.productId, subscriptionState: raw.subscriptionState, expiresAt: raw.expiresAt, renewsAt: raw.renewsAt, - cancellationReason: raw.cancellationReason as - | SubscriptionEventInput["cancellationReason"] - | undefined, + cancellationReason: raw.cancellationReason, currency: raw.currency, priceAmountMicros: raw.priceAmountMicros, }; diff --git a/packages/kit/convex/webhooks/apple.ts b/packages/kit/convex/webhooks/apple.ts index 0d1703cfa..5e484b42a 100644 --- a/packages/kit/convex/webhooks/apple.ts +++ b/packages/kit/convex/webhooks/apple.ts @@ -196,10 +196,10 @@ export const ingestAppleAsnIOS = action({ }, ); - // Always run applySubscriptionEvent — the mutation is idempotent - // against `lastEventId`, so a no-op when the row is already at - // this eventId is cheap. Skipping on dedup looked tidy in - // telemetry but left the subscription stranded if the previous + // Always run applySubscriptionEvent — the mutation atomically records + // `webhookEvents.appliedAt`, so every later replay is a no-op even after a + // newer event replaces subscriptions.lastEventId. Skipping on dedup looked + // tidy in telemetry but left the subscription stranded if the previous // attempt recorded the event then crashed before patching the // subscription row, since every Apple retry would dedup before // ever reaching the state mutation. @@ -214,18 +214,6 @@ export const ingestAppleAsnIOS = action({ { projectId: project._id, eventId: result.eventId, - event: { - type: normalized.type, - productId: normalized.productId, - subscriptionState: normalized.subscriptionState, - expiresAt: normalized.expiresAt, - renewsAt: normalized.renewsAt, - cancellationReason: normalized.cancellationReason, - currency: normalized.currency, - priceAmountMicros: normalized.priceAmountMicros, - platform: normalized.platform, - purchaseToken: normalized.purchaseToken, - }, }, ); } diff --git a/packages/kit/convex/webhooks/google.test.ts b/packages/kit/convex/webhooks/google.test.ts index 5d2eabff3..e5295d976 100644 --- a/packages/kit/convex/webhooks/google.test.ts +++ b/packages/kit/convex/webhooks/google.test.ts @@ -6,33 +6,63 @@ import { testableFunction } from "../test.setup"; const ingestGoogleRtdn = testableFunction(registeredIngestGoogleRtdn); describe("ingestGoogleRtdn preflight", () => { - it("returns an existing event before Play enrichment or mutations", async () => { + it("repairs subscription state after an event-first partial failure", async () => { const runQuery = vi .fn() .mockResolvedValueOnce({ _id: "project_a", androidPackageName: "dev.openiap.test", }) - .mockResolvedValueOnce("event_existing"); + .mockResolvedValueOnce(null) + // No Play service account: the first attempt still records and applies + // the type-derived event without enrichment. + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + _id: "project_a", + androidPackageName: "dev.openiap.test", + }) + .mockResolvedValueOnce({ + eventId: "event_existing", + type: "SubscriptionRenewed", + platform: "Android", + purchaseToken: "purchase_token", + productId: "premium_monthly", + subscriptionState: "Active", + expiresAt: 2_000, + renewsAt: 2_000, + currency: "USD", + priceAmountMicros: 9_990_000, + }); const runAction = vi.fn(); - const runMutation = vi.fn(); + const runMutation = vi + .fn() + .mockResolvedValueOnce({ eventId: "event_existing", deduped: false }) + // Simulate a crash after webhookEvents commits but before subscriptions. + .mockRejectedValueOnce(new Error("subscription write failed")) + .mockResolvedValueOnce({ transition: "renewed", active: true }); - const result = await ingestGoogleRtdn._handler( - { runAction, runMutation, runQuery }, - { - apiKey: "test_key", - rawMessage: "raw", - payload: { - messageId: "message_existing", - packageName: "dev.openiap.test", - eventTimeMillis: 1_000, - subscriptionNotification: { - notificationType: 2, - purchaseToken: "purchase_token", - subscriptionId: "premium_monthly", - }, + const input = { + apiKey: "test_key", + rawMessage: "raw", + payload: { + messageId: "message_existing", + packageName: "dev.openiap.test", + eventTimeMillis: 1_000, + subscriptionNotification: { + notificationType: 2, + purchaseToken: "purchase_token", + subscriptionId: "premium_monthly", }, }, + }; + + await expect( + ingestGoogleRtdn._handler({ runAction, runMutation, runQuery }, input), + ).rejects.toThrow("subscription write failed"); + + const result = await ingestGoogleRtdn._handler( + { runAction, runMutation, runQuery }, + input, ); expect(result).toEqual({ @@ -40,13 +70,17 @@ describe("ingestGoogleRtdn preflight", () => { type: "WebhookEvent", deduped: true, }); - expect(runQuery).toHaveBeenCalledTimes(2); - expect(runQuery.mock.calls[1]?.[1]).toEqual({ + expect(runQuery).toHaveBeenCalledTimes(5); + expect(runQuery.mock.calls[4]?.[1]).toEqual({ projectId: "project_a", source: "google", sourceNotificationId: "message_existing", }); expect(runAction).not.toHaveBeenCalled(); - expect(runMutation).not.toHaveBeenCalled(); + expect(runMutation).toHaveBeenCalledTimes(3); + expect(runMutation.mock.calls[2]?.[1]).toEqual({ + projectId: "project_a", + eventId: "event_existing", + }); }); }); diff --git a/packages/kit/convex/webhooks/google.ts b/packages/kit/convex/webhooks/google.ts index 186eec5e5..b20f12ede 100644 --- a/packages/kit/convex/webhooks/google.ts +++ b/packages/kit/convex/webhooks/google.ts @@ -149,14 +149,15 @@ export const ingestGoogleRtdn = action({ // Pre-flight idempotency probe: if this messageId already resolves through // the source-aware webhookEvents index (or the phase-1 idempotency-key // fallback), this is a Pub/Sub redelivery for an event we already - // processed. Short-circuit BEFORE - // maybeFetchSubscriptionInfo so retries don't burn Play Developer + // recorded. Reapply the stored event BEFORE returning so a retry repairs + // the gap where the first attempt wrote webhookEvents and then failed + // before updating subscriptions. Still skip maybeFetchSubscriptionInfo so + // retries don't burn Play Developer // API quota on every redelivery — kit's webhook receiver becomes a // multiplier of Play API calls otherwise (one Pub/Sub retry per - // outage minute → one Play API call per retry). The downstream - // recordWebhookEvent + applySubscriptionEvent are still fully - // idempotent, so this is purely a Play-quota / latency optimization. - const preFlightEventId = await ctx.runQuery( + // outage minute → one Play API call per retry). The downstream mutation + // reads the stored event and atomically marks its transition applied. + const preFlightEvent = await ctx.runQuery( internal.webhooks.internal.lookupExistingEvent, { projectId: project._id, @@ -164,9 +165,18 @@ export const ingestGoogleRtdn = action({ sourceNotificationId: args.payload.messageId, }, ); - if (preFlightEventId) { + if (preFlightEvent) { + if (preFlightEvent.purchaseToken) { + await ctx.runMutation( + internal.subscriptions.internal.applySubscriptionEvent, + { + projectId: project._id, + eventId: preFlightEvent.eventId, + }, + ); + } return { - eventId: preFlightEventId, + eventId: preFlightEvent.eventId, type: "WebhookEvent", deduped: true, }; @@ -245,8 +255,8 @@ export const ingestGoogleRtdn = action({ ); // Always run applySubscriptionEvent — see the matching note in - // webhooks/apple.ts. The mutation is idempotent on lastEventId so - // a no-op replay is cheap, but skipping on dedup left the + // webhooks/apple.ts. The mutation is idempotent on webhookEvents.appliedAt, + // but skipping on dedup left the // subscription stranded if a previous attempt persisted the event // then crashed before patching the subscription row (every Google // RTDN retry would dedup before reaching the state mutation). @@ -260,18 +270,6 @@ export const ingestGoogleRtdn = action({ { projectId: project._id, eventId: result.eventId, - event: { - type: normalized.type, - productId: normalized.productId, - subscriptionState: normalized.subscriptionState, - expiresAt: normalized.expiresAt, - renewsAt: normalized.renewsAt, - cancellationReason: normalized.cancellationReason, - currency: normalized.currency, - priceAmountMicros: normalized.priceAmountMicros, - platform: normalized.platform, - purchaseToken: normalized.purchaseToken, - }, }, ); } diff --git a/packages/kit/convex/webhooks/internal.test.ts b/packages/kit/convex/webhooks/internal.test.ts index 70be90bd1..6b2af9691 100644 --- a/packages/kit/convex/webhooks/internal.test.ts +++ b/packages/kit/convex/webhooks/internal.test.ts @@ -190,13 +190,15 @@ describe("recordWebhookEvent pending-deletion guard", () => { }); describe("webhook event-first dedup migration", () => { - it("dedups a replay by the source-aware event index while retaining key writes", async () => { + it("dedups a replay by the source-aware event index, writing no key row", async () => { const db = createWritableDb(); const args = webhookArgs("project_a", "apple", "notification_same"); const first = await recordWebhookEvent._handler({ db }, args); - expect(db.rows("webhookIdempotencyKeys")).toHaveLength(1); - db.rows("webhookIdempotencyKeys").splice(0); + // Phase 2: the event row IS the dedup record. A second row saying + // the same thing doubled the write cost of every webhook. + expect(db.rows("webhookIdempotencyKeys")).toHaveLength(0); + const replay = await recordWebhookEvent._handler({ db }, args); expect(first.deduped).toBe(false); @@ -205,6 +207,34 @@ describe("webhook event-first dedup migration", () => { expect(db.rows("webhookIdempotencyKeys")).toHaveLength(0); }); + it("still adopts and links a half-written legacy key row", async () => { + // Rows written before phase 2 stay in the table for a retention + // window. One that never got an eventId (its event insert failed) + // must still be linked to the event this call creates — the orphan + // sweep deletes unlinked rows, and a replay arriving in between + // would otherwise be processed twice. + const db = createWritableDb({ + webhookIdempotencyKeys: [ + { + _id: "key_legacy", + source: "apple", + sourceNotificationId: "notification_half", + firstSeenAt: 1, + }, + ], + }); + + const result = await recordWebhookEvent._handler( + { db }, + webhookArgs("project_a", "apple", "notification_half"), + ); + + expect(result.deduped).toBe(false); + const keys = db.rows("webhookIdempotencyKeys"); + expect(keys).toHaveLength(1); + expect(keys[0].eventId).toBe(result.eventId); + }); + it("keeps equal notification ids from Apple and Google separate", async () => { const db = createWritableDb(); @@ -253,13 +283,20 @@ describe("webhook event-first dedup migration", () => { { _id: "event_google", projectId: "project_a", + type: "SubscriptionRenewed", source: "GooglePlayRealTimeDeveloperNotifications", + platform: "Android", + purchaseToken: "purchase_token", + productId: "premium_monthly", + subscriptionState: "Active", sourceNotificationId: "message_a", }, { _id: "event_apple", projectId: "project_a", + type: "SubscriptionRenewed", source: "AppleAppStoreServerNotificationsV2", + platform: "IOS", sourceNotificationId: "message_a", }, ], @@ -275,11 +312,34 @@ describe("webhook event-first dedup migration", () => { sourceNotificationId: "message_a", }, ), - ).resolves.toBe("event_google"); + ).resolves.toEqual({ + eventId: "event_google", + type: "SubscriptionRenewed", + platform: "Android", + purchaseToken: "purchase_token", + productId: "premium_monthly", + subscriptionState: "Active", + expiresAt: undefined, + renewsAt: undefined, + cancellationReason: undefined, + currency: undefined, + priceAmountMicros: undefined, + }); }); it("retains the project-keyed preflight fallback during phase 1", async () => { const db = createWritableDb({ + webhookEvents: [ + { + _id: "event_from_key", + projectId: "project_a", + type: "SubscriptionRenewed", + source: "GooglePlayRealTimeDeveloperNotifications", + platform: "Android", + purchaseToken: "purchase_token", + sourceNotificationId: "message_from_key", + }, + ], webhookIdempotencyKeys: [ { _id: "key_existing", @@ -300,7 +360,14 @@ describe("webhook event-first dedup migration", () => { sourceNotificationId: "message_from_key", }, ), - ).resolves.toBe("event_from_key"); + ).resolves.toEqual( + expect.objectContaining({ + eventId: "event_from_key", + type: "SubscriptionRenewed", + platform: "Android", + purchaseToken: "purchase_token", + }), + ); }); it("adopts a half-written legacy key when no event row exists", async () => { diff --git a/packages/kit/convex/webhooks/internal.ts b/packages/kit/convex/webhooks/internal.ts index 93955872e..2fafa1b4b 100644 --- a/packages/kit/convex/webhooks/internal.ts +++ b/packages/kit/convex/webhooks/internal.ts @@ -45,15 +45,18 @@ async function findWebhookEventByDedupKey( // Cheap pre-flight dedup probe used by webhooks/google.ts to avoid // burning Play Developer API quota on Pub/Sub retries. Returns the -// existing eventId if the (projectId, source, sourceNotificationId) -// triple has already been ingested; null otherwise. Distinct from +// recorded subscription fields if the (projectId, source, +// sourceNotificationId) triple has already been ingested; null otherwise. +// Returning the stored fields lets a retry repair subscription state if the +// first attempt wrote the event and then failed before applying it. Distinct from // `recordWebhookEvent` because it's a query (no DB writes) and runs // inside the Pub/Sub action's pre-Play-API path so a retry of an // already-processed messageId can short-circuit before // `purchases.subscriptionsv2.get` ever fires. // -// Phase 1 of issue #241 treats webhookEvents as the authoritative fast path -// while retaining the project-keyed idempotency row as a rollback fallback. +// Phases 1-2 of issue #241 make webhookEvents the authoritative dedup +// record. No new idempotency rows are written; the reads below remain +// only for rows still in the table, and go away with it in phase 4. // Legacy rows (projectId == null) aren't checked here — they can still slip a // duplicate Play API call through, but `recordWebhookEvent` retains the legacy // fallback and will still dedup the actual event row. @@ -63,25 +66,84 @@ export const lookupExistingEvent = internalQuery({ source: v.union(v.literal("apple"), v.literal("google")), sourceNotificationId: v.string(), }, - returns: v.union(v.null(), v.id("webhookEvents")), + returns: v.union( + v.null(), + v.object({ + eventId: v.id("webhookEvents"), + type: v.string(), + platform: v.union(v.literal("IOS"), v.literal("Android")), + purchaseToken: v.optional(v.string()), + productId: v.optional(v.string()), + subscriptionState: v.optional( + v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), + ), + ), + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + cancellationReason: v.optional( + v.union( + v.literal("UserCanceled"), + v.literal("BillingError"), + v.literal("PriceIncreaseDeclined"), + v.literal("ProductUnavailable"), + v.literal("Refunded"), + v.literal("Other"), + ), + ), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + }), + ), handler: async (ctx, args) => { - const existingEvent = await findWebhookEventByDedupKey(ctx.db, { + let existingEvent = await findWebhookEventByDedupKey(ctx.db, { projectId: args.projectId, source: storedSourceForDedupSource(args.source), sourceNotificationId: args.sourceNotificationId, }); - if (existingEvent) return existingEvent._id; + if (!existingEvent) { + const existingKey = await ctx.db + .query("webhookIdempotencyKeys") + .withIndex("by_project_and_source_and_id", (q) => + q + .eq("projectId", args.projectId) + .eq("source", args.source) + .eq("sourceNotificationId", args.sourceNotificationId), + ) + .unique(); + const keyedEvent = existingKey?.eventId + ? await ctx.db.get(existingKey.eventId) + : null; + if ( + keyedEvent?.projectId === args.projectId && + keyedEvent.source === storedSourceForDedupSource(args.source) && + keyedEvent.sourceNotificationId === args.sourceNotificationId + ) { + existingEvent = keyedEvent; + } + } + if (!existingEvent) return null; - const existingKey = await ctx.db - .query("webhookIdempotencyKeys") - .withIndex("by_project_and_source_and_id", (q) => - q - .eq("projectId", args.projectId) - .eq("source", args.source) - .eq("sourceNotificationId", args.sourceNotificationId), - ) - .unique(); - return existingKey?.eventId ?? null; + return { + eventId: existingEvent._id, + type: existingEvent.type, + platform: existingEvent.platform, + purchaseToken: existingEvent.purchaseToken, + productId: existingEvent.productId, + subscriptionState: existingEvent.subscriptionState, + expiresAt: existingEvent.expiresAt, + renewsAt: existingEvent.renewsAt, + cancellationReason: existingEvent.cancellationReason, + currency: existingEvent.currency, + priceAmountMicros: existingEvent.priceAmountMicros, + }; }, }); @@ -173,10 +235,9 @@ export const recordWebhookEvent = internalMutation({ // on transient 5xx, and Google Pub/Sub guarantees at-least-once // delivery — both are normal, both must result in HTTP 200 here. // - // Issue #241 phase 1: read the source-aware webhookEvents index first, - // but keep the idempotency-key fallback and writes below. That makes the - // event table the exercised dedup path before phase 2 stops writing keys, - // while preserving rollback compatibility and the legacy-row drain. + // Issue #241 phases 1-2: the source-aware webhookEvents index is the + // dedup record. The idempotency-key reads below are a drain-only + // fallback for rows written before phase 2 — nothing writes new ones. const storedSource = storedSourceForDedupSource(args.source); if (args.event.sourceFull !== storedSource) { throw new Error( @@ -301,16 +362,20 @@ export const recordWebhookEvent = internalMutation({ // Idempotency key existed without an eventId (a previous attempt // crashed between dedup-row insert and event insert). Patch it // to point at the newly-inserted event so future replays dedup. + // Still done for rows already in the table: until they drain, the + // fallback above can adopt one, and leaving it unlinked would let + // the orphan sweep delete a row a replay is relying on. await ctx.db.patch(existing._id, { eventId }); - } else { - await ctx.db.insert("webhookIdempotencyKeys", { - projectId: args.projectId, - source: args.source, - sourceNotificationId: args.sourceNotificationId, - eventId, - firstSeenAt: now, - }); } + // Issue #241 phase 2: no NEW idempotency row. The event inserted + // just above carries the same (projectId, source, + // sourceNotificationId) triple and is written in this transaction, + // so a replay is deduped by the index read at the top of this + // handler — the key row was a second copy of a guarantee + // webhookEvents already made, at double the write cost per webhook. + // Existing rows stay readable and prunable until they age out past + // WEBHOOK_RETENTION_MS, which is what phase 3 waits for before the + // table and its fallbacks can be dropped. return { eventId, deduped: false }; }, diff --git a/packages/kit/server/api/v1/products.test.ts b/packages/kit/server/api/v1/products.test.ts index 0f40b9442..08a660ecb 100644 --- a/packages/kit/server/api/v1/products.test.ts +++ b/packages/kit/server/api/v1/products.test.ts @@ -580,6 +580,67 @@ describe("productsRoutes", () => { expect(mocks.mutation).not.toHaveBeenCalled(); }); + it("rejects malformed localizations and forwards valid ones", async () => { + const app = buildApp(); + const base = { + productId: "coins_100", + platform: "Android", + type: "Consumable", + title: "100 coins", + }; + const message = + "localizations must be an array of { locale, title, description? } strings"; + const rejected = [ + { ...base, localizations: "ko-KR" }, + { ...base, localizations: [{ locale: "ko-KR" }] }, + { ...base, localizations: [{ locale: 1, title: "코인" }] }, + { + ...base, + localizations: [{ locale: "ko-KR", title: "코인", description: 5 }], + }, + { ...base, localizations: [null] }, + ]; + + for (const body of rejected) { + const response = await app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [{ code: "INVALID_INPUT", message }], + }); + } + expect(mocks.mutation).not.toHaveBeenCalled(); + + // The shape check must not become the validation: locale format, + // length, and duplicate rules live in the Convex mutation so every + // surface shares them, which only works if a well-shaped payload + // actually reaches it intact. + mocks.mutation.mockResolvedValueOnce({ id: "product_1", created: true }); + const accepted = await app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ...base, + localizations: [ + { locale: "ko-KR", title: "코인 100개", description: "코인" }, + { locale: "ja-JP", title: "コイン100個" }, + ], + }), + }); + + expect(accepted.status).toBe(200); + expect(mocks.mutation.mock.calls[0]?.[1]).toMatchObject({ + localizations: [ + { locale: "ko-KR", title: "코인 100개", description: "코인" }, + { locale: "ja-JP", title: "コイン100個" }, + ], + }); + }); + it("rejects invalid product prices before calling Convex", async () => { const app = buildApp(); @@ -776,6 +837,8 @@ describe("productsRoutes", () => { type: "Subscription", title: "Premium", description: undefined, + localizations: undefined, + regions: undefined, priceAmountMicros: undefined, currency: undefined, billingPeriod: "P1M", @@ -786,6 +849,52 @@ describe("productsRoutes", () => { }); }); + it('forwards "all" and [] sales-region states to Convex', async () => { + const app = buildApp(); + mocks.mutation.mockResolvedValue({ id: "product-id", created: false }); + + for (const regions of ["all", []] as const) { + const response = await app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + productId: "coins", + platform: "Android", + type: "Consumable", + title: "Coins", + regions, + }), + }); + + expect(response.status).toBe(200); + expect(mocks.mutation).toHaveBeenLastCalledWith( + "upsertProduct", + expect.objectContaining({ regions }), + ); + } + }); + + it("rejects malformed sales-region states before calling Convex", async () => { + const app = buildApp(); + for (const regions of ["inherit", { mode: "all" }, ["US", 1]]) { + mocks.mutation.mockClear(); + const response = await app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + productId: "coins", + platform: "Android", + type: "Consumable", + title: "Coins", + regions, + }), + }); + + expect(response.status).toBe(400); + expect(mocks.mutation).not.toHaveBeenCalled(); + } + }); + it("strictly parses and forwards the bounded client-payload list opt-in", async () => { const app = buildApp(); mocks.query.mockResolvedValueOnce({ diff --git a/packages/kit/server/api/v1/products.ts b/packages/kit/server/api/v1/products.ts index d9e56f1b9..2a0afbf32 100644 --- a/packages/kit/server/api/v1/products.ts +++ b/packages/kit/server/api/v1/products.ts @@ -221,6 +221,12 @@ async function handleUpsertProduct(c: Context, apiKey: string) { reviewNote?: string; state?: ProductState; storeRef?: string; + localizations?: Array<{ + locale?: unknown; + title?: unknown; + description?: unknown; + }>; + regions?: unknown; }; if ( !isNonBlankString(payload.productId) || @@ -245,6 +251,39 @@ async function handleUpsertProduct(c: Context, apiKey: string) { if (typeof payload.title !== "string") { return invalidInput(c, "title must be a string"); } + // Shape-check only; the Convex mutation owns locale-format, length, + // and duplicate validation so the dashboard, REST, and MCP callers + // all get identical rules from one place. + if (payload.localizations !== undefined) { + if ( + !Array.isArray(payload.localizations) || + payload.localizations.some( + (entry) => + !isJsonObject(entry) || + typeof entry.locale !== "string" || + typeof entry.title !== "string" || + (entry.description !== undefined && + typeof entry.description !== "string"), + ) + ) { + return invalidInput( + c, + "localizations must be an array of { locale, title, description? } strings", + ); + } + } + if (payload.regions !== undefined) { + if ( + payload.regions !== "all" && + (!Array.isArray(payload.regions) || + payload.regions.some((code) => typeof code !== "string")) + ) { + return invalidInput( + c, + 'regions must be "all" or an array of two-letter region codes', + ); + } + } if (!payload.title.trim()) { return invalidInput(c, "productId, platform, type, title are required"); } @@ -298,6 +337,10 @@ async function handleUpsertProduct(c: Context, apiKey: string) { type: payload.type, title: payload.title, description: payload.description, + localizations: payload.localizations as + | Array<{ locale: string; title: string; description?: string }> + | undefined, + regions: payload.regions as "all" | string[] | undefined, priceAmountMicros: payload.priceAmountMicros, currency: payload.currency, billingPeriod: payload.billingPeriod, diff --git a/packages/kit/server/api/v1/replay-guard.test.ts b/packages/kit/server/api/v1/replay-guard.test.ts index 38e1fd103..c4faba60c 100644 --- a/packages/kit/server/api/v1/replay-guard.test.ts +++ b/packages/kit/server/api/v1/replay-guard.test.ts @@ -1,8 +1,10 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it, test } from "vitest"; import { hashPayload, + isStableRejection, markPayloadFailure, + replayGuardMiddleware, tryConsumeReplay, type ReplayBucket, } from "./replay-guard"; @@ -245,3 +247,145 @@ describe("markPayloadFailure + tryConsumeReplay cooldown", () => { expect(blocked.reason).toBe("repeated_failure"); }); }); + +// Issue #289: the negative cooldown defaults to 300s, which almost +// exactly spans Google's ~301s window for voiding an unacknowledged +// purchase. Arming it on a non-terminal rejection meant one blip made +// the purchase permanently unverifiable — and therefore un-acknowledgeable +// — before Google voided it. +describe("isStableRejection", () => { + it("arms the cooldown for settled store verdicts", () => { + for (const state of ["INAUTHENTIC", "CANCELED", "EXPIRED", "CONSUMED"]) { + expect(isStableRejection(state)).toBe(true); + } + }); + + 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. + for (const state of ["PENDING", "UNKNOWN", "FUTURE_STORE_STATE"]) { + expect(isStableRejection(state)).toBe(false); + } + }); + + it("arms ambiguous UNKNOWN only with explicit revoked-token provenance", () => { + expect(isStableRejection("UNKNOWN")).toBe(false); + expect(isStableRejection("UNKNOWN", true)).toBe(true); + }); + + it("is case-insensitive", () => { + expect(isStableRejection("pending")).toBe(false); + expect(isStableRejection("Unknown")).toBe(false); + expect(isStableRejection("inauthentic")).toBe(true); + }); +}); + +// The predicate above is pure; this exercises the wiring that actually +// fixes issue #289 — the middleware only arming the cooldown for a +// settled verdict. Without this, `isStableRejection` could be dropped +// from the `finally` block and every test would still pass. +describe("replayGuardMiddleware cooldown wiring", () => { + const capacity = 30; + + function runMiddleware(options: { + store: Map; + outcome?: { + isValid: boolean; + state: string; + stableRejection?: boolean; + }; + now: number; + }) { + const middleware = replayGuardMiddleware({ + capacity, + refillPerSecond: 1 / 60, + maxStoreSize: 1000, + failureCooldownMs: 300_000, + store: options.store, + now: () => options.now, + }); + const vars: Record = { apiKeyHash: "hash" }; + const body = { store: "google" as const, purchaseToken: "tok" }; + let status = 200; + let payload: unknown; + const ctx = { + var: vars, + get: (k: string) => vars[k], + set: (k: string, v: unknown) => { + vars[k] = v; + }, + req: { valid: () => body }, + header: () => undefined, + json: (b: unknown, s?: number) => { + payload = b; + status = s ?? 200; + return { status: s ?? 200 }; + }, + }; + const next = async () => { + if (options.outcome) vars.verifyOutcome = options.outcome; + }; + return middleware(ctx as never, next as never).then(() => ({ + status, + payload, + })); + } + + it("arms the cooldown for a settled rejection", async () => { + const store = new Map(); + await runMiddleware({ + store, + outcome: { isValid: false, state: "INAUTHENTIC" }, + now: 1_000, + }); + const second = await runMiddleware({ store, now: 2_000 }); + expect(second.status).toBe(429); + expect(second.payload).toMatchObject({ + errors: [{ code: "REPEATED_FAILURE" }], + }); + }); + + it("does not arm it for a state a retry can change", async () => { + for (const state of ["PENDING", "UNKNOWN", "FUTURE_STORE_STATE"]) { + const store = new Map(); + await runMiddleware({ + store, + outcome: { isValid: false, state }, + now: 1_000, + }); + // Still inside the 300s window that spans Google's ~301s void + // deadline — the retry has to get through. + const second = await runMiddleware({ store, now: 2_000 }); + expect(second.status).toBe(200); + } + }); + + it("arms UNKNOWN when the verifier reports a revoked token", async () => { + const store = new Map(); + await runMiddleware({ + store, + outcome: { + isValid: false, + state: "UNKNOWN", + stableRejection: true, + }, + now: 1_000, + }); + + const second = await runMiddleware({ store, now: 2_000 }); + expect(second.status).toBe(429); + expect(second.payload).toMatchObject({ + errors: [{ code: "REPEATED_FAILURE" }], + }); + }); + + it("does not arm it for a successful verification", async () => { + const store = new Map(); + await runMiddleware({ + store, + outcome: { isValid: true, state: "ENTITLED" }, + now: 1_000, + }); + expect((await runMiddleware({ store, now: 2_000 })).status).toBe(200); + }); +}); diff --git a/packages/kit/server/api/v1/replay-guard.ts b/packages/kit/server/api/v1/replay-guard.ts index e4554a67a..5a3b8c696 100644 --- a/packages/kit/server/api/v1/replay-guard.ts +++ b/packages/kit/server/api/v1/replay-guard.ts @@ -32,7 +32,7 @@ export interface ReplayBucket { tokens: number; lastRefillMs: number; // Set when the most recent verify call for this (key, payload) - // returned `isValid: false`. Subsequent + // 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 @@ -58,6 +58,35 @@ export interface ReplayGuardConfig { export type ReplayRejectReason = "burst" | "repeated_failure"; +// These states are settled store verdicts. Everything else remains +// 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. +const STABLE_REJECTION_STATES = new Set([ + "INAUTHENTIC", + "CANCELED", + "EXPIRED", + "CONSUMED", +]); + +/** + * Whether a rejected verification should arm the negative cooldown. + * + * The guard exists to stop someone replaying a receipt the store has + * definitively rejected (INAUTHENTIC, CANCELED, EXPIRED). Those verdicts + * don't change in seconds. Non-terminal ones do. + */ +export function isStableRejection( + state: string, + explicitStableRejection = false, +): boolean { + return ( + explicitStableRejection || STABLE_REJECTION_STATES.has(state.toUpperCase()) + ); +} + export interface ReplayConsumeResult { allowed: boolean; remaining: number; @@ -193,9 +222,9 @@ export function tryConsumeReplay( /** * Mark the (key, payload) bucket as having just observed a failed * verification. Called from the middleware's finally block when the - * handler explicitly set `verifyOutcome.isValid = false` — i.e. the - * upstream store (Apple / Google / Horizon / Amazon) returned a definitive "this - * receipt is invalid" verdict. Thrown errors from the handler (network + * handler supplied an explicit stable outcome — i.e. the upstream store + * (Apple / Google / Horizon / Amazon) returned a definitive "this receipt is + * invalid" verdict. Thrown errors from the handler (network * failures, configuration mistakes, project-not-found, etc.) do NOT * trigger the cooldown, since those aren't a verdict from the store * and a retry might legitimately succeed. @@ -258,7 +287,11 @@ const sharedStore = new Map(); type ReplayGuardVars = { apiKeyHash?: string; - verifyOutcome?: { isValid: boolean; state: string }; + verifyOutcome?: { + isValid: boolean; + state: string; + stableRejection?: boolean; + }; verifyCapacityRejected?: boolean; }; @@ -366,7 +399,11 @@ export function replayGuardMiddleware( // configuration / network errors aren't conflated with stable // receipt or product-match failures. const outcome = c.get("verifyOutcome"); - if (outcome && outcome.isValid === false) { + if ( + outcome && + outcome.isValid === false && + isStableRejection(outcome.state, outcome.stableRejection === true) + ) { markPayloadFailure(store, bucketKey, capacity, clock(), maxStoreSize); } } diff --git a/packages/kit/server/api/v1/routes.test.ts b/packages/kit/server/api/v1/routes.test.ts index a69663048..6ea376d3a 100644 --- a/packages/kit/server/api/v1/routes.test.ts +++ b/packages/kit/server/api/v1/routes.test.ts @@ -96,6 +96,74 @@ describe("apiRoutes", () => { expect(convexClientMock.query).not.toHaveBeenCalled(); }); + it("keeps fetched UNKNOWN outcomes retryable without exposing internal hints", async () => { + convexClientMock.action.mockResolvedValue({ + isValid: false, + state: "UNKNOWN", + productId: "future.product", + }); + const request = () => + apiRoutes.request("/purchase/verify", { + method: "POST", + headers: { + Authorization: "Bearer route-test-future-unknown", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "google", + purchaseToken: "future-unknown-token".repeat(3), + }), + }); + + const first = await request(); + const second = await request(); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(await first.json()).toEqual({ + store: "google", + isValid: false, + state: "UNKNOWN", + productId: "future.product", + }); + expect(convexClientMock.action).toHaveBeenCalledTimes(2); + }); + + it("cooldowns an explicitly revoked UNKNOWN without exposing provenance", async () => { + convexClientMock.action.mockResolvedValueOnce({ + isValid: false, + state: "UNKNOWN", + stableRejection: true, + }); + const request = () => + apiRoutes.request("/purchase/verify", { + method: "POST", + headers: { + Authorization: "Bearer route-test-revoked-unknown", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "google", + purchaseToken: "revoked-unknown-token".repeat(3), + }), + }); + + const first = await request(); + const second = await request(); + + expect(first.status).toBe(200); + expect(await first.json()).toEqual({ + store: "google", + isValid: false, + state: "UNKNOWN", + }); + expect(second.status).toBe(429); + expect(await second.json()).toMatchObject({ + errors: [{ code: "REPEATED_FAILURE" }], + }); + expect(convexClientMock.action).toHaveBeenCalledTimes(1); + }); + it("enriches a valid Google receipt only when client payload is requested", async () => { convexClientMock.action.mockResolvedValueOnce({ isValid: true, diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index 9e3c3e14c..34542fce2 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -40,7 +40,11 @@ type V1AppVariables = { // in-flight limit → replay guard verifyCapacityRejected?: boolean; // verify-purchase handler → request-logger - verifyOutcome: { isValid: boolean; state: string }; + verifyOutcome: { + isValid: boolean; + state: string; + stableRejection?: boolean; + }; }; const app = new Hono<{ Variables: V1AppVariables }>(); @@ -394,9 +398,19 @@ const verifyPurchaseHandler = async ( }; const sendReceiptResponse = async ( store: VerifyPurchaseJson["store"], - receipt: { isValid: boolean; state: string; productId?: string }, + receipt: { + isValid: boolean; + state: string; + productId?: string; + stableRejection?: boolean; + }, ) => { - const outcome = { isValid: receipt.isValid, state: receipt.state }; + const { stableRejection, ...publicReceipt } = receipt; + const outcome = { + isValid: receipt.isValid, + state: receipt.state, + ...(stableRejection === true ? { stableRejection: true } : {}), + }; setOutcome(outcome); let clientPayload: ProductClientPayload | null = null; @@ -431,7 +445,7 @@ const verifyPurchaseHandler = async ( return c.json({ store, - ...receipt, + ...publicReceipt, ...(clientPayload ? { clientPayload } : {}), }); }; diff --git a/packages/kit/src/pages/auth/organization/project/product-localizations.test.ts b/packages/kit/src/pages/auth/organization/project/product-localizations.test.ts new file mode 100644 index 000000000..25d872902 --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/product-localizations.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; + +import { resolveProductListingDraft } from "./product-localizations"; + +const row = ( + locale: string, + title: string, + description = "", +): { locale: string; title: string; description: string } => ({ + locale, + title, + description, +}); + +const resolve = ( + rows: Array<{ locale: string; title: string; description: string }>, + state: { + editingExisting?: boolean; + isLoadedRow?: boolean; + regionsInput?: string; + regionMode?: "inherit" | "all" | "list"; + supportsSalesRegions?: boolean; + } = {}, +) => + resolveProductListingDraft({ + rows, + editingExisting: state.editingExisting ?? false, + isLoadedRow: state.isLoadedRow ?? false, + regionsInput: state.regionsInput, + regionMode: state.regionMode, + supportsSalesRegions: state.supportsSalesRegions ?? false, + }); + +describe("resolveProductListingDraft", () => { + it("trims and keeps a blank description off the payload", () => { + expect(resolve([row(" ko-KR ", " 코인 ", " ")])).toEqual({ + ok: true, + localizations: [ + { locale: "ko-KR", title: "코인", description: undefined }, + ], + }); + }); + + it("ignores an entirely blank row instead of rejecting it", () => { + // The form always renders one empty row to type into. Treating that + // as an error would make a product with no translations unsavable. + expect(resolve([row("", "")])).toEqual({ + ok: true, + localizations: undefined, + regions: undefined, + }); + }); + + it("refuses a half-typed row rather than dropping it", () => { + // Silently filtering it out would clear the form and lose the text + // the operator typed, with nothing shown to explain why. + expect(resolve([row("ko-KR", "")])).toEqual({ + ok: false, + error: "Every language needs both a locale and a title", + }); + expect(resolve([row("", "코인")])).toEqual({ + ok: false, + error: "Every language needs both a locale and a title", + }); + }); + + it("blocks a replace-by-accident on an existing product", () => { + // The push REPLACES the stored list, so typing one language into a + // product that already has five would delete the other four. + expect( + resolve([row("ja-JP", "コイン")], { + editingExisting: true, + isLoadedRow: false, + }), + ).toEqual({ + ok: false, + error: + "Load this product's stored languages first — saving now would replace them", + }); + }); + + it("leaves stored languages alone when the operator never opened them", () => { + // Editing only the price of an existing product must not touch its + // translations: `undefined` is the mutation's "leave unchanged". + expect( + resolve([row("", "")], { editingExisting: true, isLoadedRow: false }), + ).toEqual({ ok: true, localizations: undefined }); + }); + + it("sends an empty list once loaded, so a delete-all actually clears", () => { + // A loaded row shows every stored locale. Emptying it is deliberate, + // and `undefined` there would be read as "leave unchanged" — the + // operator would delete the rows, save, and see them come back. + expect( + resolve([row("", "")], { editingExisting: true, isLoadedRow: true }), + ).toEqual({ ok: true, localizations: [] }); + }); + + it("saves the edited set of a loaded row", () => { + expect( + resolve([row("ko-KR", "코인", "100개"), row("ja-JP", "コイン")], { + editingExisting: true, + isLoadedRow: true, + }), + ).toEqual({ + ok: true, + localizations: [ + { locale: "ko-KR", title: "코인", description: "100개" }, + { locale: "ja-JP", title: "コイン", description: undefined }, + ], + }); + }); + + it("accepts languages typed on a brand-new product", () => { + expect(resolve([row("ko-KR", "코인")])).toEqual({ + ok: true, + localizations: [ + { locale: "ko-KR", title: "코인", description: undefined }, + ], + }); + }); + + it("maps the three sales-region modes to the product contract", () => { + const base = { supportsSalesRegions: true } as const; + + expect(resolve([], { ...base, regionMode: "inherit" })).toMatchObject({ + ok: true, + regions: undefined, + }); + expect(resolve([], { ...base, regionMode: "all" })).toMatchObject({ + ok: true, + regions: "all", + }); + expect( + resolve([], { + ...base, + regionMode: "list", + regionsInput: " kr, US ,kr ", + }), + ).toMatchObject({ ok: true, regions: ["kr", "US", "kr"] }); + }); + + it("requires at least one code in list mode", () => { + expect( + resolve([], { + supportsSalesRegions: true, + regionMode: "list", + regionsInput: " , ", + }), + ).toEqual({ + ok: false, + error: + "List at least one region, or choose a different sales-region option", + }); + }); + + it("blocks footprint replacement until the stored row is loaded", () => { + expect( + resolve([], { + supportsSalesRegions: true, + regionMode: "all", + editingExisting: true, + isLoadedRow: false, + }), + ).toEqual({ + ok: false, + error: + "Load this product's stored languages and regions first — saving now would replace them", + }); + }); + + it("ignores region mode for products without sales-region support", () => { + expect( + resolve([], { + supportsSalesRegions: false, + regionMode: "all", + editingExisting: true, + isLoadedRow: false, + }), + ).toEqual({ ok: true, localizations: undefined, regions: undefined }); + }); + + it("uses an empty list to clear a loaded footprint back to inherit", () => { + expect( + resolve([], { + supportsSalesRegions: true, + regionMode: "inherit", + editingExisting: true, + isLoadedRow: true, + }), + ).toMatchObject({ ok: true, regions: [] }); + }); +}); diff --git a/packages/kit/src/pages/auth/organization/project/product-localizations.ts b/packages/kit/src/pages/auth/organization/project/product-localizations.ts new file mode 100644 index 000000000..5fa8c8202 --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/product-localizations.ts @@ -0,0 +1,142 @@ +/** + * Turns the product form's language rows and sales-region input into the + * `localizations` / `regions` arguments `upsertProduct` expects. + * + * Extracted from the form because the three-way distinction below is the + * part that has been wrong before — sending `[]` where `undefined` was + * meant deletes an operator's stored translations, and sending a + * partially-typed list replaces the rest of them. Both fields REPLACE on + * write, so they share one load-before-edit guard. + */ + +export interface ProductLocalizationRow { + locale: string; + title: string; + description: string; +} + +export interface ResolvedProductListingDraft { + /** Ready to send: a list, or `undefined` to leave the stored value alone. */ + localizations?: Array<{ + locale: string; + title: string; + description?: string; + }>; + /** + * `"all"` to sell everywhere, a list to restrict, `undefined` to leave + * the stored footprint alone (and, on a new product, to let the store + * default apply). + */ + regions?: "all" | string[]; +} + +export type ProductListingDraftResolution = + | ({ ok: true } & ResolvedProductListingDraft) + | { ok: false; error: string }; + +export function resolveProductListingDraft(args: { + rows: ProductLocalizationRow[]; + /** Raw comma-separated field text, used only when mode is "list". */ + regionsInput?: string; + /** + * "inherit" leaves the stored footprint alone, "all" sells everywhere, + * "list" restricts to `regionsInput`. Defaults to "inherit" so a form + * that never showed the control cannot widen a product's markets. + */ + regionMode?: "inherit" | "all" | "list"; + /** + * False for iOS and for subscriptions, where Play/ASC give kit no way + * to set a per-product footprint. The field is hidden there, and any + * text left in it is ignored rather than sent and silently dropped. + */ + supportsSalesRegions?: boolean; + /** True while editing a product that already exists in kit. */ + editingExisting: boolean; + /** True once this row's stored values have been loaded into the form. */ + isLoadedRow: boolean; +}): ProductListingDraftResolution { + // A row the operator started but didn't finish is a mistake, not an + // instruction to drop it: silently filtering it out would clear the + // form and lose the text they typed with no error shown. + const touched = args.rows.filter((row) => + [row.locale, row.title, row.description].some((value) => value.trim()), + ); + if (touched.some((row) => !row.locale.trim() || !row.title.trim())) { + return { + ok: false, + error: "Every language needs both a locale and a title", + }; + } + + const filled = touched.map((row) => ({ + locale: row.locale.trim(), + title: row.title.trim(), + description: row.description.trim() || undefined, + })); + + const regionMode = args.regionMode ?? "inherit"; + const parsedRegions = + args.supportsSalesRegions && regionMode === "list" + ? (args.regionsInput ?? "") + .split(",") + .map((code) => code.trim()) + .filter(Boolean) + : []; + if ( + args.supportsSalesRegions && + regionMode === "list" && + !parsedRegions.length + ) { + // "Only these regions" with nothing typed would otherwise fall + // through as "inherit" and quietly ignore the choice. + return { + ok: false, + error: + "List at least one region, or choose a different sales-region option", + }; + } + + // Sending a language or region list for a product that already has one + // REPLACES it, so an operator who typed a single row without loading + // would silently drop the rest. Make them load first. + const replacesRegions = + args.supportsSalesRegions && + (parsedRegions.length > 0 || regionMode !== "inherit"); + if ( + args.editingExisting && + !args.isLoadedRow && + (filled.length > 0 || replacesRegions) + ) { + return { + ok: false, + error: replacesRegions + ? "Load this product's stored languages and regions first — saving now would replace them" + : "Load this product's stored languages first — saving now would replace them", + }; + } + + // A loaded row prefills every stored locale and region, so an empty + // list there is a deliberate delete-all and must be sent as `[]` for + // the mutation to clear it. `undefined` is for the untouched cases — a + // brand-new row, or an existing row nobody opened — where an empty + // list only means "not specified". + const sendEmptyAsDeleteAll = args.isLoadedRow; + return { + ok: true, + localizations: + sendEmptyAsDeleteAll || filled.length > 0 ? filled : undefined, + // Region modes map to the stored states directly: "all" is a value, + // "inherit" is the absence of one. A loaded row that switched back + // to "inherit" sends `[]`, which the mutation stores as null — the + // same delete-all reasoning as the language list. + regions: !args.supportsSalesRegions + ? undefined + : regionMode === "all" + ? "all" + : regionMode === "list" + ? parsedRegions + : sendEmptyAsDeleteAll + ? [] + : undefined, + }; +} diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index 407af1e8b..f941dca24 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -30,6 +30,7 @@ import { shouldShowProductSyncResult, } from "./product-sync-result"; import { ProductSyncFailureList } from "./product-sync-failure-list"; +import { resolveProductListingDraft } from "./product-localizations"; type DashboardProject = Omit< Doc<"projects">, @@ -117,6 +118,79 @@ export default function ProjectProducts() { subscriptionGroupName: "", reviewNote: "", }); + // Extra store-listing languages. `title` / `description` keep the row's + // base locale (en-US for new rows; a pulled store default may differ). + const [localizations, setLocalizations] = useState< + Array<{ locale: string; title: string; description: string }> + >([]); + // Comma-separated ISO region codes. Blank means "every region the + // store prices", which is the default that fixes US-only products. + const [regionsInput, setRegionsInput] = useState(""); + // Three states, matching what the product actually stores. "inherit" + // is the default because expanding an existing product's markets is + // something the operator asks for, not something a price edit does. + const [regionMode, setRegionMode] = useState<"inherit" | "all" | "list">( + "inherit", + ); + // Only the Android one-time push applies a region footprint: ASC + // prices per territory through a resource this workflow doesn't touch, + // and Play fixes a base plan's regional configs at create. Hiding the + // field is how the operator learns that, instead of tripping the + // mutation's guard on save. + const supportsSalesRegions = + draft.platform === "Android" && draft.type !== "Subscription"; + // Typing an existing productId means "edit this row", so show the + // locales it already has. Without this the field is write-only: the + // editor would look empty and the operator would have no way to see, + // correct, or intentionally keep what is stored. + const editingExisting = useMemo( + () => + (products ?? []).find( + (product) => + product.productId === draft.productId.trim() && + product.platform === draft.platform, + ), + [products, draft.productId, draft.platform], + ); + const baseListingLocale = editingExisting?.baseLocale ?? "en-US"; + // Which stored row the editors were explicitly loaded from, or null. + // + // Loading is a button, not an effect. Inferring it from "the typed id + // happens to match a row" was rewritten three times and lost data + // three different ways — a half-typed id overwrote work in progress, a + // stale flag latched loading off forever, and a single blank language + // row made an apparently-empty editor delete a product's stored + // listings on save. An explicit action has none of those states. + const [loadedKey, setLoadedKey] = useState(null); + const editingKey = editingExisting + ? `${editingExisting.platform}\u0000${editingExisting.productId}` + : null; + // Only a row loaded from THIS product may send an empty array, which + // is how a delete-all reaches the mutation. Otherwise an empty editor + // means "not specified" and the stored value is preserved. + const isLoadedRow = loadedKey !== null && loadedKey === editingKey; + const loadStoredMetadata = () => { + if (!editingExisting || !editingKey) return; + setLoadedKey(editingKey); + const storedRegions = editingExisting.regions; + setRegionMode( + storedRegions === "all" + ? "all" + : storedRegions?.length + ? "list" + : "inherit", + ); + setRegionsInput( + Array.isArray(storedRegions) ? storedRegions.join(", ") : "", + ); + setLocalizations( + (editingExisting.localizations ?? []).map((entry) => ({ + locale: entry.locale, + title: entry.title, + description: entry.description ?? "", + })), + ); + }; const grouped = useMemo(() => { if (!products) return { ios: [], android: [] }; @@ -232,6 +306,18 @@ export default function ProjectProducts() { return ; } + // Convex wraps a thrown ConvexError so `error.message` carries the + // framework's own prefix; the operator needs the guidance we wrote. + const convexErrorMessage = (error: unknown): string | undefined => { + const data = (error as { data?: unknown } | null)?.data; + if (data && typeof data === "object" && "message" in data) { + const message = (data as { message?: unknown }).message; + if (typeof message === "string") return message; + } + if (typeof data === "string") return data; + return error instanceof Error ? error.message : undefined; + }; + const onAdd = async () => { if (!draft.productId || !draft.title) return; // Empty strings → undefined so the mutation's `?? existing.X` @@ -251,20 +337,42 @@ export default function ProjectProducts() { : undefined; const billingPeriod = draft.type === "Subscription" ? draft.billingPeriod : undefined; - await upsert({ - projectId: project._id, - productId: draft.productId, - platform: draft.platform, - type: draft.type, - title: draft.title, - description, - priceAmountMicros, - currency: priceAmountMicros !== undefined ? "USD" : undefined, - billingPeriod, - subscriptionGroupName, - reviewNote, - state: "Draft", + const resolvedDraft = resolveProductListingDraft({ + rows: localizations, + regionsInput, + regionMode, + supportsSalesRegions, + editingExisting: Boolean(editingExisting), + isLoadedRow, }); + if (!resolvedDraft.ok) { + toast.error(resolvedDraft.error); + return; + } + try { + await upsert({ + projectId: project._id, + productId: draft.productId, + platform: draft.platform, + type: draft.type, + title: draft.title, + description, + priceAmountMicros, + currency: priceAmountMicros !== undefined ? "USD" : undefined, + billingPeriod, + subscriptionGroupName, + reviewNote, + localizations: resolvedDraft.localizations, + regions: resolvedDraft.regions, + state: "Draft", + }); + } catch (error) { + // The mutation rejects malformed locales, duplicates, and + // over-long store text. Without this the promise rejected into + // `void onAdd()` and the operator saw nothing happen. + toast.error(convexErrorMessage(error) ?? "Could not save product"); + return; + } setDraft({ ...draft, productId: "", @@ -274,6 +382,9 @@ export default function ProjectProducts() { subscriptionGroupName: "", reviewNote: "", }); + setLocalizations([]); + setRegionsInput(""); + setLoadedKey(null); }; const onSync = async ( @@ -519,6 +630,131 @@ export default function ProjectProducts() { /> + {supportsSalesRegions && ( + + + {regionMode === "list" && ( + setRegionsInput(e.target.value)} + placeholder="US, KR, JP" + className="mt-2 w-full px-2 py-1.5 rounded border border-border bg-background" + /> + )} +

+ {regionMode === "inherit" + ? "A product the store already has keeps exactly the regions it has today — a price change will not widen where it sells. A new product is priced in every region the store supports, converted from the price above." + : regionMode === "all" + ? "Prices the product in every region the store supports, and follows the store into markets it adds later." + : "Two-letter country codes, comma separated. Restricts the product to those markets and keeps it out of regions the store adds later. Stores refuse to drop a region once it has been added, so the others are withdrawn rather than removed."} +

+
+ )} +
+
+ + Other languages (optional) + + {editingExisting && !isLoadedRow && ( + + )} + +
+ {localizations.length === 0 ? ( +

+ The title and description above publish as {baseListingLocale}. + Add a language to show a translated name in that store locale — + pricing is already converted per region automatically. + {editingExisting + ? " This product already exists: leaving this empty keeps its stored languages. Load them to edit or remove them." + : ""} +

+ ) : ( + localizations.map((entry, index) => ( +
+ + setLocalizations( + localizations.map((row, i) => + i === index ? { ...row, locale: e.target.value } : row, + ), + ) + } + placeholder="ko-KR" + className="w-full px-2 py-1.5 rounded border border-border bg-background text-sm" + /> + + setLocalizations( + localizations.map((row, i) => + i === index ? { ...row, title: e.target.value } : row, + ), + ) + } + placeholder="Title in this language" + className="w-full px-2 py-1.5 rounded border border-border bg-background text-sm" + /> + + setLocalizations( + localizations.map((row, i) => + i === index + ? { ...row, description: e.target.value } + : row, + ), + ) + } + placeholder="Description in this language" + className="w-full px-2 py-1.5 rounded border border-border bg-background text-sm" + /> + +
+ )) + )} +
{draft.platform === "IOS" && (
- On iOS, Sync pushes the row to App Store Connect, creates an en-US + On iOS, Sync pushes the row to App Store Connect, creates the base localization, and sets the USA price tier. App Store Connect may still show "Missing Metadata" until review metadata and screenshots are added and the product is attached to an app version diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 059364d11..6b7f1b575 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,7 +1,7 @@ { "name": "@hyodotdev/openiap-mcp-server", "version": "0.1.0", - "description": "Model Context Protocol server for IAPKit — wires Codex, Claude Code, and other MCP clients into IAPKit's product, subscription, revenue, and webhook surfaces.", + "description": "Model Context Protocol server for IAPKit \u2014 wires Codex, Claude Code, and other MCP clients into IAPKit's product, subscription, revenue, and webhook surfaces.", "type": "module", "private": true, "bin": { @@ -18,7 +18,7 @@ "main": "src/index.ts", "scripts": { "build": "tsc -p .", - "lint": "tsc -p . --noEmit", + "lint": "tsc -p . --noEmit && prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", "test": "vitest run --passWithNoTests", "start": "bun run src/index.ts", "start:http": "bun run src/http.ts" @@ -30,6 +30,7 @@ "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.2", - "vitest": "^4.1.5" + "vitest": "^4.1.5", + "prettier": "^3.6.2" } } diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index 63fc9cb55..eefb6e070 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -21,6 +21,11 @@ import { IAPKIT_MCP_SERVER_NAME, IAPKIT_MCP_SERVER_VERSION, } from "./mcp.js"; +import { + buildSessionId, + currentMachineId, + routeUnknownSession, +} from "./session-routing.js"; const DEFAULT_MCP_PATH = "/mcp"; const DEFAULT_PORT = 3939; @@ -48,6 +53,13 @@ export interface RemoteMcpHttpServerOptions { allowedOrigins?: string[]; /** Logger for lifecycle and request failures. Defaults to console. */ logger?: Pick; + /** + * Identity of this process for session affinity. Defaults to + * FLY_MACHINE_ID; session ids are prefixed with it so a follow-up + * request landing on a sibling machine can be replayed to the owner + * (GitHub issue #287). Undefined disables replay routing. + */ + machineId?: string; } /** Runtime handle for an IAPKit remote MCP HTTP server. */ @@ -72,6 +84,7 @@ export function createRemoteMcpHttpServer( const allowedOrigins = options.allowedOrigins ?? parseAllowedOrigins(process.env.IAPKIT_MCP_ALLOWED_ORIGINS); + const machineId = options.machineId ?? currentMachineId(); const transports = new Map(); const server = createServer(async (req, res) => { @@ -134,12 +147,13 @@ export function createRemoteMcpHttpServer( res, transports, logger, + machineId, ); return; } if (req.method === "GET" || req.method === "DELETE") { - await handleExistingMcpSession(req, res, transports); + await handleExistingMcpSession(req, res, transports, machineId); return; } @@ -223,6 +237,7 @@ async function handleMcpPost( res: ServerResponse, transports: Map, logger: Pick, + machineId: string | undefined, ): Promise { const sessionId = headerString(req.headers["mcp-session-id"]); const body = await readJsonBody(req); @@ -233,7 +248,12 @@ async function handleMcpPost( return; } - if (sessionId || !isInitializeRequest(body)) { + if (sessionId) { + writeUnknownSessionResponse(req, res, sessionId, machineId); + return; + } + + if (!isInitializeRequest(body)) { writeJsonRpcError( res, 400, @@ -245,7 +265,7 @@ async function handleMcpPost( let transport!: StreamableHTTPServerTransport; transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), + sessionIdGenerator: () => buildSessionId(machineId, randomUUID()), onsessioninitialized: (initializedSessionId) => { transports.set(initializedSessionId, transport); logger.info(`IAPKit MCP session initialized: ${initializedSessionId}`); @@ -269,11 +289,16 @@ async function handleExistingMcpSession( req: IncomingMessage, res: ServerResponse, transports: Map, + machineId: string | undefined, ): Promise { const sessionId = headerString(req.headers["mcp-session-id"]); const transport = sessionId ? transports.get(sessionId) : undefined; if (!transport) { + if (sessionId) { + writeUnknownSessionResponse(req, res, sessionId, machineId); + return; + } writeJsonRpcError(res, 400, -32000, "Invalid or missing mcp-session-id"); return; } @@ -281,6 +306,50 @@ async function handleExistingMcpSession( await transport.handleRequest(req as AuthenticatedRequest, res); } +/** + * Answers a request whose session id isn't in this process's transport + * map: replay it to the machine that minted the id when possible, + * otherwise 404 so a spec-compliant client transparently re-initializes. + * (The previous 400 "initialize first" reply broke that recovery path — + * GitHub issue #287.) + */ +function writeUnknownSessionResponse( + req: IncomingMessage, + res: ServerResponse, + sessionId: string, + machineId: string | undefined, +): void { + const routing = routeUnknownSession({ + sessionId, + machineId, + alreadyReplayed: req.headers["fly-replay-src"] !== undefined, + }); + + if (routing.action === "replay") { + // Fly's proxy intercepts any response carrying `fly-replay` and + // re-sends the original request to the named machine; the client + // never sees this interim response. `prefer_instance` rather than + // `instance` so a destroyed or restarting owner degrades to "route + // anywhere" — that replay carries `fly-replay-src`, so wherever it + // lands answers 404 and the client re-initializes. A bare + // `instance=` would instead fail at the proxy after its timeout, + // and the 404 this fix depends on would never be produced. + res + .writeHead(204, { + "fly-replay": `prefer_instance=${routing.targetMachineId};timeout=5s`, + }) + .end(); + return; + } + + writeJsonRpcError( + res, + 404, + -32001, + "Session not found — initialize a new MCP session.", + ); +} + function attachAuthInfo(req: AuthenticatedRequest): void { const bearerToken = parseBearerToken(headerString(req.headers.authorization)); if (!bearerToken) return; diff --git a/packages/mcp-server/src/kit-client.ts b/packages/mcp-server/src/kit-client.ts index 8bee1ca99..6f85dfd8f 100644 --- a/packages/mcp-server/src/kit-client.ts +++ b/packages/mcp-server/src/kit-client.ts @@ -173,6 +173,12 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { type: "Subscription" | "NonConsumable" | "Consumable"; title: string; description?: string; + localizations?: Array<{ + locale: string; + title: string; + description?: string; + }>; + regions?: "all" | string[]; priceAmountMicros?: number; currency?: string; billingPeriod?: "P1W" | "P1M" | "P2M" | "P3M" | "P6M" | "P1Y"; diff --git a/packages/mcp-server/src/mcp.ts b/packages/mcp-server/src/mcp.ts index 0f8dd40c9..bf3ff5f5d 100644 --- a/packages/mcp-server/src/mcp.ts +++ b/packages/mcp-server/src/mcp.ts @@ -355,6 +355,26 @@ function registerIapKitTools(server: McpServer) { type: z.enum(["Subscription", "NonConsumable", "Consumable"]), title: TITLE_PARAM, description: z.string().optional(), + localizations: z + .array( + z.object({ + locale: z + .string() + .describe('BCP-47 code, e.g. "ko-KR" or "ja-JP".'), + title: z.string(), + description: z.string().optional(), + }), + ) + .optional() + .describe( + "Store-listing text in other languages. `title` / `description` are the base listing (en-US for a new product; a product pulled from a store preserves that store's base locale). These add locales on top. Do not repeat the product's base locale. Regional pricing is converted automatically and is not configured here.", + ), + regions: z + .union([z.literal("all"), z.array(z.string())]) + .optional() + .describe( + 'Android one-time products only — rejected for iOS and for subscriptions. A list of two-letter ISO 3166-1 codes, e.g. ["US","KR","JP"], restricts the product to those markets and keeps it out of regions Play adds later. "all" explicitly expands to every region Play prices and follows Play into new markets. On create, omission uses the safe default: every priced region. On update, omission preserves the stored choice. Send [] to clear a stored choice back to inherit; an existing Play product then keeps its current live footprint, while a product Play has never seen is created everywhere.', + ), priceAmountMicros: PRICE_AMOUNT_MICROS_PARAM.optional(), currency: z.string().optional(), billingPeriod: z @@ -392,6 +412,8 @@ function registerIapKitTools(server: McpServer) { type: args.type, title: args.title, description: args.description, + localizations: args.localizations, + regions: args.regions, priceAmountMicros: args.priceAmountMicros, currency: args.currency, billingPeriod: args.billingPeriod, diff --git a/packages/mcp-server/src/session-routing.ts b/packages/mcp-server/src/session-routing.ts new file mode 100644 index 000000000..2309577f9 --- /dev/null +++ b/packages/mcp-server/src/session-routing.ts @@ -0,0 +1,73 @@ +// MCP session ids are held in per-process memory (the transport object +// itself is stateful — an SSE stream can't be serialized into a shared +// store), so a session created on one Fly machine is invisible to its +// siblings. Fix (GitHub issue #287): embed the creating machine's id in +// the session id, and when a request lands on the wrong machine, answer +// with a `fly-replay` header so Fly's proxy re-routes the original +// request to the owner. Off Fly (no FLY_MACHINE_ID) session ids stay +// plain UUIDs and routing always resolves to `not-found`. + +/** + * Fly machine ids are lowercase hex today, but only shape-check them: + * the prefix is attacker-controlled (it arrives inside the client's + * `mcp-session-id` header), so the pattern also guards the value we + * echo back inside the `fly-replay` response header. + */ +const MACHINE_ID_PATTERN = /^[A-Za-z0-9]{1,32}$/; + +const SESSION_MACHINE_SEPARATOR = "."; + +/** Reads the Fly machine identity, or undefined when not running on Fly. */ +export function currentMachineId( + env: Record = process.env, +): string | undefined { + const raw = env.FLY_MACHINE_ID; + return raw && MACHINE_ID_PATTERN.test(raw) ? raw : undefined; +} + +/** Builds a session id that carries the creating machine's identity. */ +export function buildSessionId( + machineId: string | undefined, + uuid: string, +): string { + return machineId ? `${machineId}${SESSION_MACHINE_SEPARATOR}${uuid}` : uuid; +} + +/** Routing decision for a session id this process doesn't recognize. */ +export type UnknownSessionRouting = + | { action: "replay"; targetMachineId: string } + | { action: "not-found" }; + +/** + * Decides what to do with a session id that isn't in the local + * transport map. + * + * @param options.sessionId Session id from the `mcp-session-id` header. + * @param options.machineId This process's machine id (undefined off Fly). + * @param options.alreadyReplayed True when the request carries + * `fly-replay-src`, i.e. it was already replayed once — never replay + * again or two stale machines could bounce a request forever. + * @returns `replay` toward the owning machine, or `not-found` (the + * caller answers 404 so the client re-initializes per the MCP spec). + */ +export function routeUnknownSession(options: { + sessionId: string; + machineId: string | undefined; + alreadyReplayed: boolean; +}): UnknownSessionRouting { + if (!options.machineId || options.alreadyReplayed) { + return { action: "not-found" }; + } + + const separatorIndex = options.sessionId.indexOf(SESSION_MACHINE_SEPARATOR); + if (separatorIndex <= 0) return { action: "not-found" }; + + const prefix = options.sessionId.slice(0, separatorIndex); + if (!MACHINE_ID_PATTERN.test(prefix) || prefix === options.machineId) { + // Malformed prefix, or the session was minted by this very machine + // (map lost to a restart/deploy) — replaying to ourselves would loop. + return { action: "not-found" }; + } + + return { action: "replay", targetMachineId: prefix }; +} diff --git a/packages/mcp-server/src/web.ts b/packages/mcp-server/src/web.ts index dc3a73fcb..9da6442df 100644 --- a/packages/mcp-server/src/web.ts +++ b/packages/mcp-server/src/web.ts @@ -9,6 +9,11 @@ import { isPublishableApiKey, } from "./auth.js"; import { createIapKitMcpServer } from "./mcp.js"; +import { + buildSessionId, + currentMachineId, + routeUnknownSession, +} from "./session-routing.js"; const MAX_MCP_BODY_BYTES = 1024 * 1024; const MCP_BODY_TOO_LARGE_ERROR = "MCP request body is too large"; @@ -24,6 +29,13 @@ const DEFAULT_ALLOWED_ORIGINS = [ export interface IapKitWebMcpHandlerOptions { allowedOrigins?: string[]; logger?: Pick; + /** + * Identity of this process for session affinity. Defaults to + * FLY_MACHINE_ID; session ids are prefixed with it so a follow-up + * request landing on a sibling machine can be replayed to the owner + * (GitHub issue #287). Undefined disables replay routing. + */ + machineId?: string; } export function createIapKitWebMcpHandler( @@ -33,6 +45,7 @@ export function createIapKitWebMcpHandler( const allowedOrigins = options.allowedOrigins ?? parseAllowedOrigins(process.env.IAPKIT_MCP_ALLOWED_ORIGINS); + const machineId = options.machineId ?? currentMachineId(); const transports = new Map< string, WebStandardStreamableHTTPServerTransport @@ -69,6 +82,7 @@ export function createIapKitWebMcpHandler( transports, logger, authInfo, + machineId, ); return withCors(request, response, allowedOrigins); } @@ -78,6 +92,7 @@ export function createIapKitWebMcpHandler( request, transports, authInfo, + machineId, ); return withCors(request, response, allowedOrigins); } @@ -117,6 +132,7 @@ async function handlePost( transports: Map, logger: Pick, authInfo: AuthInfo | undefined, + machineId: string | undefined, ): Promise { const sessionId = request.headers.get("mcp-session-id") ?? undefined; const body = await readJsonBody(request); @@ -129,7 +145,11 @@ async function handlePost( }); } - if (sessionId || !isInitializeRequest(body)) { + if (sessionId) { + return unknownSessionResponse(request, sessionId, machineId); + } + + if (!isInitializeRequest(body)) { return jsonRpcError( 400, -32000, @@ -139,7 +159,7 @@ async function handlePost( let transport!: WebStandardStreamableHTTPServerTransport; transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), + sessionIdGenerator: () => buildSessionId(machineId, randomUUID()), onsessioninitialized: (initializedSessionId) => { transports.set(initializedSessionId, transport); logger.info(`IAPKit MCP session initialized: ${initializedSessionId}`); @@ -167,17 +187,63 @@ async function handleExistingSession( request: Request, transports: Map, authInfo: AuthInfo | undefined, + machineId: string | undefined, ): Promise { const sessionId = request.headers.get("mcp-session-id") ?? undefined; const transport = sessionId ? transports.get(sessionId) : undefined; if (!transport) { + if (sessionId) { + return unknownSessionResponse(request, sessionId, machineId); + } return jsonRpcError(400, -32000, "Invalid or missing mcp-session-id"); } return transport.handleRequest(request, { authInfo }); } +/** + * Answers a request whose session id isn't in this process's transport + * map: replay it to the machine that minted the id when possible, + * otherwise 404 so a spec-compliant client transparently re-initializes. + * (The previous 400 "initialize first" reply broke that recovery path — + * GitHub issue #287.) + */ +function unknownSessionResponse( + request: Request, + sessionId: string, + machineId: string | undefined, +): Response { + const routing = routeUnknownSession({ + sessionId, + machineId, + alreadyReplayed: request.headers.has("fly-replay-src"), + }); + + if (routing.action === "replay") { + // Fly's proxy intercepts any response carrying `fly-replay` and + // re-sends the original request to the named machine; the client + // never sees this interim response. `prefer_instance` rather than + // `instance` so a destroyed or restarting owner degrades to "route + // anywhere" — that replay carries `fly-replay-src`, so wherever it + // lands answers 404 and the client re-initializes. A bare + // `instance=` would instead fail at the proxy after its timeout, + // and the 404 this fix depends on would never be produced. + return new Response(null, { + status: 204, + headers: { + "fly-replay": `prefer_instance=${routing.targetMachineId};timeout=5s`, + }, + }); + } + + return jsonRpcError( + 404, + -32001, + "Session not found — initialize a new MCP session.", + ); +} + function authInfoFromRequest(request: Request): AuthInfo | undefined { const token = parseBearerToken(request.headers.get("authorization")); if (!token) return undefined; diff --git a/packages/mcp-server/test/http.test.ts b/packages/mcp-server/test/http.test.ts index 049052602..fe8497f40 100644 --- a/packages/mcp-server/test/http.test.ts +++ b/packages/mcp-server/test/http.test.ts @@ -149,6 +149,26 @@ describe("remote MCP HTTP server", () => { destructiveHint: true, }, ); + const createProduct = toolsByName.get("iapkit_create_product") as + | { + inputSchema?: { + properties?: { + regions?: { + anyOf?: Array<{ const?: string; type?: string }>; + description?: string; + }; + }; + }; + } + | undefined; + const regionSchema = createProduct?.inputSchema?.properties?.regions; + expect(regionSchema?.anyOf).toEqual( + expect.arrayContaining([ + expect.objectContaining({ const: "all" }), + expect.objectContaining({ type: "array" }), + ]), + ); + expect(regionSchema?.description).toContain("Send [] to clear"); }); it("returns 403 before a publishable key can initialize the admin MCP surface", async () => { @@ -599,6 +619,87 @@ describe("remote MCP HTTP server", () => { expect(payload.info).toContain("/v1/webhooks/{publishableKey}"); }); + it("replays foreign-machine sessions and 404s unrecoverable ones (issue #287)", async () => { + const baseUrl = await startServer({ machineId: "self42" }); + + const init = await postMcp(baseUrl, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "vitest", version: "0.0.0" }, + }, + }); + expect(init.headers.get("mcp-session-id")).toMatch( + /^self42\.[0-9a-f-]{36}$/, + ); + await init.text(); + + const foreign = await postMcp( + baseUrl, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + "other77.7e33e2b1-9a45-4c8e-b1de-000000000000", + ); + expect(foreign.status).toBe(204); + expect(foreign.headers.get("fly-replay")).toBe( + "prefer_instance=other77;timeout=5s", + ); + + const replayed = await postMcp( + baseUrl, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + "other77.7e33e2b1-9a45-4c8e-b1de-000000000000", + { "fly-replay-src": "instance=other77;state=;t=1754400000000000" }, + ); + expect(replayed.status).toBe(404); + await expect(replayed.json()).resolves.toMatchObject({ + error: { + code: -32001, + message: "Session not found — initialize a new MCP session.", + }, + }); + + const lostOwn = await postMcp( + baseUrl, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + "self42.7e33e2b1-9a45-4c8e-b1de-000000000000", + ); + expect(lostOwn.status).toBe(404); + }); + + it("routes GET and DELETE for an unknown session like POST does", async () => { + const baseUrl = await startServer({ machineId: "self42" }); + const foreignSession = "other77.7e33e2b1-9a45-4c8e-b1de-000000000000"; + + for (const method of ["GET", "DELETE"] as const) { + const replayed = await fetch(`${baseUrl}/mcp`, { + method, + headers: { + accept: "application/json, text/event-stream", + "mcp-session-id": foreignSession, + }, + }); + expect(replayed.status).toBe(204); + expect(replayed.headers.get("fly-replay")).toBe( + "prefer_instance=other77;timeout=5s", + ); + + // Reverting this branch to the old "Invalid or missing + // mcp-session-id" 400 breaks the client's own recovery, since the + // MCP spec has it re-initialize on 404 only. + const lost = await fetch(`${baseUrl}/mcp`, { + method, + headers: { + accept: "application/json, text/event-stream", + "mcp-session-id": "self42.7e33e2b1-9a45-4c8e-b1de-000000000000", + }, + }); + expect(lost.status).toBe(404); + } + }); + it("returns client errors for invalid JSON and oversized payloads", async () => { const baseUrl = await startServer(); @@ -722,12 +823,13 @@ describe("remote MCP HTTP server", () => { }); }); -async function startServer(): Promise { +async function startServer(options?: { machineId?: string }): Promise { remote = createRemoteMcpHttpServer({ logger: { error: () => undefined, info: () => undefined, }, + ...options, }); await new Promise((resolve) => { diff --git a/packages/mcp-server/test/kit-client.test.ts b/packages/mcp-server/test/kit-client.test.ts index b6123d627..17864a4a2 100644 --- a/packages/mcp-server/test/kit-client.test.ts +++ b/packages/mcp-server/test/kit-client.test.ts @@ -76,6 +76,43 @@ describe("kitClient", () => { ); }); + it("forwards explicit and cleared sales-region states", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ id: "product-id", created: false }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const client = kitClient({ + apiKey: "custom-secret", + baseUrl: "https://kit.example", + }); + + for (const regions of ["all", []] as const) { + await client.upsertProduct({ + productId: "coins", + platform: "Android", + type: "Consumable", + title: "Coins", + regions, + }); + expect(fetchMock).toHaveBeenLastCalledWith( + "https://kit.example/v1/products", + expect.objectContaining({ + body: JSON.stringify({ + productId: "coins", + platform: "Android", + type: "Consumable", + title: "Coins", + regions, + }), + }), + ); + } + }); + it("parses JSON response content types case-insensitively", async () => { const fetchMock = vi.fn(async () => { return new Response(JSON.stringify({ products: [] }), { diff --git a/packages/mcp-server/test/session-routing.test.ts b/packages/mcp-server/test/session-routing.test.ts new file mode 100644 index 000000000..d862967fb --- /dev/null +++ b/packages/mcp-server/test/session-routing.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { + buildSessionId, + currentMachineId, + routeUnknownSession, +} from "../src/session-routing"; + +describe("currentMachineId", () => { + it("reads a well-formed FLY_MACHINE_ID", () => { + expect(currentMachineId({ FLY_MACHINE_ID: "17811953c25489" })).toBe( + "17811953c25489", + ); + }); + + it("returns undefined off Fly or for malformed ids", () => { + expect(currentMachineId({})).toBeUndefined(); + expect(currentMachineId({ FLY_MACHINE_ID: "" })).toBeUndefined(); + expect(currentMachineId({ FLY_MACHINE_ID: "bad.value" })).toBeUndefined(); + expect( + currentMachineId({ FLY_MACHINE_ID: "a".repeat(33) }), + ).toBeUndefined(); + }); +}); + +describe("buildSessionId", () => { + it("prefixes the machine id when present", () => { + expect(buildSessionId("m1", "uuid-1")).toBe("m1.uuid-1"); + }); + + it("returns the bare uuid off Fly", () => { + expect(buildSessionId(undefined, "uuid-1")).toBe("uuid-1"); + }); +}); + +describe("routeUnknownSession", () => { + it("replays to the machine that minted the session id", () => { + expect( + routeUnknownSession({ + sessionId: "other77.uuid-1", + machineId: "self42", + alreadyReplayed: false, + }), + ).toEqual({ action: "replay", targetMachineId: "other77" }); + }); + + it("never replays a request that was already replayed once", () => { + expect( + routeUnknownSession({ + sessionId: "other77.uuid-1", + machineId: "self42", + alreadyReplayed: true, + }), + ).toEqual({ action: "not-found" }); + }); + + it("never replays to itself (map lost to a restart)", () => { + expect( + routeUnknownSession({ + sessionId: "self42.uuid-1", + machineId: "self42", + alreadyReplayed: false, + }), + ).toEqual({ action: "not-found" }); + }); + + it("does not replay off Fly", () => { + expect( + routeUnknownSession({ + sessionId: "other77.uuid-1", + machineId: undefined, + alreadyReplayed: false, + }), + ).toEqual({ action: "not-found" }); + }); + + it("rejects unprefixed or malformed session ids", () => { + for (const sessionId of [ + "plain-uuid-without-prefix", + ".uuid-1", + "bad prefix.uuid-1", + `${"a".repeat(33)}.uuid-1`, + "inject=1\r\n.uuid-1", + ]) { + expect( + routeUnknownSession({ + sessionId, + machineId: "self42", + alreadyReplayed: false, + }), + ).toEqual({ action: "not-found" }); + } + }); +}); diff --git a/packages/mcp-server/test/web.test.ts b/packages/mcp-server/test/web.test.ts new file mode 100644 index 000000000..b2b0d37a2 --- /dev/null +++ b/packages/mcp-server/test/web.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; + +import { createIapKitWebMcpHandler } from "../src/web"; + +// Regression suite for GitHub issue #287: the hosted /mcp endpoint kept +// per-process session state, so a valid mcp-session-id landing on a +// sibling Fly machine was rejected with 400 "initialize first". The web +// handler must instead (a) mint machine-prefixed session ids, (b) replay +// foreign-machine sessions via `fly-replay`, and (c) answer 404 (not +// 400) for sessions it genuinely cannot serve so spec-compliant clients +// transparently re-initialize. + +const silentLogger = { error: () => undefined, info: () => undefined }; + +function createHandler(machineId?: string) { + return createIapKitWebMcpHandler({ logger: silentLogger, machineId }); +} + +function initializeRequest(sessionId?: string): Request { + return mcpRequest( + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "vitest", version: "0.0.0" }, + }, + }, + sessionId, + ); +} + +function toolsListRequest( + sessionId: string, + headers: Record = {}, +): Request { + return mcpRequest( + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + sessionId, + headers, + ); +} + +function mcpRequest( + body: unknown, + sessionId?: string, + headers: Record = {}, +): Request { + return new Request("http://localhost/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + ...headers, + }, + body: JSON.stringify(body), + }); +} + +describe("web MCP handler session routing", () => { + it("prefixes session ids with the machine id on Fly", async () => { + const handler = createHandler("self42"); + const response = await handler(initializeRequest()); + + expect(response.status).toBe(200); + const sessionId = response.headers.get("mcp-session-id"); + expect(sessionId).toMatch(/^self42\.[0-9a-f-]{36}$/); + }); + + it("keeps bare-UUID session ids off Fly", async () => { + const handler = createHandler(undefined); + const response = await handler(initializeRequest()); + + expect(response.status).toBe(200); + expect(response.headers.get("mcp-session-id")).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("serves follow-up requests on a session it owns", async () => { + const handler = createHandler("self42"); + const init = await handler(initializeRequest()); + const sessionId = init.headers.get("mcp-session-id") ?? ""; + await init.text(); + + const list = await handler(toolsListRequest(sessionId)); + expect(list.status).toBe(200); + }); + + it("replays a foreign machine's session via fly-replay", async () => { + const handler = createHandler("self42"); + const response = await handler( + toolsListRequest("other77.7e33e2b1-9a45-4c8e-b1de-000000000000"), + ); + + expect(response.status).toBe(204); + expect(response.headers.get("fly-replay")).toBe( + "prefer_instance=other77;timeout=5s", + ); + }); + + it("returns 404 instead of replaying twice", async () => { + const handler = createHandler("self42"); + const response = await handler( + toolsListRequest("other77.7e33e2b1-9a45-4c8e-b1de-000000000000", { + "fly-replay-src": "instance=other77;state=;t=1754400000000000", + }), + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: { + code: -32001, + message: "Session not found — initialize a new MCP session.", + }, + }); + }); + + it("returns 404 for its own session id after a restart wiped the map", async () => { + const handler = createHandler("self42"); + const response = await handler( + toolsListRequest("self42.7e33e2b1-9a45-4c8e-b1de-000000000000"), + ); + + expect(response.status).toBe(404); + }); + + it("returns 404 for unknown sessions off Fly", async () => { + const handler = createHandler(undefined); + const response = await handler( + toolsListRequest("7e33e2b1-9a45-4c8e-b1de-000000000000"), + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: { code: -32001 }, + }); + }); + + it("routes GET and DELETE for foreign sessions the same way", async () => { + const handler = createHandler("self42"); + + for (const method of ["GET", "DELETE"] as const) { + const response = await handler( + new Request("http://localhost/mcp", { + method, + headers: { + accept: "application/json, text/event-stream", + "mcp-session-id": "other77.7e33e2b1-9a45-4c8e-b1de-000000000000", + }, + }), + ); + expect(response.status).toBe(204); + expect(response.headers.get("fly-replay")).toBe( + "prefer_instance=other77;timeout=5s", + ); + } + }); + + it("still 400s a POST that has no session and is not initialize", async () => { + const handler = createHandler("self42"); + const response = await handler( + mcpRequest({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { + code: -32000, + message: + "Bad Request: initialize first, then send mcp-session-id on follow-up requests.", + }, + }); + }); + + it("still 400s GET/DELETE without any session id", async () => { + const handler = createHandler("self42"); + const response = await handler( + new Request("http://localhost/mcp", { + method: "DELETE", + headers: { accept: "application/json, text/event-stream" }, + }), + ); + + expect(response.status).toBe(400); + }); +});