diff --git a/packages/kit/convex/products/play.test.ts b/packages/kit/convex/products/play.test.ts index feb00499a..7e3d7dc83 100644 --- a/packages/kit/convex/products/play.test.ts +++ b/packages/kit/convex/products/play.test.ts @@ -3,12 +3,44 @@ import { describe, expect, it } from "vitest"; import { basePlanIdForPeriod, + mapModernPlayOneTimeState, moneyToMicros, playPriceMicrosToNumber, shouldFallbackToLegacyOneTimeProduct, upsertModernAndroidOneTimeProduct, } from "./play"; +describe("mapModernPlayOneTimeState", () => { + it("maps the modern purchase-option lifecycle without assuming availability", () => { + expect(mapModernPlayOneTimeState([{ state: "DRAFT" }])).toBe("Draft"); + expect(mapModernPlayOneTimeState([{ state: "INACTIVE" }])).toBe("Removed"); + expect(mapModernPlayOneTimeState([{ state: "INACTIVE_PUBLISHED" }])).toBe( + "Removed", + ); + expect(mapModernPlayOneTimeState(undefined)).toBe("Removed"); + expect(mapModernPlayOneTimeState(null)).toBe("Removed"); + expect(mapModernPlayOneTimeState([])).toBe("Removed"); + expect(mapModernPlayOneTimeState([{ state: "STATE_UNSPECIFIED" }])).toBe( + "Removed", + ); + expect(mapModernPlayOneTimeState([{ state: "FUTURE_STATE" }])).toBe( + "Removed", + ); + }); + + it("keeps a product active when any purchase option remains active", () => { + expect( + mapModernPlayOneTimeState([{ state: "INACTIVE" }, { state: "ACTIVE" }]), + ).toBe("Active"); + }); + + it("treats mixed inactive and draft options as unavailable", () => { + expect( + mapModernPlayOneTimeState([{ state: "DRAFT" }, { state: "INACTIVE" }]), + ).toBe("Removed"); + }); +}); + describe("upsertModernAndroidOneTimeProduct", () => { it("uses the generated lowercase one-time-product PATCH route", async () => { let capturedRequest: Common.GaxiosOptions | undefined; diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts index f32fd1f0e..2b942837f 100644 --- a/packages/kit/convex/products/play.ts +++ b/packages/kit/convex/products/play.ts @@ -413,7 +413,7 @@ async function performAndroidSync( priceAmountMicros, currency: preferred?.currencyCode ?? undefined, storeRef: product.productId, - state: "Active", + state: mapModernPlayOneTimeState(product.purchaseOptions), }); pulled += 1; } @@ -1240,6 +1240,24 @@ function mapPlayStatus( } } +export function mapModernPlayOneTimeState( + purchaseOptions: Array<{ state?: string | null }> | null | undefined, +): "Draft" | "Active" | "Removed" { + const states = (purchaseOptions ?? []).map((option) => + option.state?.toUpperCase(), + ); + if (states.includes("ACTIVE")) return "Active"; + if (states.includes("INACTIVE") || states.includes("INACTIVE_PUBLISHED")) { + return "Removed"; + } + if (states.length > 0 && states.every((state) => state === "DRAFT")) { + return "Draft"; + } + // Missing or future states must not become a kit Draft: a `both` sync would + // treat that as an instruction to push and could make the option sellable. + return "Removed"; +} + function pickPlayTitle( product: androidpublisher_v3.Schema$InAppProduct, ): string | undefined { diff --git a/packages/kit/convex/products/sync.test.ts b/packages/kit/convex/products/sync.test.ts index b2cc606b0..7b9d09e02 100644 --- a/packages/kit/convex/products/sync.test.ts +++ b/packages/kit/convex/products/sync.test.ts @@ -6,6 +6,7 @@ import { deleteRemovedProductRow as registeredDeleteRemovedProductRow, isSafePriceAmountMicros, listDraftIosProducts as registeredListDraftIosProducts, + listRemovedAndroidProducts as registeredListRemovedAndroidProducts, markPushed as registeredMarkPushed, shouldPreserveKitRemovedDuringPull, upsertFromStore as registeredUpsertFromStore, @@ -18,6 +19,9 @@ const deleteRemovedProductRow = testableFunction( ); const upsertFromStore = testableFunction(registeredUpsertFromStore); const listDraftIosProducts = testableFunction(registeredListDraftIosProducts); +const listRemovedAndroidProducts = testableFunction( + registeredListRemovedAndroidProducts, +); const markPushed = testableFunction(registeredMarkPushed); type Row = Record & { _id: string }; @@ -128,6 +132,103 @@ describe("shouldPreserveKitRemovedDuringPull", () => { }); }); +describe("upsertFromStore removal provenance", () => { + function writableDb(product: Row) { + return new TestDb({ + organizations: [{ _id: "organization_a", pendingDeletion: false }], + projects: [ + { + _id: "project_a", + organizationId: "organization_a", + pendingDeletion: false, + }, + ], + products: [product], + }); + } + + it("marks a store-reported removal as store-authored", async () => { + const product = { + _id: "product_a", + projectId: "project_a", + platform: "Android", + productId: "legacy_bundle", + type: "NonConsumable", + title: "Legacy bundle", + state: "Active", + origin: "kit", + storeRef: "legacy_bundle", + updatedAt: 1, + }; + const db = writableDb(product); + + await upsertFromStore._handler( + { db }, + { + projectId: "project_a" as never, + productId: "legacy_bundle", + platform: "Android", + type: "NonConsumable", + title: "Legacy bundle", + storeRef: "legacy_bundle", + state: "Removed", + }, + ); + + expect(product).toMatchObject({ state: "Removed", origin: "store" }); + await expect( + listRemovedAndroidProducts._handler( + { db }, + { projectId: "project_a" as never }, + ), + ).resolves.toEqual([]); + }); + + it("preserves an explicit kit removal for the push phase", async () => { + const product = { + _id: "product_a", + projectId: "project_a", + platform: "Android", + productId: "legacy_bundle", + type: "NonConsumable", + title: "Legacy bundle", + state: "Removed", + origin: "kit", + storeRef: "legacy_bundle", + updatedAt: 1, + }; + const db = writableDb(product); + + await upsertFromStore._handler( + { db }, + { + projectId: "project_a" as never, + productId: "legacy_bundle", + platform: "Android", + type: "NonConsumable", + title: "Legacy bundle", + storeRef: "legacy_bundle", + state: "Removed", + }, + ); + + expect(product).toMatchObject({ state: "Removed", origin: "kit" }); + await expect( + listRemovedAndroidProducts._handler( + { db }, + { projectId: "project_a" as never }, + ), + ).resolves.toEqual([ + { + productId: "legacy_bundle", + platform: "Android", + type: "NonConsumable", + storeRef: "legacy_bundle", + }, + ]); + }); +}); + describe("listDraftIosProducts review resumption", () => { it("includes Ready rows that have not handled the configured screenshot", async () => { const base = { diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts index 6a0f1e4e2..ebe3c7833 100644 --- a/packages/kit/convex/products/sync.ts +++ b/packages/kit/convex/products/sync.ts @@ -189,13 +189,16 @@ export const upsertFromStore = internalMutation({ offers: args.offers, syncedAt: now, updatedAt: now, - // Preserve `origin` on UPDATE — only set it if absent - // (back-fill for rows created before this field existed). - // A pulled row touched later by `upsertProduct` keeps - // `origin: "store"`; a kit row touched by pull-sync keeps - // `origin: "kit"`. The push filter then correctly excludes - // pulled-from-store rows even after they're re-touched. - ...(existing.origin === undefined ? { origin: "store" as const } : {}), + // A store-reported removal is upstream state, not a new kit-authored + // delete request. Reclassify it so the push half of a `both` job does + // not attempt to delete the already-unavailable resource. An explicit + // kit removal returned above before reaching this patch and therefore + // keeps its deletion intent. Preserve origin for all other updates. + ...(args.state === "Removed" + ? { origin: "store" as const } + : existing.origin === undefined + ? { origin: "store" as const } + : {}), }); return existing._id; }