From d201ad9602a1997636c109a309c754228646fbd2 Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 7 Jul 2026 02:43:41 +0900 Subject: [PATCH 1/3] fix(kit): sync store products end to end --- .codex/skills/iapkit-e2e-petgu/SKILL.md | 100 ++++ .../iapkit-e2e-petgu/agents/openai.yaml | 4 + packages/kit/convex/products/asc.ts | 392 +++++++++++++--- packages/kit/convex/products/jwt.test.ts | 16 + packages/kit/convex/products/jwt.ts | 14 +- packages/kit/convex/products/mutation.test.ts | 14 + packages/kit/convex/products/mutation.ts | 20 +- packages/kit/convex/products/play.ts | 428 +++++++++++++++--- packages/kit/convex/products/sync.test.ts | 31 +- packages/kit/convex/products/sync.ts | 122 ++++- packages/kit/convex/schema.ts | 4 +- .../auth/organization/project/products.tsx | 45 +- packages/mcp-server/src/mcp.ts | 4 +- 13 files changed, 1044 insertions(+), 150 deletions(-) create mode 100644 .codex/skills/iapkit-e2e-petgu/SKILL.md create mode 100644 .codex/skills/iapkit-e2e-petgu/agents/openai.yaml create mode 100644 packages/kit/convex/products/mutation.test.ts diff --git a/.codex/skills/iapkit-e2e-petgu/SKILL.md b/.codex/skills/iapkit-e2e-petgu/SKILL.md new file mode 100644 index 000000000..2882bf541 --- /dev/null +++ b/.codex/skills/iapkit-e2e-petgu/SKILL.md @@ -0,0 +1,100 @@ +--- +name: iapkit-e2e-petgu +description: Use for IAPKit product sync E2E testing in packages/kit with the Petgu React Native app, localhost dashboard, App Store Connect, and Google Play Console. Triggers when verifying kit product/entitlement create, update, delete, pull, push, or store sync behavior with Petgu. +--- + +# IAPKit Petgu E2E + +Use this skill when the user asks to E2E test IAPKit product sync, especially +create/update/delete behavior between `packages/kit`, App Store Connect, and +Google Play Console. + +## Default Targets + +- OpenIAP repo: `/Users/hyo/Github/hyodotdev/openiap` +- Kit package: `/Users/hyo/Github/hyodotdev/openiap/packages/kit` +- Petgu app: `/Users/hyo/Github/hyodotdev/Petgu` +- Local dashboard: `http://127.0.0.1:5174/hyo-dev/project/petgu/products` +- IAPKit project: organization `hyo-dev`, project `petgu` +- iOS app: App Store Connect app `6755113628` +- Android app: Play Console app `4975127121389308944`, package + `dev.hyo.petgu.app` +- Google service account: + `iap2-22@martie-c0b27.iam.gserviceaccount.com` + +## Safety Rules + +- Do not delete real Petgu store product IDs after a sync test unless the user + explicitly confirms they accept permanent loss of those IDs. +- Apple product IDs cannot be reused in the same app after an IAP is created, + even if the IAP is deleted. Treat deletion of real iOS products as + irreversible. +- For cleanup, create temporary SKUs and delete only those temporary SKUs after + verification. Prefer IDs under `dev.hyo.petgu.codex.*`. +- Keep the final store catalog clean: no temporary SKUs should remain in + App Store Connect, Play Console, or IAPKit. +- Do not run real purchase flows unless the user explicitly asks. Product + loading/sync verification is normally enough. + +## Standard Workflow + +1. Read repository rules before changing code: + - `/Users/hyo/Github/hyodotdev/openiap/AGENTS.md` + - `/Users/hyo/Github/hyodotdev/openiap/packages/kit/CONVENTION.md` +2. Start or use the local kit dashboard on `127.0.0.1:5174`. +3. Use Petgu as the React Native fixture app for product IDs and native billing + capability checks. +4. Verify store credentials before sync: + - iOS App Store Connect API key can list/push products. + - Android Play service account has Petgu app permissions and the app bundle + includes `com.android.vending.BILLING`. +5. Run kit product sync jobs through Convex for Petgu: + - dry-run push + - actual push + - actual pull + - create/update/delete using a temporary SKU +6. Check the stores directly: + - Use Chrome for already-open logged-in Play Console/App Store Connect tabs. + - Use Play Developer API/App Store Connect API for precise state checks. +7. Cleanup: + - Delete all temporary test SKUs from IAPKit and upstream stores. + - Re-run pull to confirm temporary SKUs are gone. + - Leave real Petgu SKUs intact unless the user confirms irreversible deletion. +8. Run focused verification: + - `cd /Users/hyo/Github/hyodotdev/openiap/packages/kit` + - `bun run typecheck` + - `bun run test -- convex/products` + - `bun run lint:eslint` + - `cd /Users/hyo/Github/hyodotdev/Petgu` + - `bun install --frozen-lockfile` + - `bun run type-check` + +## Known Good Petgu Products + +Use these as the real Petgu catalog baseline: + +- `dev.hyo.petgu.lifetime`: non-consumable lifetime unlock +- `dev.hyo.petgu.treats100`: consumable +- `dev.hyo.petgu.premium.monthly`: subscription, `P1M` +- `dev.hyo.petgu.premium.yearly`: subscription, `P1Y` + +## Expected Store Behavior + +- Android one-time products must have an active `buy` purchase option. Creating + the product alone is not enough. +- Android pull should preserve a kit-authored `Consumable` type because Play + does not round-trip the consumable/non-consumable distinction. +- iOS products may remain `Missing Metadata` / kit `Draft` until screenshots, + review metadata, and version submission requirements are satisfied. This can + still mean product sync itself succeeded. +- App Store Connect's first IAP/subscription submission must be attached to a + new app version before it can move past missing metadata. + +## Reporting + +In the final report, include: + +- Convex job IDs and counts for push/pull/delete. +- Whether Chrome/App Store/Play Console showed the expected SKUs. +- Whether temporary SKUs were deleted from both IAPKit and the stores. +- Any permanent store-policy limitation that prevented deletion or recreation. diff --git a/.codex/skills/iapkit-e2e-petgu/agents/openai.yaml b/.codex/skills/iapkit-e2e-petgu/agents/openai.yaml new file mode 100644 index 000000000..9818f24cf --- /dev/null +++ b/.codex/skills/iapkit-e2e-petgu/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "IAPKit Petgu E2E" + short_description: "Run IAPKit E2E checks with Petgu" + default_prompt: "Use $iapkit-e2e-petgu to verify IAPKit product sync locally with Petgu." diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts index 21ab38d8f..ef9848041 100644 --- a/packages/kit/convex/products/asc.ts +++ b/packages/kit/convex/products/asc.ts @@ -30,7 +30,7 @@ class ProductSyncCancelledError extends Error { // slot for projects mid-migration). Throws on missing config or // missing .p8 with the operator-actionable message we want surfaced. type AscCredentials = { - issuerId: string; + issuerId?: string; keyId: string; keyContent: string; }; @@ -72,16 +72,19 @@ async function resolveAscCredentials( ? (project.iosAscIssuerId ?? project.iosAppStoreIssuerId) : project.iosAppStoreIssuerId; const keyId = useAsc ? project.iosAscKeyId : project.iosAppStoreKeyId; - if (!issuerId || !keyId) { + if (!keyId) { + const missing = [ + ...(!keyId ? [useAsc ? "iosAscKeyId" : "iosAppStoreKeyId"] : []), + ]; throw new Error( options.detailedErrors - ? "App Store Connect API Issuer ID / Key ID not configured. " + + ? `App Store Connect API ${missing.join(", ")} not configured. ` + "Generate them at App Store Connect → Users and Access → " + "Integrations → App Store Connect API (NOT under In-App " + "Purchase — those credentials are scoped to receipt " + "verification only). Save them in Settings → iOS " + "Configuration → 'App Store Connect API (push-sync)'." - : "App Store Connect API Issuer ID / Key ID not configured", + : `App Store Connect API ${missing.join(", ")} not configured`, ); } // Prefer the dedicated ASC .p8 file; fall back to the Server API @@ -179,12 +182,14 @@ async function getProjectForActionArgs( // // Surface area implemented (matches what `@onesub/providers` exposes): // - listInAppPurchases(appId) → GET /v1/apps/{id}/inAppPurchasesV2 -// - createInAppPurchase(args) → POST /v1/inAppPurchases -// - patchInAppPurchase(id,...) → PATCH /v1/inAppPurchases/{id} +// - createInAppPurchase(args) → POST /v2/inAppPurchases +// - patchInAppPurchase(id,...) → PATCH /v2/inAppPurchases/{id} +// - deleteInAppPurchase(id) → DELETE /v2/inAppPurchases/{id} // - listSubscriptionGroups(appId) → GET /v1/apps/{id}/subscriptionGroups // - listSubscriptions(groupId) → GET /v1/subscriptionGroups/{id}/subscriptions // - createSubscription(...) → POST /v1/subscriptions // - patchSubscription(...) → PATCH /v1/subscriptions/{id} +// - deleteSubscription(id) → DELETE /v1/subscriptions/{id} // The `pushSyncProducts` action drives kit→ASC sync for a project. // // Failure model: ASC returns an `errors[]` array per the JSON:API @@ -215,11 +220,28 @@ export class AscApiError extends Error { } } +function isBenignAscRetryConflict(error: unknown): boolean { + if (!(error instanceof AscApiError) || error.status !== 409) return false; + const message = error.message.toLowerCase(); + if ( + message.includes("missing a required") || + message.includes("invalid format") || + message.includes("invalid") + ) { + return false; + } + return ( + message.includes("already") || + message.includes("duplicate") || + message.includes("exists") + ); +} + class AscClient { private cached: AscToken | null = null; constructor( - private readonly issuerId: string, + private readonly issuerId: string | undefined, private readonly keyId: string, private readonly privateKey: string, ) {} @@ -412,7 +434,7 @@ class AscClient { // stays on `/v1/inAppPurchasePriceSchedules/...`. try { const schedule = await this.call( - `/v2/inAppPurchases/${encodeURIComponent(iapId)}/iapPriceSchedule`, + `/v2/inAppPurchases/${encodeURIComponent(iapId)}/relationships/iapPriceSchedule`, ); if (!schedule?.data?.id) { return new Error( @@ -473,7 +495,7 @@ class AscClient { const list = await this.collectAllPages< AscPricePointListResponse["data"][number] >( - `/v1/inAppPurchases/${encodeURIComponent(iapId)}/pricePoints?filter[territory]=USA&limit=200`, + `/v2/inAppPurchases/${encodeURIComponent(iapId)}/pricePoints?filter[territory]=USA&limit=200`, ); return pickPricePointIdMatching(list, targetMicros); } @@ -498,7 +520,7 @@ class AscClient { pricePointId: string; startDate?: string; // YYYY-MM-DD; omit for "effective immediately" }) { - const priceLid = "newPrice"; + const priceLid = "${newPrice}"; const today = args.startDate ?? new Date().toISOString().slice(0, 10); return this.call<{ data: { id: string } }>( `/v1/inAppPurchasePriceSchedules`, @@ -511,6 +533,9 @@ class AscClient { inAppPurchase: { data: { type: "inAppPurchases", id: args.iapId }, }, + baseTerritory: { + data: { type: "territories", id: "USA" }, + }, manualPrices: { data: [{ type: "inAppPurchasePrices", id: priceLid }], }, @@ -543,15 +568,62 @@ class AscClient { pricePointId: string; startDate?: string; }) { - const priceLid = "newSubPrice"; - const today = args.startDate ?? new Date().toISOString().slice(0, 10); + const attributes = + args.startDate === undefined ? {} : { startDate: args.startDate }; + const priceLid = "${newSubPrice}"; + return this.call<{ data: { id: string } }>( + `/v1/subscriptions/${encodeURIComponent(args.subId)}`, + { + method: "PATCH", + body: JSON.stringify({ + data: { + type: "subscriptions", + id: args.subId, + relationships: { + prices: { + data: [{ type: "subscriptionPrices", id: priceLid }], + }, + }, + }, + included: [ + { + type: "subscriptionPrices", + id: priceLid, + attributes, + relationships: { + subscription: { + data: { type: "subscriptions", id: args.subId }, + }, + subscriptionPricePoint: { + data: { + type: "subscriptionPricePoints", + id: args.pricePointId, + }, + }, + territory: { + data: { type: "territories", id: "USA" }, + }, + }, + }, + ], + }), + }, + ); + } + + createSubPriceChange(args: { + subId: string; + pricePointId: string; + startDate?: string; + }) { + const attributes = + args.startDate === undefined ? {} : { startDate: args.startDate }; return this.call<{ data: { id: string } }>(`/v1/subscriptionPrices`, { method: "POST", body: JSON.stringify({ data: { type: "subscriptionPrices", - id: priceLid, - attributes: { startDate: today }, + attributes, relationships: { subscription: { data: { type: "subscriptions", id: args.subId }, @@ -562,6 +634,9 @@ class AscClient { id: args.pricePointId, }, }, + territory: { + data: { type: "territories", id: "USA" }, + }, }, }, }), @@ -600,6 +675,39 @@ class AscClient { }, ); } + async upsertIapLocalization(args: { + iapId: string; + name: string; + description: string; + locale?: string; + }) { + const locale = args.locale ?? "en-US"; + const existing = await this.call( + `/v2/inAppPurchases/${encodeURIComponent(args.iapId)}/inAppPurchaseLocalizations?limit=200`, + ); + const match = existing.data.find( + (item) => item.attributes.locale === locale, + ); + if (!match) { + return await this.createIapLocalization(args); + } + return this.call<{ data: { id: string } }>( + `/v1/inAppPurchaseLocalizations/${encodeURIComponent(match.id)}`, + { + method: "PATCH", + body: JSON.stringify({ + data: { + type: "inAppPurchaseLocalizations", + id: match.id, + attributes: { + name: args.name, + description: args.description, + }, + }, + }), + }, + ); + } createSubLocalization(args: { subId: string; name: string; @@ -628,6 +736,39 @@ class AscClient { }, ); } + async upsertSubLocalization(args: { + subId: string; + name: string; + description: string; + locale?: string; + }) { + const locale = args.locale ?? "en-US"; + const existing = await this.call( + `/v1/subscriptions/${encodeURIComponent(args.subId)}/subscriptionLocalizations?limit=200`, + ); + const match = existing.data.find( + (item) => item.attributes.locale === locale, + ); + if (!match) { + return await this.createSubLocalization(args); + } + return this.call<{ data: { id: string } }>( + `/v1/subscriptionLocalizations/${encodeURIComponent(match.id)}`, + { + method: "PATCH", + body: JSON.stringify({ + data: { + type: "subscriptionLocalizations", + id: match.id, + attributes: { + name: args.name, + description: args.description, + }, + }, + }), + }, + ); + } // Look up an existing subscription group by referenceName, or // create one. Used by the Add Product flow when the operator types @@ -668,7 +809,7 @@ class AscClient { type: "CONSUMABLE" | "NON_CONSUMABLE" | "NON_RENEWING_SUBSCRIPTION"; reviewNote?: string; }) { - return this.call(`/v1/inAppPurchases`, { + return this.call(`/v2/inAppPurchases`, { method: "POST", body: JSON.stringify({ data: { @@ -692,7 +833,7 @@ class AscClient { attributes: { name?: string; reviewNote?: string }, ) { return this.call( - `/v1/inAppPurchases/${encodeURIComponent(id)}`, + `/v2/inAppPurchases/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ @@ -702,6 +843,12 @@ class AscClient { ); } + async deleteInAppPurchase(id: string): Promise { + await this.call(`/v2/inAppPurchases/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + } + createSubscription(args: { groupId: string; productId: string; @@ -750,6 +897,12 @@ class AscClient { }, ); } + + async deleteSubscription(id: string): Promise { + await this.call(`/v1/subscriptions/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + } } type AscIapResource = { @@ -788,6 +941,15 @@ type AscSubListResponse = { data: AscSubResource["data"][]; }; +type AscLocalizationListResponse = { + data: Array<{ + id: string; + attributes: { + locale?: string; + }; + }>; +}; + type AscSubGroupListResponse = { data: Array<{ id: string; @@ -1074,6 +1236,7 @@ export const runProductSyncIOS = internalAction({ jobId: args.jobId, pulled: result.pulled, pushed: result.pushed, + deleted: result.deleted, failures: result.failures, plannedWrites: result.plannedWrites, }); @@ -1117,6 +1280,7 @@ interface IosSyncOptions { interface SyncResult { pulled: number; pushed: number; + deleted?: number; failures: Array<{ productId: string; reason: string }>; plannedWrites?: Array<{ productId: string; step: string; detail?: string }>; } @@ -1174,6 +1338,7 @@ async function performIosSync( }> = []; const appIdStr = String(project.iosAppAppleId); + let deleted = 0; // ── PULL: ASC → kit catalog ──────────────────────────────────── if (direction === "pull" || direction === "both") { @@ -1335,6 +1500,70 @@ async function performIosSync( // POST) is a follow-up because it needs a screenshot file and a // dashboard upload slot we haven't built yet — see TODO below. if (direction === "push" || direction === "both") { + await checkCancelled(); + await reportPhase("push-removals", { + current: pulled, + failuresCount: failures.length, + }); + const removals = await ctx.runQuery( + internal.products.sync.listRemovedIosProducts, + { projectId: project._id }, + ); + for (const row of removals) { + await checkCancelled(); + const deleteStep = + row.storeRef === undefined + ? "delete local product row" + : row.type === "Subscription" + ? "delete subscription" + : "delete in-app purchase"; + if (dryRun) { + plannedWrites.push({ + productId: row.productId, + step: deleteStep, + detail: row.storeRef + ? `storeRef=${row.storeRef}; Apple product IDs cannot be reused after deletion` + : "kit-only row has no upstream storeRef", + }); + continue; + } + try { + if (row.storeRef) { + if (row.type === "Subscription") { + await client.deleteSubscription(row.storeRef); + } else { + await client.deleteInAppPurchase(row.storeRef); + } + } + const didDelete = await ctx.runMutation( + internal.products.sync.deleteRemovedProductRow, + { + projectId: project._id, + productId: row.productId, + platform: "IOS", + }, + ); + if (didDelete) deleted += 1; + } catch (error) { + if (error instanceof AscApiError && error.status === 404) { + const didDelete = await ctx.runMutation( + internal.products.sync.deleteRemovedProductRow, + { + projectId: project._id, + productId: row.productId, + platform: "IOS", + }, + ); + if (didDelete) deleted += 1; + continue; + } + failures.push({ + productId: `${row.productId} (delete)`, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + await checkCancelled(); await reportPhase("push-drafts", { current: pulled, @@ -1464,6 +1693,16 @@ async function performIosSync( step: "skip create (resuming partial sync)", detail: `existing storeRef=${storeRef}`, }); + plannedWrites.push({ + productId: row.productId, + step: "patch subscription", + detail: row.title, + }); + } else { + await client.patchSubscription(storeRef, { + name: row.title, + reviewNote: row.reviewNote, + }); } } else { let groupId: string; @@ -1533,12 +1772,14 @@ async function performIosSync( if (dryRun) { plannedWrites.push({ productId: row.productId, - step: "create en-US localization", + step: row.storeRef + ? "patch en-US localization" + : "create en-US localization", detail: row.description ?? row.title, }); } else { try { - await client.createSubLocalization({ + await client.upsertSubLocalization({ subId: storeRef, name: row.title, description: row.description ?? row.title, @@ -1577,30 +1818,36 @@ async function performIosSync( }); } else { try { - const pricePointId = await client.findSubUsaPricePointId( - storeRef, - row.priceAmountMicros, - ); - if (!pricePointId) { - recordFailure({ - productId: `${row.productId} (price)`, - reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`, - }); - } else { - await client.setSubPriceSchedule({ - subId: storeRef, - pricePointId, - }); + const currentPrice = await client.subCurrentPrice(storeRef); + const assigned = + currentPrice instanceof Error + ? {} + : parseAssignedPrice( + currentPrice, + "subscriptionPricePoint", + ); + if (assigned.priceAmountMicros !== row.priceAmountMicros) { + const pricePointId = await client.findSubUsaPricePointId( + storeRef, + row.priceAmountMicros, + ); + if (!pricePointId) { + recordFailure({ + productId: `${row.productId} (price)`, + reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`, + }); + } else { + await client.setSubPriceSchedule({ + subId: storeRef, + pricePointId, + }); + } } } catch (error) { - // 409 Conflict means a price schedule already exists - // for the (subscription, startDate=today) pair from a - // prior partial sync — Apple keys schedules by date, - // not by id. Treat as benign retry so the subsequent - // markPushed step still runs (PR #124 - // (https://github.com/hyodotdev/openiap/pull/124) - // review). - if (!(error instanceof AscApiError && error.status === 409)) { + // Treat only duplicate/existing conflicts as benign + // retries. ASC also reports malformed price payloads + // as 409 ENTITY_ERROR, and those must stay visible. + if (!isBenignAscRetryConflict(error)) { recordFailure({ productId: `${row.productId} (price)`, reason: @@ -1637,6 +1884,16 @@ async function performIosSync( step: "skip create (resuming partial sync)", detail: `existing storeRef=${storeRef}`, }); + plannedWrites.push({ + productId: row.productId, + step: "patch in-app purchase", + detail: row.title, + }); + } else { + await client.patchInAppPurchase(storeRef, { + name: row.title, + reviewNote: row.reviewNote, + }); } } else if (dryRun) { storeRef = "(would-create)"; @@ -1667,12 +1924,14 @@ async function performIosSync( if (dryRun) { plannedWrites.push({ productId: row.productId, - step: "create en-US localization", + step: row.storeRef + ? "patch en-US localization" + : "create en-US localization", detail: row.description ?? row.title, }); } else { try { - await client.createIapLocalization({ + await client.upsertIapLocalization({ iapId: storeRef, name: row.title, description: row.description ?? row.title, @@ -1702,28 +1961,36 @@ async function performIosSync( }); } else { try { - const pricePointId = await client.findIapUsaPricePointId( - storeRef, - row.priceAmountMicros, - ); - if (!pricePointId) { - recordFailure({ - productId: `${row.productId} (price)`, - reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`, - }); - } else { - await client.setIapPriceSchedule({ - iapId: storeRef, - pricePointId, - }); + const currentPrice = await client.iapCurrentPrice(storeRef); + const assigned = + currentPrice instanceof Error + ? {} + : parseAssignedPrice( + currentPrice, + "inAppPurchasePricePoint", + ); + if (assigned.priceAmountMicros !== row.priceAmountMicros) { + const pricePointId = await client.findIapUsaPricePointId( + storeRef, + row.priceAmountMicros, + ); + if (!pricePointId) { + recordFailure({ + productId: `${row.productId} (price)`, + reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`, + }); + } else { + await client.setIapPriceSchedule({ + iapId: storeRef, + pricePointId, + }); + } } } catch (error) { - // Same 409-is-benign rationale as the subscription - // price schedule path above — Apple keys IAP price - // schedules by (iapId, startDate) so a same-day - // retry hits Conflict. Allow the row to proceed to - // markPushed instead of stalling in Draft. - if (!(error instanceof AscApiError && error.status === 409)) { + // Treat only duplicate/existing conflicts as benign + // retries. ASC also reports malformed price payloads + // as 409 ENTITY_ERROR, and those must stay visible. + if (!isBenignAscRetryConflict(error)) { recordFailure({ productId: `${row.productId} (price)`, reason: @@ -1772,6 +2039,7 @@ async function performIosSync( return { pulled, pushed, + ...(deleted > 0 ? { deleted } : {}), failures, plannedWrites: dryRun ? plannedWrites : undefined, }; diff --git a/packages/kit/convex/products/jwt.test.ts b/packages/kit/convex/products/jwt.test.ts index 61a524740..858dac6ef 100644 --- a/packages/kit/convex/products/jwt.test.ts +++ b/packages/kit/convex/products/jwt.test.ts @@ -55,6 +55,22 @@ describe("mintAscJwt", () => { expect(payload.exp - payload.iat).toBe(1_200); }); + it("mints individual-key payloads without issuer id", () => { + const pem = generateP8(); + const token = mintAscJwt({ + keyId: "INDIVIDUAL1", + privateKey: pem, + nowSeconds: () => 1_711_000_000, + }); + + const payload = JSON.parse( + Buffer.from(token.split(".")[1], "base64url").toString("utf-8"), + ); + expect(payload.iss).toBeUndefined(); + expect(payload.sub).toBe("user"); + expect(payload.aud).toBe("appstoreconnect-v1"); + }); + it("produces a signature that verifies against the public key with the JOSE r||s format", () => { const pem = generateP8(); const token = mintAscJwt({ diff --git a/packages/kit/convex/products/jwt.ts b/packages/kit/convex/products/jwt.ts index fe6a902f9..5d4f06a16 100644 --- a/packages/kit/convex/products/jwt.ts +++ b/packages/kit/convex/products/jwt.ts @@ -1,7 +1,8 @@ "use node"; // Minimal ES256 JWT minter for App Store Connect API authentication. // ASC requires every request to carry a JWT in `Authorization: Bearer` -// signed with the project's downloaded `.p8` key (kid + issuerId). +// signed with the project's downloaded `.p8` key. Team keys include an +// issuer id; individual keys omit it and identify the user subject. // // We do NOT reach for `jose` / `jsonwebtoken` here — both pull // substantial node-only dependency trees into the Convex action @@ -15,7 +16,8 @@ import { createPrivateKey, createSign } from "node:crypto"; export type AscJwtClaims = { - iss: string; // issuerId — ASC > Users and Access > Keys + iss?: string; // issuerId — ASC > Users and Access > Keys + sub?: "user"; // Individual API keys use a user subject instead of iss. scope?: string[]; // optional ASC scope claim // aud is fixed to "appstoreconnect-v1" by ASC. // iat / exp are computed from `nowSeconds`. @@ -24,7 +26,7 @@ export type AscJwtClaims = { export type AscJwtOptions = { keyId: string; // ASC > Keys > Key ID privateKey: string; // PKCS#8 PEM (the .p8 file content) - issuerId: string; + issuerId?: string; // Token TTL in seconds. ASC enforces ≤ 1200s (20 min); default to a // conservative 600s to leave headroom for clock skew. ttlSeconds?: number; @@ -43,11 +45,15 @@ export function mintAscJwt(opts: AscJwtOptions): string { typ: "JWT", }; const payload: Record = { - iss: opts.issuerId, iat: now, exp: now + ttl, aud: "appstoreconnect-v1", }; + if (opts.issuerId) { + payload.iss = opts.issuerId; + } else { + payload.sub = "user"; + } const headerB64 = base64UrlEncode(Buffer.from(JSON.stringify(header))); const payloadB64 = base64UrlEncode(Buffer.from(JSON.stringify(payload))); diff --git a/packages/kit/convex/products/mutation.test.ts b/packages/kit/convex/products/mutation.test.ts new file mode 100644 index 000000000..07aef06b9 --- /dev/null +++ b/packages/kit/convex/products/mutation.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { nextStateForKitProductUpsert } from "./mutation"; + +describe("nextStateForKitProductUpsert", () => { + it("marks edited products as Draft when no explicit state is requested", () => { + expect(nextStateForKitProductUpsert()).toBe("Draft"); + }); + + it("honors explicit state updates", () => { + expect(nextStateForKitProductUpsert("Active")).toBe("Active"); + expect(nextStateForKitProductUpsert("Removed")).toBe("Removed"); + }); +}); diff --git a/packages/kit/convex/products/mutation.ts b/packages/kit/convex/products/mutation.ts index eaaccf7c2..f603d8d88 100644 --- a/packages/kit/convex/products/mutation.ts +++ b/packages/kit/convex/products/mutation.ts @@ -19,6 +19,13 @@ const stateValidator = v.union( v.literal("Active"), v.literal("Removed"), ); +type ProductState = "Draft" | "Ready" | "Active" | "Removed"; + +export function nextStateForKitProductUpsert( + requestedState?: ProductState, +): ProductState { + return requestedState ?? "Draft"; +} async function resolveProjectForMutationArgs( ctx: MutationCtx, @@ -137,7 +144,12 @@ export const upsertProduct = mutation({ subscriptionGroupName: args.subscriptionGroupName ?? existing.subscriptionGroupName, reviewNote: args.reviewNote ?? existing.reviewNote, - state: args.state ?? existing.state, + // A dashboard / MCP edit is a new kit-authored version that + // needs another push pass even if the previous version was + // already Ready or Active. `listDraft*Products` is the worker + // queue, so move edited rows back to Draft unless the caller + // explicitly requested a state. + state: nextStateForKitProductUpsert(args.state), storeRef: args.storeRef ?? existing.storeRef, updatedAt: now, // ALWAYS claim kit-management on dashboard / MCP edits — @@ -212,6 +224,11 @@ export const setProductState = mutation({ await ctx.db.patch(existing._id, { state: args.state, + // State flips are operator intent too. In particular, marking a + // pulled row Removed must turn it into a kit-authored deletion + // request so pull-sync does not resurrect it before push-sync can + // remove it from the store. + origin: "kit" as const, updatedAt: Date.now(), }); return { id: existing._id, state: args.state }; @@ -245,6 +262,7 @@ export const removeProduct = mutation({ // dashboard and preserves any webhook events that reference this productId. await ctx.db.patch(existing._id, { state: "Removed", + origin: "kit" as const, updatedAt: Date.now(), }); return { ok: true }; diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts index 3ffb22c7a..a9dfd604f 100644 --- a/packages/kit/convex/products/play.ts +++ b/packages/kit/convex/products/play.ts @@ -1,6 +1,6 @@ "use node"; import { v } from "convex/values"; -import { google } from "googleapis"; +import { google, type Auth } from "googleapis"; import type { androidpublisher_v3 } from "googleapis"; import { internalAction, type ActionCtx } from "../_generated/server"; @@ -117,6 +117,7 @@ export const runProductSyncAndroid = internalAction({ jobId: args.jobId, pulled: result.pulled, pushed: result.pushed, + deleted: result.deleted, failures: result.failures, plannedWrites: result.plannedWrites, }); @@ -159,6 +160,7 @@ interface AndroidSyncOptions { interface AndroidSyncResult { pulled: number; pushed: number; + deleted?: number; failures: ProductSyncFailure[]; plannedWrites?: Array<{ productId: string; step: string; detail?: string }>; } @@ -230,6 +232,7 @@ async function performAndroidSync( }> = []; let pulled = 0; let pushed = 0; + let deleted = 0; // ── PULL: Play → kit ───────────────────────────────────────── if (direction === "pull" || direction === "both") { @@ -385,15 +388,24 @@ async function performAndroidSync( nanos: preferred.nanos, }) : undefined; + const existingType = await ctx.runQuery( + internal.products.sync.getExistingProductType, + { + projectId: project._id, + platform: "Android", + productId: product.productId, + }, + ); await ctx.runMutation(internal.products.sync.upsertFromStore, { projectId: project._id, productId: product.productId, platform: "Android", // The new API doesn't carry a "consumable vs. - // non-consumable" distinction the same way — Play - // tracks consumption at purchase time. Default to - // NonConsumable; operators can edit on the kit side. - type: "NonConsumable", + // non-consumable" distinction; Play tracks consumption + // at purchase time. Preserve any kit-authored type so + // pull-sync doesn't turn consumables into + // non-consumables, and default only for first imports. + type: existingType ?? "NonConsumable", title: listing?.title ?? product.productId, description: listing?.description ?? undefined, priceAmountMicros, @@ -433,11 +445,19 @@ async function performAndroidSync( if (seenOneTimeSkus.has(product.sku)) continue; seenOneTimeSkus.add(product.sku); if (product.purchaseType === "subscription") continue; + const existingType = await ctx.runQuery( + internal.products.sync.getExistingProductType, + { + projectId: project._id, + platform: "Android", + productId: product.sku, + }, + ); await ctx.runMutation(internal.products.sync.upsertFromStore, { projectId: project._id, productId: product.sku, platform: "Android", - type: mapPlayOneTimeType(product), + type: existingType ?? mapPlayOneTimeType(product), title: pickPlayTitle(product) ?? product.sku, description: pickPlayDescription(product), priceAmountMicros: parsePlayPriceMicros(product), @@ -529,6 +549,70 @@ async function performAndroidSync( // ── PUSH: kit → Play for Draft rows ────────────────────────── if (direction === "push" || direction === "both") { + await checkCancelled(); + await reportPhase("push-removals", { + current: pulled, + failuresCount: failures.length, + }); + const removals = await ctx.runQuery( + internal.products.sync.listRemovedAndroidProducts, + { projectId: project._id }, + ); + for (const row of removals) { + await checkCancelled(); + const deleteStep = + row.storeRef === undefined + ? "delete local product row" + : row.type === "Subscription" + ? "delete subscription" + : "delete one-time product"; + if (dryRun) { + plannedWrites.push({ + productId: row.productId, + step: deleteStep, + detail: row.storeRef + ? `storeRef=${row.storeRef}` + : "kit-only row has no upstream storeRef", + }); + continue; + } + try { + if (row.storeRef) { + if (row.type === "Subscription") { + try { + await androidpublisher.monetization.subscriptions.delete({ + packageName, + productId: row.storeRef, + }); + } catch (error) { + if (!isGoogleNotFoundError(error)) throw error; + } + } else { + await deleteAndroidOneTimeProduct( + androidpublisher, + auth, + packageName, + row.storeRef, + ); + } + } + const didDelete = await ctx.runMutation( + internal.products.sync.deleteRemovedProductRow, + { + projectId: project._id, + productId: row.productId, + platform: "Android", + }, + ); + if (didDelete) deleted += 1; + } catch (error) { + failures.push({ + productId: `${row.productId} (delete)`, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + await checkCancelled(); await reportPhase("push-drafts", { current: pulled, @@ -613,9 +697,10 @@ async function performAndroidSync( } } } else { - // One-time product: patch listings + price via the - // legacy inappproducts.patch endpoint, which accepts a - // partial body and merges it. + // One-time product: modern Play Console apps use + // monetization.onetimeproducts, while older apps still + // accept the legacy inappproducts endpoint. Prefer the + // modern API and let the helper fall back when needed. if (dryRun) { plannedWrites.push({ productId: row.productId, @@ -628,29 +713,19 @@ async function performAndroidSync( }); } else { try { - await androidpublisher.inappproducts.patch({ - packageName, - sku: row.storeRef, - requestBody: { + await upsertAndroidOneTimeProduct( + androidpublisher, + auth, + { packageName, - sku: row.storeRef, - purchaseType: "managedUser", - listings: { - "en-US": { - title: row.title, - description: row.description ?? row.title, - }, - }, - ...(row.priceAmountMicros !== undefined && row.currency - ? { - defaultPrice: { - priceMicros: String(row.priceAmountMicros), - currency: row.currency, - }, - } - : {}), + productId: row.storeRef, + title: row.title, + description: row.description ?? row.title, + priceAmountMicros: row.priceAmountMicros, + currency: row.currency, }, - }); + { allowCreate: false }, + ); } catch (error) { patchOk = false; failures.push({ @@ -794,35 +869,19 @@ async function performAndroidSync( : " · no price set"), }); } else { - await androidpublisher.inappproducts.insert({ - packageName, - requestBody: { + await upsertAndroidOneTimeProduct( + androidpublisher, + auth, + { packageName, - sku: row.productId, - // Play API uses `managedUser` for both consumable and - // non-consumable; the difference is consumed at - // purchase time via `consumeAsync`. Subscriptions go - // through `monetization.subscriptions.*` (see branch - // above), not this endpoint. - purchaseType: "managedUser", - status: "active", - defaultLanguage: "en-US", - listings: { - "en-US": { - title: row.title, - description: row.description ?? row.title, - }, - }, - ...(row.priceAmountMicros !== undefined && row.currency - ? { - defaultPrice: { - priceMicros: String(row.priceAmountMicros), - currency: row.currency, - }, - } - : {}), + productId: row.productId, + title: row.title, + description: row.description ?? row.title, + priceAmountMicros: row.priceAmountMicros, + currency: row.currency, }, - }); + { allowCreate: true }, + ); } } if (!dryRun) { @@ -860,11 +919,255 @@ async function performAndroidSync( return { pulled, pushed, + ...(deleted > 0 ? { deleted } : {}), failures, plannedWrites: dryRun ? plannedWrites : undefined, }; } +function googleErrorStatus(error: unknown): number | undefined { + if (!error || typeof error !== "object") return undefined; + const candidate = error as { code?: unknown; status?: unknown }; + if (typeof candidate.code === "number") return candidate.code; + if (typeof candidate.status === "number") return candidate.status; + return undefined; +} + +function isGoogleNotFoundError(error: unknown): boolean { + return googleErrorStatus(error) === 404; +} + +interface AndroidOneTimeProductUpsertArgs { + packageName: string; + productId: string; + title: string; + description: string; + priceAmountMicros?: number; + currency?: string; +} + +async function upsertAndroidOneTimeProduct( + androidpublisher: androidpublisher_v3.Androidpublisher, + auth: Auth.GoogleAuth, + args: AndroidOneTimeProductUpsertArgs, + options: { allowCreate: boolean }, +): Promise { + validateAndroidOneTimePrice(args); + + const onetime = androidpublisher.monetization.onetimeproducts; + if (onetime?.patch) { + await onetime.patch({ + packageName: args.packageName, + productId: args.productId, + allowMissing: options.allowCreate, + updateMask: "listings,purchaseOptions", + "regionsVersion.version": "2022/01", + requestBody: buildAndroidOneTimeProduct(args), + }); + await activateAndroidOneTimePurchaseOption(auth, args); + return; + } + + if (options.allowCreate) { + await insertLegacyAndroidOneTimeProduct(androidpublisher, args); + await activateAndroidOneTimePurchaseOption(auth, args); + return; + } + await patchLegacyAndroidOneTimeProduct(androidpublisher, args); + await activateAndroidOneTimePurchaseOption(auth, args); +} + +function validateAndroidOneTimePrice( + args: AndroidOneTimeProductUpsertArgs, +): void { + if (!args.priceAmountMicros || !args.currency) { + throw new Error( + "One-time product requires priceAmountMicros + currency to mint a Play purchase option; otherwise the product will not be purchasable.", + ); + } + if (args.currency !== "USD") { + 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.`, + ); + } +} + +function buildAndroidOneTimeProduct( + args: AndroidOneTimeProductUpsertArgs, +): androidpublisher_v3.Schema$OneTimeProduct { + 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.", + ); + } + return { + packageName: args.packageName, + productId: args.productId, + listings: [ + { + languageCode: "en-US", + title: args.title, + description: args.description, + }, + ], + purchaseOptions: [ + { + purchaseOptionId: "buy", + buyOption: { + legacyCompatible: true, + multiQuantityEnabled: false, + }, + regionalPricingAndAvailabilityConfigs: [ + { + regionCode: "US", + availability: "AVAILABLE", + price: microsToGoogleMoney(args.priceAmountMicros, args.currency), + }, + ], + }, + ], + }; +} + +async function insertLegacyAndroidOneTimeProduct( + androidpublisher: androidpublisher_v3.Androidpublisher, + args: AndroidOneTimeProductUpsertArgs, +): Promise { + await androidpublisher.inappproducts.insert({ + packageName: args.packageName, + requestBody: { + packageName: args.packageName, + sku: args.productId, + purchaseType: "managedUser", + status: "active", + defaultLanguage: "en-US", + listings: { + "en-US": { + title: args.title, + description: args.description, + }, + }, + defaultPrice: { + priceMicros: String(args.priceAmountMicros), + currency: args.currency, + }, + }, + }); +} + +async function patchLegacyAndroidOneTimeProduct( + androidpublisher: androidpublisher_v3.Androidpublisher, + args: AndroidOneTimeProductUpsertArgs, +): Promise { + await androidpublisher.inappproducts.patch({ + packageName: args.packageName, + sku: args.productId, + requestBody: { + packageName: args.packageName, + sku: args.productId, + purchaseType: "managedUser", + listings: { + "en-US": { + title: args.title, + description: args.description, + }, + }, + defaultPrice: { + priceMicros: String(args.priceAmountMicros), + currency: args.currency, + }, + }, + }); +} + +async function activateAndroidOneTimePurchaseOption( + auth: Auth.GoogleAuth, + args: AndroidOneTimeProductUpsertArgs, +): Promise { + const client = await auth.getClient(); + await client.request({ + method: "POST", + url: + `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/` + + `${encodeURIComponent(args.packageName)}/oneTimeProducts/` + + `${encodeURIComponent(args.productId)}/purchaseOptions:batchUpdateStates`, + data: { + requests: [ + { + activatePurchaseOptionRequest: { + packageName: args.packageName, + productId: args.productId, + purchaseOptionId: "buy", + latencyTolerance: + "PRODUCT_UPDATE_LATENCY_TOLERANCE_LATENCY_TOLERANT", + }, + }, + ], + }, + }); +} + +async function deleteAndroidOneTimeProduct( + androidpublisher: androidpublisher_v3.Androidpublisher, + auth: Auth.GoogleAuth, + packageName: string, + productId: string, +): Promise { + try { + await deleteModernAndroidOneTimeProduct(auth, packageName, productId); + return; + } catch (error) { + if (isGoogleNotFoundError(error)) return; + } + + const monetizationApi = androidpublisher.monetization as + | { onetimeproducts?: unknown } + | undefined; + const onetime = ( + monetizationApi as unknown as { + onetimeproducts?: { + delete?: (params: { + packageName: string; + productId: string; + }) => Promise; + }; + } + ).onetimeproducts; + + if (onetime?.delete) { + try { + await onetime.delete({ packageName, productId }); + return; + } catch (error) { + if (!isGoogleNotFoundError(error)) throw error; + } + } + + try { + await androidpublisher.inappproducts.delete({ + packageName, + sku: productId, + }); + } catch (error) { + if (!isGoogleNotFoundError(error)) throw error; + } +} + +async function deleteModernAndroidOneTimeProduct( + auth: Auth.GoogleAuth, + packageName: string, + productId: string, +): Promise { + const client = await auth.getClient(); + await client.request({ + method: "DELETE", + url: + `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/` + + `${encodeURIComponent(packageName)}/oneTimeProducts/` + + `${encodeURIComponent(productId)}`, + }); +} + function mapPlayOneTimeType( product: androidpublisher_v3.Schema$InAppProduct, ): "Subscription" | "NonConsumable" | "Consumable" { @@ -1132,6 +1435,17 @@ export function moneyToMicros( } } +function microsToGoogleMoney( + priceAmountMicros: number, + currency: string, +): androidpublisher_v3.Schema$Money { + return { + currencyCode: currency, + units: String(Math.trunc(priceAmountMicros / 1_000_000)), + nanos: (priceAmountMicros % 1_000_000) * 1_000, + }; +} + function moneyUnitsToMicros(units: string): bigint | undefined { if (!/^\d+$/.test(units)) return undefined; return BigInt(units) * 1_000_000n; diff --git a/packages/kit/convex/products/sync.test.ts b/packages/kit/convex/products/sync.test.ts index 0719d5e56..150a2d6f1 100644 --- a/packages/kit/convex/products/sync.test.ts +++ b/packages/kit/convex/products/sync.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { isSafePriceAmountMicros } from "./sync"; +import { + isSafePriceAmountMicros, + shouldPreserveKitRemovedDuringPull, +} from "./sync"; describe("isSafePriceAmountMicros", () => { it("accepts missing and non-negative safe integer prices", () => { @@ -15,3 +18,29 @@ describe("isSafePriceAmountMicros", () => { expect(isSafePriceAmountMicros(Number.MAX_SAFE_INTEGER + 1)).toBe(false); }); }); + +describe("shouldPreserveKitRemovedDuringPull", () => { + it("preserves kit-authored Removed rows so direction=both can delete them upstream", () => { + expect( + shouldPreserveKitRemovedDuringPull({ + state: "Removed", + origin: "kit", + }), + ).toBe(true); + }); + + it("does not preserve store-authored or active rows", () => { + expect( + shouldPreserveKitRemovedDuringPull({ + state: "Removed", + origin: "store", + }), + ).toBe(false); + expect( + shouldPreserveKitRemovedDuringPull({ + state: "Ready", + origin: "kit", + }), + ).toBe(false); + }); +}); diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts index ef819028e..54fbd5a4e 100644 --- a/packages/kit/convex/products/sync.ts +++ b/packages/kit/convex/products/sync.ts @@ -75,6 +75,12 @@ function assertSafePriceAmountMicros( } } +export function shouldPreserveKitRemovedDuringPull( + existing: Pick, "state" | "origin"> | null | undefined, +): boolean { + return existing?.state === "Removed" && existing.origin === "kit"; +} + // Internal mutation called by the ASC / Play push-sync actions when a // row is mirrored from the upstream store. Distinct from the public // `upsertProduct` mutation in mutation.ts so server-driven sync can't @@ -139,6 +145,13 @@ export const upsertFromStore = internalMutation({ ) .unique(); const now = Date.now(); + if (existing && shouldPreserveKitRemovedDuringPull(existing)) { + // A kit-authored removal is an upstream delete request. Pull + // runs before push for direction="both", so without this guard + // the still-existing store row would resurrect the local row to + // Active/Ready and the delete pass would never see it. + return existing._id; + } // Subscription group metadata only applies to subscriptions — // explicitly null it out for non-Subscription rows so a row // that flipped types (or that the operator typed a group name @@ -273,6 +286,27 @@ export const markPushed = internalMutation({ }, }); +export const getExistingProductType = internalQuery({ + args: { + projectId: v.id("projects"), + platform: platformValidator, + productId: v.string(), + }, + returns: v.union(typeValidator, v.null()), + handler: async (ctx, args) => { + const existing = await ctx.db + .query("products") + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", args.projectId) + .eq("platform", args.platform) + .eq("productId", args.productId), + ) + .unique(); + return existing?.type ?? null; + }, +}); + // Pull every Draft iOS row that the push pass should attempt. We do // NOT gate on `storeRef === undefined` here: a previous sync may have // successfully created the upstream resource (storeRef now populated) @@ -417,14 +451,94 @@ export const listDraftAndroidProducts = internalQuery({ }, }); +const removedProductReturnValidator = v.object({ + productId: v.string(), + platform: platformValidator, + type: typeValidator, + storeRef: v.optional(v.string()), +}); + +function removedProductsForPush(rows: Doc<"products">[]): Array<{ + productId: string; + platform: "IOS" | "Android"; + type: "Subscription" | "NonConsumable" | "Consumable"; + storeRef?: string; +}> { + return rows + .filter( + (row) => + row.state === "Removed" && + // Only push deletes that were authored in kit. Store-imported + // removed/draft-ish rows are cache state, not an operator's + // instruction to delete upstream resources. + (row.origin === "kit" || row.storeRef === undefined), + ) + .map((row) => ({ + productId: row.productId, + platform: row.platform, + type: row.type, + storeRef: row.storeRef, + })); +} + +export const listRemovedIosProducts = internalQuery({ + args: { projectId: v.id("projects") }, + returns: v.array(removedProductReturnValidator), + handler: async (ctx, args) => { + const all = await ctx.db + .query("products") + .withIndex("by_project_and_platform", (q) => + q.eq("projectId", args.projectId).eq("platform", "IOS"), + ) + .collect(); + return removedProductsForPush(all); + }, +}); + +export const listRemovedAndroidProducts = internalQuery({ + args: { projectId: v.id("projects") }, + returns: v.array(removedProductReturnValidator), + handler: async (ctx, args) => { + const all = await ctx.db + .query("products") + .withIndex("by_project_and_platform", (q) => + q.eq("projectId", args.projectId).eq("platform", "Android"), + ) + .collect(); + return removedProductsForPush(all); + }, +}); + +export const deleteRemovedProductRow = internalMutation({ + args: { + projectId: v.id("projects"), + productId: v.string(), + platform: platformValidator, + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const existing = await ctx.db + .query("products") + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", args.projectId) + .eq("platform", args.platform) + .eq("productId", args.productId), + ) + .unique(); + if (!existing || existing.state !== "Removed") return false; + await ctx.db.delete(existing._id); + return true; + }, +}); + // Bounded delete used by the `purge-local` sync direction. Deletes // the project's kit-side product rows for one platform and returns // `{ deleted, hasMore }` so the worker can loop until empty without // blowing past Convex's per-mutation document budget. Does NOT touch -// App Store Connect / Play Console — purging upstream is intentionally -// out of scope (Apple can't fully delete IAPs via API; Play archive -// risks live-billing breakage). The operator does that in the -// store's web console if they really need it. +// App Store Connect / Play Console — upstream deletion is handled by +// marking individual rows Removed and running push/both sync so the +// platform-specific delete constraints can be reported per product. export const deletePlatformCatalog = internalMutation({ args: { projectId: v.id("projects"), diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts index c198ba606..56539c166 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -985,8 +985,8 @@ const schema = defineSchema({ v.object({ pulled: v.number(), pushed: v.number(), - // Number of kit-side rows the purge-local direction removed. - // Always 0 (or unset) for pull/push/both directions. + // Number of kit-side product rows removed by purge-local or + // by a successful upstream delete during push/both sync. deleted: v.optional(v.number()), failures: v.array( v.object({ productId: v.string(), reason: v.string() }), diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index 50ef6a575..b29acc1c0 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -154,9 +154,13 @@ export default function ProjectProducts() { const result = job.result; if (job.status === "succeeded" && result) { const summary = - result.deleted !== undefined + job.direction === "purge-local" && result.deleted !== undefined ? `Deleted ${result.deleted} row${result.deleted === 1 ? "" : "s"}` - : `Pulled ${result.pulled}, pushed ${result.pushed}`; + : `Pulled ${result.pulled}, pushed ${result.pushed}${ + result.deleted !== undefined + ? `, deleted ${result.deleted}` + : "" + }`; const plannedLines = result.plannedWrites?.length ? result.plannedWrites .map( @@ -501,12 +505,15 @@ export default function ProjectProducts() { Add product -

- On Sync, kit pushes the row to App Store Connect / Play Console, - creates an en-US localization, and sets the USA price tier — leaving - the IAP in "Ready to Submit" state. Final review submission - (screenshot upload) is still done in App Store Connect web. -

+ {draft.platform === "IOS" && ( +
+ On iOS, Sync pushes the row to App Store Connect, creates an en-US + 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 + for review. +
+ )} {job.result.deleted !== undefined - ? `Reset — deleted ${job.result.deleted} row${ - job.result.deleted === 1 ? "" : "s" - }` + ? job.direction === "purge-local" + ? `Reset — deleted ${job.result.deleted} row${ + job.result.deleted === 1 ? "" : "s" + }` + : `Last sync — pulled ${job.result.pulled}, pushed ${ + job.result.pushed + }, deleted ${job.result.deleted}` : `Last sync — pulled ${job.result.pulled}, pushed ${job.result.pushed}`} {job.result.failures.length ? `, ${job.result.failures.length} failure${ diff --git a/packages/mcp-server/src/mcp.ts b/packages/mcp-server/src/mcp.ts index 681e80022..4ab3a34e1 100644 --- a/packages/mcp-server/src/mcp.ts +++ b/packages/mcp-server/src/mcp.ts @@ -650,7 +650,7 @@ function registerIapKitTools(server: McpServer) { registerTool( server, "manage_product", - "Update or remove a product in IAPKit's catalog. `action: 'remove'` soft-removes via the product state endpoint.", + "Update or remove a product in IAPKit's catalog. `action: 'remove'` marks the row Removed; the next product sync push/both deletes the upstream store product when the platform allows it.", { productId: PRODUCT_ID_PARAM, platform: z.enum(["IOS", "Android"]), @@ -699,7 +699,7 @@ function registerIapKitTools(server: McpServer) { .enum(["pull", "push", "both", "purge-local"]) .optional() .describe( - "pull imports from the store, push writes IAPKit catalog rows to the store, both does both, purge-local removes local rows missing from the store.", + "pull imports from the store, push writes IAPKit catalog rows to the store (including eligible Removed-row deletes), both does both, purge-local deletes kit's local catalog cache only.", ), dryRun: z .boolean() From 9818763b0fe2e6b46d381ff255d0379e8ac6c4ea Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 7 Jul 2026 02:55:10 +0900 Subject: [PATCH 2/3] fix(kit): harden android product sync review fixes --- packages/kit/convex/products/asc.ts | 4 +- packages/kit/convex/products/play.ts | 105 +++++++++++++++++++-------- packages/kit/convex/products/sync.ts | 25 +++++++ 3 files changed, 100 insertions(+), 34 deletions(-) diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts index ef9848041..32fb3c20d 100644 --- a/packages/kit/convex/products/asc.ts +++ b/packages/kit/convex/products/asc.ts @@ -73,9 +73,7 @@ async function resolveAscCredentials( : project.iosAppStoreIssuerId; const keyId = useAsc ? project.iosAscKeyId : project.iosAppStoreKeyId; if (!keyId) { - const missing = [ - ...(!keyId ? [useAsc ? "iosAscKeyId" : "iosAppStoreKeyId"] : []), - ]; + const missing = [useAsc ? "iosAscKeyId" : "iosAppStoreKeyId"]; throw new Error( options.detailedErrors ? `App Store Connect API ${missing.join(", ")} not configured. ` + diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts index a9dfd604f..46b56a450 100644 --- a/packages/kit/convex/products/play.ts +++ b/packages/kit/convex/products/play.ts @@ -266,6 +266,13 @@ async function performAndroidSync( // AED happens to be a regional override. New endpoint runs // first; legacy only fills in skus the new endpoint missed. const seenOneTimeSkus = new Set(); + const existingTypeRows = await ctx.runQuery( + internal.products.sync.listExistingProductTypes, + { projectId: project._id, platform: "Android" }, + ); + const existingTypesByProductId = new Map( + existingTypeRows.map((row) => [row.productId, row.type]), + ); try { // Defensive guard: the new monetization API isn't surfaced in // any typed shape by `googleapis` yet, so we cast through @@ -388,13 +395,8 @@ async function performAndroidSync( nanos: preferred.nanos, }) : undefined; - const existingType = await ctx.runQuery( - internal.products.sync.getExistingProductType, - { - projectId: project._id, - platform: "Android", - productId: product.productId, - }, + const existingType = existingTypesByProductId.get( + product.productId, ); await ctx.runMutation(internal.products.sync.upsertFromStore, { projectId: project._id, @@ -405,7 +407,7 @@ async function performAndroidSync( // at purchase time. Preserve any kit-authored type so // pull-sync doesn't turn consumables into // non-consumables, and default only for first imports. - type: existingType ?? "NonConsumable", + type: preservePlayOneTimeType(existingType, "NonConsumable"), title: listing?.title ?? product.productId, description: listing?.description ?? undefined, priceAmountMicros, @@ -445,19 +447,15 @@ async function performAndroidSync( if (seenOneTimeSkus.has(product.sku)) continue; seenOneTimeSkus.add(product.sku); if (product.purchaseType === "subscription") continue; - const existingType = await ctx.runQuery( - internal.products.sync.getExistingProductType, - { - projectId: project._id, - platform: "Android", - productId: product.sku, - }, - ); + const existingType = existingTypesByProductId.get(product.sku); await ctx.runMutation(internal.products.sync.upsertFromStore, { projectId: project._id, productId: product.sku, platform: "Android", - type: existingType ?? mapPlayOneTimeType(product), + type: preservePlayOneTimeType( + existingType, + mapPlayOneTimeType(product), + ), title: pickPlayTitle(product) ?? product.sku, description: pickPlayDescription(product), priceAmountMicros: parsePlayPriceMicros(product), @@ -927,9 +925,20 @@ async function performAndroidSync( function googleErrorStatus(error: unknown): number | undefined { if (!error || typeof error !== "object") return undefined; - const candidate = error as { code?: unknown; status?: unknown }; + const candidate = error as { + code?: unknown; + status?: unknown; + response?: { status?: unknown }; + }; if (typeof candidate.code === "number") return candidate.code; + if (typeof candidate.code === "string") { + const parsed = Number.parseInt(candidate.code, 10); + if (Number.isFinite(parsed)) return parsed; + } if (typeof candidate.status === "number") return candidate.status; + if (typeof candidate.response?.status === "number") { + return candidate.response.status; + } return undefined; } @@ -954,27 +963,19 @@ async function upsertAndroidOneTimeProduct( ): Promise { validateAndroidOneTimePrice(args); - const onetime = androidpublisher.monetization.onetimeproducts; - if (onetime?.patch) { - await onetime.patch({ - packageName: args.packageName, - productId: args.productId, - allowMissing: options.allowCreate, - updateMask: "listings,purchaseOptions", - "regionsVersion.version": "2022/01", - requestBody: buildAndroidOneTimeProduct(args), - }); + try { + await upsertModernAndroidOneTimeProduct(auth, args, options); await activateAndroidOneTimePurchaseOption(auth, args); return; + } catch (error) { + if (!shouldFallbackToLegacyOneTimeProduct(error)) throw error; } if (options.allowCreate) { await insertLegacyAndroidOneTimeProduct(androidpublisher, args); - await activateAndroidOneTimePurchaseOption(auth, args); return; } await patchLegacyAndroidOneTimeProduct(androidpublisher, args); - await activateAndroidOneTimePurchaseOption(auth, args); } function validateAndroidOneTimePrice( @@ -1029,6 +1030,27 @@ function buildAndroidOneTimeProduct( }; } +async function upsertModernAndroidOneTimeProduct( + auth: Auth.GoogleAuth, + args: AndroidOneTimeProductUpsertArgs, + options: { allowCreate: boolean }, +): Promise { + const client = await auth.getClient(); + await client.request({ + method: "PATCH", + url: + `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/` + + `${encodeURIComponent(args.packageName)}/oneTimeProducts/` + + `${encodeURIComponent(args.productId)}`, + params: { + allowMissing: options.allowCreate, + updateMask: "listings,purchaseOptions", + "regionsVersion.version": "2022/01", + }, + data: buildAndroidOneTimeProduct(args), + }); +} + async function insertLegacyAndroidOneTimeProduct( androidpublisher: androidpublisher_v3.Androidpublisher, args: AndroidOneTimeProductUpsertArgs, @@ -1080,6 +1102,17 @@ async function patchLegacyAndroidOneTimeProduct( }); } +function shouldFallbackToLegacyOneTimeProduct(error: unknown): boolean { + const status = googleErrorStatus(error); + const message = error instanceof Error ? error.message : String(error); + return ( + status === 404 || + message.includes("inappproducts") || + message.includes("InAppProduct") || + message.includes("Please use the InAppProducts API") + ); +} + async function activateAndroidOneTimePurchaseOption( auth: Auth.GoogleAuth, args: AndroidOneTimeProductUpsertArgs, @@ -1170,11 +1203,21 @@ async function deleteModernAndroidOneTimeProduct( function mapPlayOneTimeType( product: androidpublisher_v3.Schema$InAppProduct, -): "Subscription" | "NonConsumable" | "Consumable" { +): "NonConsumable" | "Consumable" { if (product.purchaseType === "managedUser") return "NonConsumable"; return "Consumable"; } +function preservePlayOneTimeType( + existingType: "Subscription" | "NonConsumable" | "Consumable" | undefined, + fallback: "NonConsumable" | "Consumable", +): "NonConsumable" | "Consumable" { + if (existingType === "Consumable" || existingType === "NonConsumable") { + return existingType; + } + return fallback; +} + function mapPlayStatus( status: string | null | undefined, ): "Draft" | "Ready" | "Active" | "Removed" { diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts index 54fbd5a4e..ffa57b8b0 100644 --- a/packages/kit/convex/products/sync.ts +++ b/packages/kit/convex/products/sync.ts @@ -307,6 +307,31 @@ export const getExistingProductType = internalQuery({ }, }); +export const listExistingProductTypes = internalQuery({ + args: { + projectId: v.id("projects"), + platform: platformValidator, + }, + returns: v.array( + v.object({ + productId: v.string(), + type: typeValidator, + }), + ), + handler: async (ctx, args) => { + const rows = await ctx.db + .query("products") + .withIndex("by_project_and_platform", (q) => + q.eq("projectId", args.projectId).eq("platform", args.platform), + ) + .collect(); + return rows.map((row) => ({ + productId: row.productId, + type: row.type, + })); + }, +}); + // Pull every Draft iOS row that the push pass should attempt. We do // NOT gate on `storeRef === undefined` here: a previous sync may have // successfully created the upstream resource (storeRef now populated) From 87f445b9ae43e7315fa6d8c3ce079b488b20b1b2 Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 7 Jul 2026 03:04:46 +0900 Subject: [PATCH 3/3] fix(kit): address catalog sync review followups --- .codex/skills/iapkit-e2e-petgu/SKILL.md | 17 +++++----- packages/kit/convex/products/asc.ts | 42 +------------------------ packages/kit/convex/products/play.ts | 2 +- packages/kit/convex/products/sync.ts | 8 ++++- 4 files changed, 18 insertions(+), 51 deletions(-) diff --git a/.codex/skills/iapkit-e2e-petgu/SKILL.md b/.codex/skills/iapkit-e2e-petgu/SKILL.md index 2882bf541..bf307010d 100644 --- a/.codex/skills/iapkit-e2e-petgu/SKILL.md +++ b/.codex/skills/iapkit-e2e-petgu/SKILL.md @@ -11,10 +11,11 @@ Google Play Console. ## Default Targets -- OpenIAP repo: `/Users/hyo/Github/hyodotdev/openiap` -- Kit package: `/Users/hyo/Github/hyodotdev/openiap/packages/kit` -- Petgu app: `/Users/hyo/Github/hyodotdev/Petgu` -- Local dashboard: `http://127.0.0.1:5174/hyo-dev/project/petgu/products` +- OpenIAP repo: `$OPENIAP_REPO` (current checkout; this repo) +- Kit package: `$OPENIAP_REPO/packages/kit` +- Petgu app: `$PETGU_REPO` +- Local dashboard: `$IAPKIT_LOCAL_URL`, default + `http://127.0.0.1:5174/hyo-dev/project/petgu/products` - IAPKit project: organization `hyo-dev`, project `petgu` - iOS app: App Store Connect app `6755113628` - Android app: Play Console app `4975127121389308944`, package @@ -39,8 +40,8 @@ Google Play Console. ## Standard Workflow 1. Read repository rules before changing code: - - `/Users/hyo/Github/hyodotdev/openiap/AGENTS.md` - - `/Users/hyo/Github/hyodotdev/openiap/packages/kit/CONVENTION.md` + - `$OPENIAP_REPO/AGENTS.md` + - `$OPENIAP_REPO/packages/kit/CONVENTION.md` 2. Start or use the local kit dashboard on `127.0.0.1:5174`. 3. Use Petgu as the React Native fixture app for product IDs and native billing capability checks. @@ -61,11 +62,11 @@ Google Play Console. - Re-run pull to confirm temporary SKUs are gone. - Leave real Petgu SKUs intact unless the user confirms irreversible deletion. 8. Run focused verification: - - `cd /Users/hyo/Github/hyodotdev/openiap/packages/kit` + - `cd $OPENIAP_REPO/packages/kit` - `bun run typecheck` - `bun run test -- convex/products` - `bun run lint:eslint` - - `cd /Users/hyo/Github/hyodotdev/Petgu` + - `cd $PETGU_REPO` - `bun install --frozen-lockfile` - `bun run type-check` diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts index 32fb3c20d..e68f29cf5 100644 --- a/packages/kit/convex/products/asc.ts +++ b/packages/kit/convex/products/asc.ts @@ -566,47 +566,7 @@ class AscClient { pricePointId: string; startDate?: string; }) { - const attributes = - args.startDate === undefined ? {} : { startDate: args.startDate }; - const priceLid = "${newSubPrice}"; - return this.call<{ data: { id: string } }>( - `/v1/subscriptions/${encodeURIComponent(args.subId)}`, - { - method: "PATCH", - body: JSON.stringify({ - data: { - type: "subscriptions", - id: args.subId, - relationships: { - prices: { - data: [{ type: "subscriptionPrices", id: priceLid }], - }, - }, - }, - included: [ - { - type: "subscriptionPrices", - id: priceLid, - attributes, - relationships: { - subscription: { - data: { type: "subscriptions", id: args.subId }, - }, - subscriptionPricePoint: { - data: { - type: "subscriptionPricePoints", - id: args.pricePointId, - }, - }, - territory: { - data: { type: "territories", id: "USA" }, - }, - }, - }, - ], - }), - }, - ); + return this.createSubPriceChange(args); } createSubPriceChange(args: { diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts index 46b56a450..fde8ebcaf 100644 --- a/packages/kit/convex/products/play.ts +++ b/packages/kit/convex/products/play.ts @@ -1150,7 +1150,7 @@ async function deleteAndroidOneTimeProduct( await deleteModernAndroidOneTimeProduct(auth, packageName, productId); return; } catch (error) { - if (isGoogleNotFoundError(error)) return; + if (!isGoogleNotFoundError(error)) throw error; } const monetizationApi = androidpublisher.monetization as diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts index ffa57b8b0..d18572a5f 100644 --- a/packages/kit/convex/products/sync.ts +++ b/packages/kit/convex/products/sync.ts @@ -551,7 +551,13 @@ export const deleteRemovedProductRow = internalMutation({ .eq("productId", args.productId), ) .unique(); - if (!existing || existing.state !== "Removed") return false; + if ( + !existing || + existing.state !== "Removed" || + !(existing.origin === "kit" || existing.storeRef === undefined) + ) { + return false; + } await ctx.db.delete(existing._id); return true; },