1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";2 3import { HarmonizedPurchaseState } from "../purchases/purchaseState";4import { hmacSha256Hex } from "../utils/sha256";5import {6 applySubscriptionEventHandler,7 bindSubscriptionToUserHandler,8 rebindSubscriptionToUserHandler,9 drainSubscriptionUserErasurePage,10 buildVerifiedSubscriptionSnapshot,11 getCurrentProductIdByTokenHandler,12 getSourceProductIdByTokenHandler,13 mergeVerifiedSubscriptionSnapshot,14 recordVerifiedSubscriptionHandler,15} from "./internal";16 17type Row = Record<string, unknown> & { _id: string; _creationTime: number };18 19class MemQuery {20 constructor(private rows: Row[]) {}21 22 withIndex(_name: string, cb?: (q: IndexBuilder) => IndexBuilder): MemQuery {23 if (!cb) return this;24 const builder = new IndexBuilder();25 cb(builder);26 return new MemQuery(27 this.rows.filter((row) =>28 builder.predicates.every((predicate) => predicate(row)),29 ),30 );31 }32 33 async unique(): Promise<Row | null> {34 if (this.rows.length > 1) {35 throw new Error("unique() called on a query that returned > 1 row");36 }37 return this.rows[0] ?? null;38 }39 40 async collect(): Promise<Row[]> {41 return [...this.rows];42 }43 44 async take(count: number): Promise<Row[]> {45 return this.rows.slice(0, count);46 }47}48 49class IndexBuilder {50 predicates: Array<(row: Row) => boolean> = [];51 52 eq(field: string, value: unknown): IndexBuilder {53 this.predicates.push((row) => row[field] === value);54 return this;55 }56}57 58class MemDb {59 tables = new Map<string, Map<string, Row>>();60 private counter = 0;61 62 constructor() {63 this.table("organizations").set("organizations_seed_1", {64 _id: "organizations_seed_1",65 _creationTime: Date.now(),66 });67 this.table("projects").set("projects_seed_1", {68 _id: "projects_seed_1",69 _creationTime: Date.now(),70 organizationId: "organizations_seed_1",71 });72 }73 74 private table(name: string): Map<string, Row> {75 let table = this.tables.get(name);76 if (!table) {77 table = new Map();78 this.tables.set(name, table);79 }80 return table;81 }82 83 query(tableName: string): MemQuery {84 return new MemQuery([...this.table(tableName).values()]);85 }86 87 async insert(88 tableName: string,89 doc: Record<string, unknown>,90 ): Promise<string> {91 const id = `${tableName}_${++this.counter}`;92 this.table(tableName).set(id, {93 ...doc,94 _id: id,95 _creationTime: Date.now() + this.counter / 1_000,96 });97 return id;98 }99 100 async get(id: string): Promise<Row | null> {101 for (const table of this.tables.values()) {102 const row = table.get(id);103 if (row) return row;104 }105 return null;106 }107 108 async patch(id: string, patch: Record<string, unknown>): Promise<void> {109 for (const table of this.tables.values()) {110 const row = table.get(id);111 if (row) {112 Object.assign(row, patch);113 return;114 }115 }116 throw new Error(`patch: no doc with id ${id}`);117 }118 119 async delete(id: string): Promise<void> {120 for (const table of this.tables.values()) {121 if (table.delete(id)) return;122 }123 }124 125 rows(tableName: string): Row[] {126 return [...this.table(tableName).values()];127 }128 129 seedProduct(doc: {130 projectId: string;131 platform: "IOS" | "Android";132 productId: string;133 billingPeriod?: string;134 }): void {135 this.table("products").set(`products_${++this.counter}`, {136 _id: `products_${this.counter}`,137 _creationTime: Date.now(),138 ...doc,139 });140 }141}142 143function makeCtx(db: MemDb) {144 return { db } as unknown as Parameters<145 typeof recordVerifiedSubscriptionHandler146 >[0];147}148 149const PROJECT_ID = "projects_seed_1";150const TOKEN = "purchase_token_1";151 152describe("entitlement events across the expiry deadline", () => {153 it.each([false, true])(154 "revokes once when the clock expires before notifications (cancel first: %s)",155 async (cancelFirst) => {156 vi.useFakeTimers();157 try {158 vi.setSystemTime(1_000);159 const db = new MemDb();160 const started = await seedWebhookEvent(db, {161 type: "SubscriptionStarted",162 notificationId: "clock-start",163 occurredAt: 1_000,164 });165 await db.patch(started, { expiresAt: 2_000, renewsAt: 2_000 });166 await applySubscriptionEventHandler(makeCtx(db), {167 projectId: PROJECT_ID as never,168 eventId: started as never,169 });170 await bindSubscriptionToUserHandler(makeCtx(db), {171 projectId: PROJECT_ID as never,172 purchaseToken: TOKEN,173 userId: "clock-user",174 });175 vi.setSystemTime(3_000);176 if (cancelFirst) {177 const canceled = await seedWebhookEvent(db, {178 type: "SubscriptionStarted",179 notificationId: "clock-cancel",180 occurredAt: 2_500,181 });182 await db.patch(canceled, {183 type: "SubscriptionCanceled",184 expiresAt: 2_000,185 renewsAt: undefined,186 willRenew: false,187 });188 await applySubscriptionEventHandler(makeCtx(db), {189 projectId: PROJECT_ID as never,190 eventId: canceled as never,191 });192 }193 const expired = await seedWebhookEvent(db, {194 type: "SubscriptionExpired",195 notificationId: "clock-expire",196 occurredAt: 3_000,197 });198 await db.patch(expired, { expiresAt: 2_000 });199 const args = {200 projectId: PROJECT_ID as never,201 eventId: expired as never,202 };203 await applySubscriptionEventHandler(makeCtx(db), args);204 await applySubscriptionEventHandler(makeCtx(db), args);205 expect(206 db207 .rows("commerceEvents")208 .filter((row) => String(row.eventType).startsWith("entitlement."))209 .map((row) => row.eventType),210 ).toEqual(["entitlement.granted", "entitlement.revoked"]);211 } finally {212 vi.useRealTimers();213 }214 },215 );216});217 218async function seedWebhookEvent(219 db: MemDb,220 args: {221 type:222 | "SubscriptionStarted"223 | "SubscriptionRenewed"224 | "SubscriptionExpired"225 | "SubscriptionProductChanged";226 notificationId: string;227 occurredAt: number;228 platform?: "IOS" | "Android";229 productId?: string;230 },231): Promise<string> {232 const platform = args.platform ?? "Android";233 return await db.insert("webhookEvents", {234 projectId: PROJECT_ID,235 type: args.type,236 source:237 platform === "IOS"238 ? "AppleAppStoreServerNotificationsV2"239 : "GooglePlayRealTimeDeveloperNotifications",240 platform,241 environment: "Sandbox",242 purchaseToken: TOKEN,243 sourceNotificationId: args.notificationId,244 productId: args.productId ?? "premium_monthly",245 subscriptionState:246 args.type === "SubscriptionExpired" ? "Expired" : "Active",247 expiresAt: 1_800_000_000_000,248 renewsAt: 1_800_000_000_000,249 currency: "USD",250 priceAmountMicros: 9_990_000,251 occurredAt: args.occurredAt,252 receivedAt: args.occurredAt,253 });254}255 256describe("subscription erasure across replacement tokens", () => {257 it.each([false, true])(258 "reconciles a granted successor without restoring erased identities (successor erasure requested: %s)",259 async (successorErased) => {260 const db = new MemDb();261 const ctx = makeCtx(db);262 const hashKey = "erasure-test-key";263 await db.patch(PROJECT_ID, { userErasureHashKey: hashKey });264 async function erasureJob(userId: string): Promise<string> {265 return db.insert("subscriptionUserErasureJobs", {266 projectId: PROJECT_ID,267 userId,268 userIdHash: await hmacSha256Hex(hashKey, userId),269 status: "queued",270 subscriptionsErased: 0,271 commerceEventsErased: 0,272 });273 }274 await recordVerifiedSubscriptionHandler(ctx, {275 projectId: PROJECT_ID as never,276 platform: "Android",277 purchaseToken: TOKEN,278 productId: "premium_monthly",279 purchaseState: "ENTITLED",280 });281 await bindSubscriptionToUserHandler(ctx, {282 projectId: PROJECT_ID as never,283 purchaseToken: TOKEN,284 userId: "erased-owner",285 });286 await drainSubscriptionUserErasurePage(287 ctx,288 (await erasureJob("erased-owner")) as never,289 );290 const startedId = await seedWebhookEvent(db, {291 type: "SubscriptionStarted",292 notificationId: "successor-granted",293 occurredAt: 1_000,294 });295 await db.patch(startedId, {296 purchaseToken: "replacement",297 expiresAt: Date.now() + 60_000,298 });299 await applySubscriptionEventHandler(ctx, {300 projectId: PROJECT_ID as never,301 eventId: startedId as never,302 });303 await bindSubscriptionToUserHandler(ctx, {304 projectId: PROJECT_ID as never,305 purchaseToken: "replacement",306 userId: "successor-owner",307 });308 const grant = db309 .rows("commerceEvents")310 .find((event) => event.eventType === "entitlement.granted");311 expect(grant?.userId).toBe("successor-owner");312 313 const successorErasureJob = successorErased314 ? await erasureJob("successor-owner")315 : undefined;316 // A destination exists so an emitted revocation would be delivered;317 // none is, which is the point of the assertions below.318 await db.insert("outboundDestinations", {319 projectId: PROJECT_ID,320 enabled: true,321 eventTypes: ["entitlement.revoked"],322 });323 const linkedId = await seedWebhookEvent(db, {324 type: "SubscriptionProductChanged",325 notificationId: "late-erased-link",326 occurredAt: 2_000,327 });328 await db.patch(linkedId, {329 purchaseToken: "replacement",330 linkedPurchaseToken: TOKEN,331 });332 const args = {333 projectId: PROJECT_ID as never,334 eventId: linkedId as never,335 };336 await applySubscriptionEventHandler(ctx, args);337 await applySubscriptionEventHandler(ctx, args);338 339 // The erased owner was unlinked, not the subscription. The successor340 // bound the replacement token afterwards, so that live association341 // survives the merge and nothing is revoked.342 expect(db.rows("subscriptions")).toHaveLength(1);343 expect(db.rows("subscriptions")[0].accountErased).toBeUndefined();344 expect(db.rows("subscriptions")[0].userId).toBe("successor-owner");345 expect(346 db347 .rows("commerceEvents")348 .filter((event) => event.eventType === "entitlement.revoked"),349 ).toEqual([]);350 expect(db.rows("outboundDeliveries")).toEqual([]);351 if (successorErased) {352 await drainSubscriptionUserErasurePage(353 ctx,354 successorErasureJob as never,355 );356 expect(357 db.rows("commerceEvents").some((event) => event.userId !== undefined),358 ).toBe(false);359 }360 expect(361 JSON.stringify(362 [...db.tables.values()].map((rows) => [...rows.values()]),363 ),364 ).not.toContain("erased-owner");365 },366 );367 368 it.each([369 { erasedToken: TOKEN, successorExists: false, boundToken: undefined },370 { erasedToken: TOKEN, successorExists: true, boundToken: undefined },371 { erasedToken: TOKEN, successorExists: true, boundToken: "replacement" },372 { erasedToken: "replacement", successorExists: true, boundToken: TOKEN },373 ])(374 "merges an erased record without dropping a live binding: %j",375 async (testCase) => {376 const db = new MemDb();377 const ctx = makeCtx(db);378 for (const purchaseToken of testCase.successorExists379 ? [TOKEN, "replacement"]380 : [TOKEN]) {381 const id = await recordVerifiedSubscriptionHandler(ctx, {382 projectId: PROJECT_ID as never,383 platform: "Android",384 purchaseToken,385 productId: "premium_monthly",386 purchaseState: "ENTITLED",387 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",388 });389 if (purchaseToken === testCase.erasedToken)390 await db.patch(id!, { accountErased: true });391 if (purchaseToken === testCase.boundToken)392 await bindSubscriptionToUserHandler(ctx, {393 projectId: PROJECT_ID as never,394 purchaseToken,395 userId: "linked-owner",396 });397 }398 const eventId = await seedWebhookEvent(db, {399 type: "SubscriptionStarted",400 notificationId: "erased-replacement",401 occurredAt: Date.now(),402 });403 await db.patch(eventId, {404 purchaseToken: "replacement",405 linkedPurchaseToken: TOKEN,406 });407 await applySubscriptionEventHandler(ctx, {408 projectId: PROJECT_ID as never,409 eventId: eventId as never,410 });411 412 expect(db.rows("subscriptions")).toHaveLength(1);413 const merged = db.rows("subscriptions")[0];414 expect(merged.purchaseToken).toBe("replacement");415 // The merge must not attribute the record to anyone who did not bind it,416 // and it must not revoke the binder who did.417 expect(418 db419 .rows("commerceEvents")420 .every(421 (event) =>422 event.userId === undefined || event.userId === "linked-owner",423 ),424 ).toBe(true);425 expect(426 db427 .rows("commerceEvents")428 .some((event) => event.eventType === "entitlement.revoked"),429 ).toBe(false);430 if (testCase.boundToken) {431 // A live binding on either side is a later association than the erasure,432 // so it survives and the marker does not carry.433 expect(merged.userId).toBe("linked-owner");434 expect(merged.accountErased).toBeUndefined();435 } else {436 expect(merged.userId).toBeUndefined();437 expect(merged.accountErased).toBe(true);438 // Unowned again, so the record can be associated with someone new.439 expect(440 await bindSubscriptionToUserHandler(ctx, {441 projectId: PROJECT_ID as never,442 purchaseToken: "replacement",443 userId: "new-owner",444 }),445 ).not.toBeNull();446 expect(db.rows("subscriptions")[0].accountErased).toBeUndefined();447 }448 },449 );450});451 452describe("bindSubscriptionToUser amount handling", () => {453 it("does not repeat an amount the webhook already reported", async () => {454 const db = new MemDb();455 db.seedProduct({456 projectId: PROJECT_ID,457 platform: "Android",458 productId: "premium_monthly",459 billingPeriod: "P1M",460 });461 const eventId = await seedWebhookEvent(db, {462 type: "SubscriptionStarted",463 notificationId: "message-webhook-first",464 occurredAt: 1_000,465 });466 await applySubscriptionEventHandler(makeCtx(db), {467 projectId: PROJECT_ID as never,468 eventId: eventId as never,469 });470 const afterWebhook = db471 .rows("commerceEvents")472 .filter((row) => row.amountMicros !== undefined).length;473 474 await bindSubscriptionToUserHandler(makeCtx(db), {475 projectId: PROJECT_ID as never,476 purchaseToken: TOKEN,477 userId: "user_1",478 });479 480 const priced = db481 .rows("commerceEvents")482 .filter((row) => row.amountMicros !== undefined);483 // The bind grant correlates an existing purchase to a user; it is not a484 // second billing, so the count must not move.485 expect(priced.length).toBe(afterWebhook);486 expect(db.rows("commerceEvents").at(-1)?.eventType).toBe(487 "entitlement.granted",488 );489 expect(db.rows("commerceEvents").at(-1)?.amountMicros).toBeUndefined();490 });491});492 493describe("applySubscriptionEventHandler", () => {494 beforeEach(() => {495 vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));496 });497 498 afterEach(() => {499 vi.useRealTimers();500 });501 502 // Commerce Protocol's `whenNoPriorStoreEvent` turns on store history, not on503 // whether a record exists — a purchase learned from a client receipt but504 // never from the store still begins the story. That distinction lives here,505 // in the override, and nowhere in the state machine, so it is tested here.506 it("starts rather than recovers when a record exists with no store history", async () => {507 const db = new MemDb();508 db.seedProduct({509 projectId: PROJECT_ID,510 platform: "Android",511 productId: "premium_monthly",512 billingPeriod: "P1M",513 });514 // A client receipt created this row; no store notification ever touched it,515 // so it carries no lastEventId.516 await db.insert("subscriptions", {517 projectId: PROJECT_ID,518 platform: "Android",519 purchaseToken: TOKEN,520 productId: "premium_monthly",521 state: "Unknown",522 willRenew: true,523 updatedAt: 0,524 });525 const eventId = await seedWebhookEvent(db, {526 type: "SubscriptionStarted",527 notificationId: "message-first-store-event",528 occurredAt: 1_000,529 });530 531 await applySubscriptionEventHandler(makeCtx(db), {532 projectId: PROJECT_ID as never,533 eventId: eventId as never,534 });535 // Assert on what was emitted, not what the handler returned: the override536 // applies to the commerce event, while the return value carries the state537 // machine's own transition.538 expect(db.rows("commerceEvents").map((row) => row.eventType)).toContain(539 "subscription.started",540 );541 });542 543 it.each([["SubscriptionPriceChange"], ["SubscriptionDeferred"]])(544 "emits no commerce event when %s is the first store event for a receipt-bootstrapped row",545 async (storeEventType) => {546 // store-event-mapping.json pins whenNoPriorStoreEvent -> event: null for547 // price changes and deferrals: with no earlier store event there is no548 // baseline the event could describe.549 const db = new MemDb();550 db.seedProduct({551 projectId: PROJECT_ID,552 platform: "Android",553 productId: "premium_monthly",554 billingPeriod: "P1M",555 });556 await db.insert("subscriptions", {557 projectId: PROJECT_ID,558 platform: "Android",559 purchaseToken: TOKEN,560 productId: "premium_monthly",561 state: "Active",562 expiresAt: 9_999_999_999_999,563 willRenew: true,564 updatedAt: 0,565 });566 const eventId = await seedWebhookEvent(db, {567 type: storeEventType as never,568 notificationId: `message-${storeEventType}`,569 occurredAt: 1_000,570 });571 572 await applySubscriptionEventHandler(makeCtx(db), {573 projectId: PROJECT_ID as never,574 eventId: eventId as never,575 });576 expect(db.rows("commerceEvents")).toEqual([]);577 },578 );579 580 // Rows written before `lastEventOccurredAt` existed carry only `lastEventId`,581 // so the timestamp guard cannot judge them. Both of its fallbacks protect a582 // money path: a redelivery re-applies the transition and books it twice.583 it.each([584 ["the same event redelivered", "message-legacy", 500, "Expired"],585 ["an older event arriving late", "message-older", 200, "Expired"],586 ])(587 "drops %s against a legacy row",588 async (_name, notificationId, occurredAt, expected) => {589 const db = new MemDb();590 db.seedProduct({591 projectId: PROJECT_ID,592 platform: "Android",593 productId: "premium_monthly",594 billingPeriod: "P1M",595 });596 const priorId = await seedWebhookEvent(db, {597 type: "SubscriptionExpired",598 notificationId: "message-legacy",599 occurredAt: 500,600 });601 await db.insert("subscriptions", {602 projectId: PROJECT_ID,603 platform: "Android",604 purchaseToken: TOKEN,605 productId: "premium_monthly",606 state: "Expired",607 willRenew: false,608 lastEventId: priorId,609 updatedAt: 0,610 });611 const replayId =612 notificationId === "message-legacy"613 ? priorId614 : await seedWebhookEvent(db, {615 type: "SubscriptionStarted",616 notificationId,617 occurredAt,618 });619 620 await expect(621 applySubscriptionEventHandler(makeCtx(db), {622 projectId: PROJECT_ID as never,623 eventId: replayId as never,624 }),625 ).resolves.toMatchObject({ transition: null });626 expect(db.rows("subscriptions")).toMatchObject([{ state: expected }]);627 expect(db.rows("commerceEvents")).toEqual([]);628 },629 );630 631 it("recovers when the record already has store history", async () => {632 const db = new MemDb();633 db.seedProduct({634 projectId: PROJECT_ID,635 platform: "Android",636 productId: "premium_monthly",637 billingPeriod: "P1M",638 });639 const priorId = await seedWebhookEvent(db, {640 type: "SubscriptionExpired",641 notificationId: "message-prior",642 occurredAt: 500,643 });644 await db.insert("subscriptions", {645 projectId: PROJECT_ID,646 platform: "Android",647 purchaseToken: TOKEN,648 productId: "premium_monthly",649 state: "Expired",650 willRenew: false,651 lastEventId: priorId,652 updatedAt: 0,653 });654 const eventId = await seedWebhookEvent(db, {655 type: "SubscriptionStarted",656 notificationId: "message-after-history",657 occurredAt: 1_000,658 });659 660 await applySubscriptionEventHandler(makeCtx(db), {661 projectId: PROJECT_ID as never,662 eventId: eventId as never,663 });664 expect(db.rows("commerceEvents").map((row) => row.eventType)).toContain(665 "subscription.recovered",666 );667 });668 669 it("applies a recorded-but-unapplied event exactly once on redelivery", async () => {670 const db = new MemDb();671 db.seedProduct({672 projectId: PROJECT_ID,673 platform: "Android",674 productId: "premium_monthly",675 billingPeriod: "P1M",676 });677 const eventId = await seedWebhookEvent(db, {678 type: "SubscriptionStarted",679 notificationId: "message-a",680 occurredAt: 1_000,681 });682 const args = {683 projectId: PROJECT_ID as never,684 eventId: eventId as never,685 };686 687 await expect(688 applySubscriptionEventHandler(makeCtx(db), args),689 ).resolves.toMatchObject({ transition: "Started", active: true });690 const appliedAt = db.rows("webhookEvents")[0]?.appliedAt;691 692 await expect(693 applySubscriptionEventHandler(makeCtx(db), args),694 ).resolves.toMatchObject({ transition: null, active: true });695 expect(db.rows("webhookEvents")[0]?.appliedAt).toBe(appliedAt);696 expect(db.rows("subscriptions")).toMatchObject([697 { state: "Active", lastEventId: eventId },698 ]);699 expect(db.rows("subscriptionStats")).toMatchObject([700 { activeSubs: 1, mrrMicros: 9_990_000 },701 ]);702 });703 704 it("applies distinct same-timestamp events without replaying the old one", async () => {705 const db = new MemDb();706 db.seedProduct({707 projectId: PROJECT_ID,708 platform: "Android",709 productId: "premium_monthly",710 billingPeriod: "P1M",711 });712 const startedId = await seedWebhookEvent(db, {713 type: "SubscriptionStarted",714 notificationId: "message-a",715 occurredAt: 1_000,716 });717 const expiredId = await seedWebhookEvent(db, {718 type: "SubscriptionExpired",719 notificationId: "message-b",720 occurredAt: 1_000,721 });722 723 await applySubscriptionEventHandler(makeCtx(db), {724 projectId: PROJECT_ID as never,725 eventId: startedId as never,726 });727 await applySubscriptionEventHandler(makeCtx(db), {728 projectId: PROJECT_ID as never,729 eventId: expiredId as never,730 });731 await expect(732 applySubscriptionEventHandler(makeCtx(db), {733 projectId: PROJECT_ID as never,734 eventId: startedId as never,735 }),736 ).resolves.toMatchObject({ transition: null, active: false });737 738 expect(db.rows("subscriptions")).toMatchObject([739 { state: "Expired", lastEventId: expiredId },740 ]);741 expect(db.rows("subscriptionStats")).toMatchObject([742 { activeSubs: 0, mrrMicros: 0 },743 ]);744 });745 746 it("uses ingestion order to backfill a same-timestamp legacy event", async () => {747 const db = new MemDb();748 db.seedProduct({749 projectId: PROJECT_ID,750 platform: "Android",751 productId: "premium_monthly",752 billingPeriod: "P1M",753 });754 const startedId = await seedWebhookEvent(db, {755 type: "SubscriptionStarted",756 notificationId: "legacy-a",757 occurredAt: 1_000,758 });759 const expiredId = await seedWebhookEvent(db, {760 type: "SubscriptionExpired",761 notificationId: "legacy-b",762 occurredAt: 1_000,763 });764 await applySubscriptionEventHandler(makeCtx(db), {765 projectId: PROJECT_ID as never,766 eventId: startedId as never,767 });768 await applySubscriptionEventHandler(makeCtx(db), {769 projectId: PROJECT_ID as never,770 eventId: expiredId as never,771 });772 await db.patch(startedId, { appliedAt: undefined });773 774 await expect(775 applySubscriptionEventHandler(makeCtx(db), {776 projectId: PROJECT_ID as never,777 eventId: startedId as never,778 }),779 ).resolves.toMatchObject({ transition: null, active: false });780 781 expect(782 db.rows("webhookEvents").find((row) => row._id === startedId),783 ).toHaveProperty("appliedAt", Date.now());784 expect(db.rows("subscriptions")).toMatchObject([785 { state: "Expired", lastEventId: expiredId },786 ]);787 expect(db.rows("subscriptionStats")).toMatchObject([788 { activeSubs: 0, mrrMicros: 0 },789 ]);790 });791 792 it("keeps durable ordering after the previous webhook row is pruned", async () => {793 const db = new MemDb();794 const startedId = await seedWebhookEvent(db, {795 type: "SubscriptionStarted",796 notificationId: "newer",797 occurredAt: 2_000,798 });799 await applySubscriptionEventHandler(makeCtx(db), {800 projectId: PROJECT_ID as never,801 eventId: startedId as never,802 });803 await db.delete(startedId);804 const staleId = await seedWebhookEvent(db, {805 type: "SubscriptionExpired",806 notificationId: "older",807 occurredAt: 1_000,808 });809 810 await expect(811 applySubscriptionEventHandler(makeCtx(db), {812 projectId: PROJECT_ID as never,813 eventId: staleId as never,814 }),815 ).resolves.toMatchObject({ transition: null, active: true });816 expect(db.rows("subscriptions")).toMatchObject([817 {818 state: "Active",819 lastEventOccurredAt: 2_000,820 lastEventSourceNotificationId: "newer",821 },822 ]);823 });824 825 it("does not grant entitlement for an already-expired start event", async () => {826 const db = new MemDb();827 const eventId = await seedWebhookEvent(db, {828 type: "SubscriptionStarted",829 notificationId: "expired-start",830 occurredAt: 1_000,831 });832 await db.patch(eventId, { expiresAt: Date.now() - 1 });833 834 await expect(835 applySubscriptionEventHandler(makeCtx(db), {836 projectId: PROJECT_ID as never,837 eventId: eventId as never,838 }),839 ).resolves.toMatchObject({ transition: "Started", active: false });840 expect(db.rows("commerceEvents").map((row) => row.eventType)).toEqual([841 "subscription.started",842 ]);843 });844 845 it("records one-time events without creating subscription commerce", async () => {846 const db = new MemDb();847 const eventId = await seedWebhookEvent(db, {848 type: "SubscriptionStarted",849 notificationId: "one-time",850 occurredAt: 1_000,851 });852 await db.patch(eventId, { productKind: "one_time" });853 854 await expect(855 applySubscriptionEventHandler(makeCtx(db), {856 projectId: PROJECT_ID as never,857 eventId: eventId as never,858 }),859 ).resolves.toEqual({ transition: null, active: false });860 expect(db.rows("subscriptions")).toHaveLength(0);861 expect(db.rows("commerceEvents")).toHaveLength(0);862 });863 864 it("keeps an Apple renewal preference separate from the active product", async () => {865 const db = new MemDb();866 db.seedProduct({867 projectId: PROJECT_ID,868 platform: "IOS",869 productId: "premium_monthly",870 billingPeriod: "P1M",871 });872 const startedId = await seedWebhookEvent(db, {873 type: "SubscriptionStarted",874 notificationId: "apple-started",875 occurredAt: 1_000,876 platform: "IOS",877 });878 await applySubscriptionEventHandler(makeCtx(db), {879 projectId: PROJECT_ID as never,880 eventId: startedId as never,881 });882 const changedId = await seedWebhookEvent(db, {883 type: "SubscriptionProductChanged",884 notificationId: "apple-next-product",885 occurredAt: 2_000,886 platform: "IOS",887 productId: "premium_yearly",888 });889 await db.patch(changedId, { priceAmountMicros: 99_990_000 });890 891 await expect(892 applySubscriptionEventHandler(makeCtx(db), {893 projectId: PROJECT_ID as never,894 eventId: changedId as never,895 }),896 ).resolves.toMatchObject({ transition: "ProductChanged", active: true });897 expect(db.rows("subscriptions")).toMatchObject([898 {899 productId: "premium_monthly",900 platform: "IOS",901 priceAmountMicros: 9_990_000,902 },903 ]);904 expect(db.rows("commerceEvents").at(-1)).toMatchObject({905 eventType: "subscription.product_changed",906 productId: "premium_yearly",907 amountMicros: 99_990_000,908 subscription: { productId: "premium_monthly" },909 });910 await db.delete(changedId);911 912 await bindSubscriptionToUserHandler(makeCtx(db), {913 projectId: PROJECT_ID as never,914 purchaseToken: TOKEN,915 userId: "user-1",916 });917 expect(db.rows("commerceEvents").at(-1)).toMatchObject({918 eventType: "entitlement.granted",919 productId: "premium_monthly",920 userId: "user-1",921 });922 expect(db.rows("commerceEvents").at(-1)?.amountMicros).toBeUndefined();923 924 const renewedId = await seedWebhookEvent(db, {925 type: "SubscriptionRenewed",926 notificationId: "apple-renewed-on-new-product",927 occurredAt: 3_000,928 platform: "IOS",929 productId: "premium_yearly",930 });931 await applySubscriptionEventHandler(makeCtx(db), {932 projectId: PROJECT_ID as never,933 eventId: renewedId as never,934 });935 expect(db.rows("commerceEvents").at(-1)).toMatchObject({936 eventType: "subscription.renewed",937 productId: "premium_yearly",938 previousProductId: "premium_monthly",939 userId: "user-1",940 });941 });942 943 it("applies an Apple upgrade product immediately", async () => {944 const db = new MemDb();945 const startedId = await seedWebhookEvent(db, {946 type: "SubscriptionStarted",947 notificationId: "apple-started",948 occurredAt: 1_000,949 platform: "IOS",950 });951 await applySubscriptionEventHandler(makeCtx(db), {952 projectId: PROJECT_ID as never,953 eventId: startedId as never,954 });955 const changedId = await seedWebhookEvent(db, {956 type: "SubscriptionProductChanged",957 notificationId: "apple-upgrade",958 occurredAt: 2_000,959 platform: "IOS",960 productId: "premium_yearly",961 });962 await db.patch(changedId, { effectiveImmediately: true });963 964 await applySubscriptionEventHandler(makeCtx(db), {965 projectId: PROJECT_ID as never,966 eventId: changedId as never,967 });968 969 expect(db.rows("subscriptions")[0]).toMatchObject({970 productId: "premium_yearly",971 lastEventId: changedId,972 });973 });974 975 it("includes the previous product for an applied Google item change", async () => {976 const db = new MemDb();977 const startedId = await seedWebhookEvent(db, {978 type: "SubscriptionStarted",979 notificationId: "google-started",980 occurredAt: 1_000,981 });982 await applySubscriptionEventHandler(makeCtx(db), {983 projectId: PROJECT_ID as never,984 eventId: startedId as never,985 });986 const changedId = await seedWebhookEvent(db, {987 type: "SubscriptionProductChanged",988 notificationId: "google-item-change",989 occurredAt: 2_000,990 productId: "premium_yearly",991 });992 await applySubscriptionEventHandler(makeCtx(db), {993 projectId: PROJECT_ID as never,994 eventId: changedId as never,995 });996 997 expect(db.rows("commerceEvents").at(-1)).toMatchObject({998 eventType: "subscription.product_changed",999 previousProductId: "premium_monthly",1000 productId: "premium_yearly",1001 subscription: { productId: "premium_yearly" },1002 });1003 });1004 1005 it("does not guess a product for a multi-item Google change", async () => {1006 const db = new MemDb();1007 const startedId = await seedWebhookEvent(db, {1008 type: "SubscriptionStarted",1009 notificationId: "google-bundle-started",1010 occurredAt: 1_000,1011 });1012 await applySubscriptionEventHandler(makeCtx(db), {1013 projectId: PROJECT_ID as never,1014 eventId: startedId as never,1015 });1016 const changedId = await db.insert("webhookEvents", {1017 projectId: PROJECT_ID,1018 type: "SubscriptionProductChanged",1019 source: "GooglePlayRealTimeDeveloperNotifications",1020 platform: "Android",1021 environment: "Production",1022 purchaseToken: TOKEN,1023 productKind: "subscription",1024 sourceNotificationId: "google-bundle-change",1025 occurredAt: 2_000,1026 receivedAt: 2_000,1027 });1028 1029 await expect(1030 applySubscriptionEventHandler(makeCtx(db), {1031 projectId: PROJECT_ID as never,1032 eventId: changedId as never,1033 }),1034 ).resolves.toMatchObject({ transition: null, active: true });1035 expect(db.rows("subscriptions")[0]).toMatchObject({1036 productId: "premium_monthly",1037 lastEventId: changedId,1038 lastEventOccurredAt: 2_000,1039 });1040 expect(db.rows("commerceEvents")).toHaveLength(1);1041 1042 const delayedId = await seedWebhookEvent(db, {1043 type: "SubscriptionExpired",1044 notificationId: "google-delayed-expiry",1045 occurredAt: 1_500,1046 });1047 await expect(1048 applySubscriptionEventHandler(makeCtx(db), {1049 projectId: PROJECT_ID as never,1050 eventId: delayedId as never,1051 }),1052 ).resolves.toMatchObject({ transition: null, active: true });1053 expect(db.rows("subscriptions")[0]).toMatchObject({1054 state: "Active",1055 lastEventId: changedId,1056 });1057 });1058 1059 it("moves a Google replacement flow onto the linked token row", async () => {1060 const db = new MemDb();1061 const startedId = await seedWebhookEvent(db, {1062 type: "SubscriptionStarted",1063 notificationId: "google-old-token",1064 occurredAt: 1_000,1065 });1066 await applySubscriptionEventHandler(makeCtx(db), {1067 projectId: PROJECT_ID as never,1068 eventId: startedId as never,1069 });1070 const changedId = await db.insert("webhookEvents", {1071 projectId: PROJECT_ID,1072 type: "SubscriptionProductChanged",1073 source: "GooglePlayRealTimeDeveloperNotifications",1074 platform: "Android",1075 environment: "Production",1076 purchaseToken: "purchase_token_2",1077 linkedPurchaseToken: TOKEN,1078 productKind: "subscription",1079 productId: "premium_yearly",1080 subscriptionState: "Active",1081 sourceNotificationId: "google-new-token",1082 occurredAt: 2_000,1083 receivedAt: 2_000,1084 });1085 await applySubscriptionEventHandler(makeCtx(db), {1086 projectId: PROJECT_ID as never,1087 eventId: changedId as never,1088 });1089 1090 expect(db.rows("subscriptions")).toHaveLength(1);1091 expect(db.rows("subscriptions")[0]).toMatchObject({1092 purchaseToken: "purchase_token_2",1093 productId: "premium_yearly",1094 });1095 expect(db.rows("commerceEvents").at(-1)).toMatchObject({1096 eventType: "subscription.product_changed",1097 previousProductId: "premium_monthly",1098 productId: "premium_yearly",1099 });1100 });1101 1102 it("classifies a linked Google purchase with a new product as a change", async () => {1103 const db = new MemDb();1104 const startedId = await seedWebhookEvent(db, {1105 type: "SubscriptionStarted",1106 notificationId: "google-linked-start-old",1107 occurredAt: 1_000,1108 });1109 await applySubscriptionEventHandler(makeCtx(db), {1110 projectId: PROJECT_ID as never,1111 eventId: startedId as never,1112 });1113 const replacementId = await db.insert("webhookEvents", {1114 projectId: PROJECT_ID,1115 type: "SubscriptionStarted",1116 source: "GooglePlayRealTimeDeveloperNotifications",1117 platform: "Android",1118 environment: "Production",1119 purchaseToken: "purchase_token_2",1120 linkedPurchaseToken: TOKEN,1121 productKind: "subscription",1122 productId: "premium_yearly",1123 subscriptionState: "Active",1124 sourceNotificationId: "google-linked-start-new",1125 occurredAt: 2_000,1126 receivedAt: 2_000,1127 });1128 1129 await expect(1130 applySubscriptionEventHandler(makeCtx(db), {1131 projectId: PROJECT_ID as never,1132 eventId: replacementId as never,1133 }),1134 ).resolves.toMatchObject({ transition: "ProductChanged", active: true });1135 1136 expect(1137 db1138 .rows("commerceEvents")1139 .filter((event) => event.sourceEventId === replacementId),1140 ).toEqual([1141 expect.objectContaining({1142 eventType: "subscription.product_changed",1143 previousProductId: "premium_monthly",1144 productId: "premium_yearly",1145 }),1146 ]);1147 });1148 1149 it("defers a same-product token handoff until the replacement renews", async () => {1150 const db = new MemDb();1151 const startedId = await seedWebhookEvent(db, {1152 type: "SubscriptionStarted",1153 notificationId: "google-deferred-start-old",1154 occurredAt: 1_000,1155 });1156 await applySubscriptionEventHandler(makeCtx(db), {1157 projectId: PROJECT_ID as never,1158 eventId: startedId as never,1159 });1160 const handoffId = await db.insert("webhookEvents", {1161 projectId: PROJECT_ID,1162 type: "SubscriptionStarted",1163 source: "GooglePlayRealTimeDeveloperNotifications",1164 platform: "Android",1165 environment: "Production",1166 purchaseToken: "purchase_token_2",1167 linkedPurchaseToken: TOKEN,1168 productKind: "subscription",1169 productId: "premium_monthly",1170 subscriptionState: "Active",1171 sourceNotificationId: "google-deferred-token-handoff",1172 occurredAt: 2_000,1173 receivedAt: 2_000,1174 });1175 await expect(1176 applySubscriptionEventHandler(makeCtx(db), {1177 projectId: PROJECT_ID as never,1178 eventId: handoffId as never,1179 }),1180 ).resolves.toMatchObject({ transition: null, active: true });1181 expect(1182 db1183 .rows("commerceEvents")1184 .filter((event) => event.sourceEventId === handoffId),1185 ).toEqual([]);1186 1187 const predecessorExpiryId = await db.insert("webhookEvents", {1188 projectId: PROJECT_ID,1189 type: "SubscriptionExpired",1190 source: "GooglePlayRealTimeDeveloperNotifications",1191 platform: "Android",1192 environment: "Production",1193 purchaseToken: TOKEN,1194 productKind: "subscription",1195 productId: "premium_monthly",1196 subscriptionState: "Expired",1197 expiresAt: 2_500,1198 sourceNotificationId: "google-deferred-predecessor-expired",1199 occurredAt: 2_500,1200 receivedAt: 2_500,1201 });1202 await expect(1203 applySubscriptionEventHandler(makeCtx(db), {1204 projectId: PROJECT_ID as never,1205 eventId: predecessorExpiryId as never,1206 }),1207 ).resolves.toMatchObject({ transition: null, active: true });1208 expect(db.rows("subscriptions")).toHaveLength(1);1209 expect(db.rows("subscriptions")[0]).toMatchObject({1210 purchaseToken: "purchase_token_2",1211 state: "Active",1212 });1213 expect(1214 db1215 .rows("commerceEvents")1216 .filter((event) => event.sourceEventId === predecessorExpiryId),1217 ).toEqual([]);1218 expect(db.rows("subscriptionTokenAliases")).toMatchObject([1219 {1220 purchaseToken: TOKEN,1221 successorPurchaseToken: "purchase_token_2",1222 },1223 ]);1224 1225 const successor = db.rows("subscriptions")[0];1226 const statsBeforeStaleVerification = structuredClone(1227 db.rows("subscriptionStats"),1228 );1229 await expect(1230 recordVerifiedSubscriptionHandler(makeCtx(db), {1231 projectId: PROJECT_ID as never,1232 platform: "Android",1233 purchaseToken: TOKEN,1234 productId: "stale_monthly",1235 purchaseState: "ENTITLED",1236 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",1237 expiresAt: 2_600,1238 willRenew: false,1239 }),1240 ).resolves.toBe(successor._id);1241 expect(db.rows("subscriptions")).toMatchObject([1242 {1243 _id: successor._id,1244 purchaseToken: "purchase_token_2",1245 productId: "premium_monthly",1246 state: "Active",1247 },1248 ]);1249 expect(db.rows("subscriptionStats")).toEqual(statsBeforeStaleVerification);1250 1251 await expect(1252 bindSubscriptionToUserHandler(makeCtx(db), {1253 projectId: PROJECT_ID as never,1254 purchaseToken: TOKEN,1255 userId: "user-after-handoff",1256 }),1257 ).resolves.toBe(successor._id);1258 expect(db.rows("subscriptions")).toMatchObject([1259 {1260 _id: successor._id,1261 purchaseToken: "purchase_token_2",1262 userId: "user-after-handoff",1263 },1264 ]);1265 expect(db.rows("subscriptions")).toHaveLength(1);1266 1267 const renewalId = await db.insert("webhookEvents", {1268 projectId: PROJECT_ID,1269 type: "SubscriptionRenewed",1270 source: "GooglePlayRealTimeDeveloperNotifications",1271 platform: "Android",1272 environment: "Production",1273 purchaseToken: "purchase_token_2",1274 linkedPurchaseToken: TOKEN,1275 productKind: "subscription",1276 productId: "premium_yearly",1277 subscriptionState: "Active",1278 sourceNotificationId: "google-deferred-first-renewal",1279 occurredAt: 3_000,1280 receivedAt: 3_000,1281 });1282 await applySubscriptionEventHandler(makeCtx(db), {1283 projectId: PROJECT_ID as never,1284 eventId: renewalId as never,1285 });1286 1287 expect(db.rows("subscriptions")[0]).toMatchObject({1288 purchaseToken: "purchase_token_2",1289 productId: "premium_yearly",1290 });1291 expect(1292 db1293 .rows("commerceEvents")1294 .filter((event) => event.sourceEventId === renewalId),1295 ).toEqual([1296 expect.objectContaining({1297 eventType: "subscription.renewed",1298 previousProductId: "premium_monthly",1299 productId: "premium_yearly",1300 }),1301 ]);1302 });1303 1304 it("resolves predecessor aliases beyond 32 replacement hops", async () => {1305 const db = new MemDb();1306 const hopCount = 40;1307 for (let index = 0; index < hopCount; index += 1) {1308 await db.insert("subscriptionTokenAliases", {1309 projectId: PROJECT_ID,1310 purchaseToken: `token_${index}`,1311 successorPurchaseToken: `token_${index + 1}`,1312 predecessorProductId:1313 index === 0 ? "premium_monthly" : "premium_yearly",1314 createdAt: index,1315 updatedAt: index,1316 });1317 }1318 const subscriptionId = await db.insert("subscriptions", {1319 projectId: PROJECT_ID,1320 purchaseToken: `token_${hopCount}`,1321 productKind: "subscription",1322 productId: "premium_yearly",1323 platform: "Android",1324 state: "Active",1325 expiresAt: 1_900_000_000_000,1326 willRenew: true,1327 startedAt: 1,1328 updatedAt: 1,1329 });1330 await db.insert("subscriptions", {1331 projectId: PROJECT_ID,1332 purchaseToken: "token_20",1333 productKind: "subscription",1334 productId: "stale_intermediate",1335 platform: "Android",1336 state: "Expired",1337 expiresAt: 2_000,1338 willRenew: false,1339 startedAt: 1,1340 updatedAt: 1,1341 });1342 const expiredId = await db.insert("webhookEvents", {1343 projectId: PROJECT_ID,1344 type: "SubscriptionExpired",1345 source: "GooglePlayRealTimeDeveloperNotifications",1346 platform: "Android",1347 environment: "Production",1348 purchaseToken: "token_0",1349 productKind: "subscription",1350 productId: "premium_monthly",1351 sourceNotificationId: "old-chain-expired",1352 occurredAt: 2_000,1353 receivedAt: 2_000,1354 });1355 1356 await expect(1357 getSourceProductIdByTokenHandler(makeCtx(db), {1358 projectId: PROJECT_ID as never,1359 purchaseToken: "token_0",1360 }),1361 ).resolves.toBe("premium_monthly");1362 await expect(1363 getCurrentProductIdByTokenHandler(makeCtx(db), {1364 projectId: PROJECT_ID as never,1365 purchaseToken: "token_0",1366 }),1367 ).resolves.toBe("premium_yearly");1368 1369 await expect(1370 applySubscriptionEventHandler(makeCtx(db), {1371 projectId: PROJECT_ID as never,1372 eventId: expiredId as never,1373 }),1374 ).resolves.toEqual({1375 transition: null,1376 active: true,1377 subscriptionId,1378 });1379 expect(db.rows("subscriptions")).toHaveLength(2);1380 expect(1381 db.rows("subscriptions").find((row) => row._id === subscriptionId),1382 ).toMatchObject({1383 purchaseToken: `token_${hopCount}`,1384 productId: "premium_yearly",1385 state: "Active",1386 });1387 expect(db.rows("commerceEvents")).toEqual([]);1388 });1389 1390 it("emits predecessor refunds and revocations without deactivating the successor", async () => {1391 const db = new MemDb();1392 const subscriptionId = await db.insert("subscriptions", {1393 projectId: PROJECT_ID,1394 purchaseToken: "current_token",1395 productKind: "subscription",1396 productId: "premium_yearly",1397 platform: "Android",1398 state: "Active",1399 expiresAt: 1_900_000_000_000,1400 willRenew: true,1401 startedAt: 1,1402 updatedAt: 1,1403 });1404 await db.insert("subscriptionTokenAliases", {1405 projectId: PROJECT_ID,1406 purchaseToken: "old_token",1407 successorPurchaseToken: "current_token",1408 predecessorProductId: "premium_monthly",1409 createdAt: 1,1410 updatedAt: 1,1411 });1412 1413 const cases = [1414 ["PurchaseRefunded", "Refunded", "subscription.refunded"],1415 ["SubscriptionRevoked", "Revoked", "subscription.revoked"],1416 ] as const;1417 for (const [type, transition, eventType] of cases) {1418 const eventId = await db.insert("webhookEvents", {1419 projectId: PROJECT_ID,1420 type,1421 source: "GooglePlayRealTimeDeveloperNotifications",1422 platform: "Android",1423 environment: "Production",1424 purchaseToken: "old_token",1425 productKind: "subscription",1426 productId: "premium_monthly",1427 sourceNotificationId: `old-token-${type}`,1428 occurredAt: 2_000,1429 receivedAt: 2_000,1430 });1431 await expect(1432 applySubscriptionEventHandler(makeCtx(db), {1433 projectId: PROJECT_ID as never,1434 eventId: eventId as never,1435 }),1436 ).resolves.toEqual({ transition, active: true, subscriptionId });1437 expect(1438 db1439 .rows("commerceEvents")1440 .filter((event) => event.sourceEventId === eventId)1441 .map((event) => [1442 event.eventType,1443 event.productId,1444 (event.subscription as { state?: string } | undefined)?.state,1445 event.entitlementActive,1446 ]),1447 ).toEqual([[eventType, "premium_monthly", transition, false]]);1448 }1449 expect(db.rows("subscriptions")).toMatchObject([1450 {1451 purchaseToken: "current_token",1452 productId: "premium_yearly",1453 state: "Active",1454 },1455 ]);1456 });1457 1458 it("classifies a same-product linked prepaid extension as a renewal", async () => {1459 const db = new MemDb();1460 const startedId = await seedWebhookEvent(db, {1461 type: "SubscriptionStarted",1462 notificationId: "google-prepaid-start",1463 occurredAt: 1_000,1464 });1465 await applySubscriptionEventHandler(makeCtx(db), {1466 projectId: PROJECT_ID as never,1467 eventId: startedId as never,1468 });1469 const topUpId = await db.insert("webhookEvents", {1470 projectId: PROJECT_ID,1471 type: "SubscriptionStarted",1472 source: "GooglePlayRealTimeDeveloperNotifications",1473 platform: "Android",1474 environment: "Production",1475 purchaseToken: "purchase_token_2",1476 linkedPurchaseToken: TOKEN,1477 productKind: "subscription",1478 productId: "premium_monthly",1479 subscriptionState: "Active",1480 expiresAt: 1_900_000_000_000,1481 willRenew: false,1482 sourceNotificationId: "google-prepaid-top-up",1483 occurredAt: 2_000,1484 receivedAt: 2_000,1485 });1486 1487 await expect(1488 applySubscriptionEventHandler(makeCtx(db), {1489 projectId: PROJECT_ID as never,1490 eventId: topUpId as never,1491 }),1492 ).resolves.toMatchObject({ transition: "Renewed", active: true });1493 expect(1494 db1495 .rows("commerceEvents")1496 .filter((event) => event.sourceEventId === topUpId),1497 ).toEqual([1498 expect.objectContaining({1499 eventType: "subscription.renewed",1500 productId: "premium_monthly",1501 subscription: expect.objectContaining({1502 expiresAt: 1_900_000_000_000,1503 willRenew: false,1504 }),1505 }),1506 ]);1507 expect(1508 db.rows("commerceEvents").find((event) => event.sourceEventId === topUpId)1509 ?.subscription,1510 ).not.toHaveProperty("renewsAt");1511 expect(db.rows("subscriptions")[0]).toMatchObject({1512 willRenew: false,1513 expiresAt: 1_900_000_000_000,1514 });1515 expect(db.rows("subscriptions")[0]?.renewsAt).toBeUndefined();1516 });1517 1518 it("classifies an active same-product linked resubscribe as uncanceled", async () => {1519 const db = new MemDb();1520 const startedId = await seedWebhookEvent(db, {1521 type: "SubscriptionStarted",1522 notificationId: "google-resubscribe-start",1523 occurredAt: 1_000,1524 });1525 await applySubscriptionEventHandler(makeCtx(db), {1526 projectId: PROJECT_ID as never,1527 eventId: startedId as never,1528 });1529 const canceledId = await db.insert("webhookEvents", {1530 projectId: PROJECT_ID,1531 type: "SubscriptionCanceled",1532 source: "GooglePlayRealTimeDeveloperNotifications",1533 platform: "Android",1534 environment: "Production",1535 purchaseToken: TOKEN,1536 productKind: "subscription",1537 productId: "premium_monthly",1538 subscriptionState: "Active",1539 expiresAt: 1_800_000_000_000,1540 willRenew: false,1541 sourceNotificationId: "google-resubscribe-canceled",1542 occurredAt: 2_000,1543 receivedAt: 2_000,1544 });1545 await applySubscriptionEventHandler(makeCtx(db), {1546 projectId: PROJECT_ID as never,1547 eventId: canceledId as never,1548 });1549 const resubscribedId = await db.insert("webhookEvents", {1550 projectId: PROJECT_ID,1551 type: "SubscriptionStarted",1552 source: "GooglePlayRealTimeDeveloperNotifications",1553 platform: "Android",1554 environment: "Production",1555 purchaseToken: "purchase_token_2",1556 linkedPurchaseToken: TOKEN,1557 productKind: "subscription",1558 productId: "premium_monthly",1559 subscriptionState: "Active",1560 expiresAt: 1_800_000_000_000,1561 willRenew: true,1562 sourceNotificationId: "google-resubscribe-linked",1563 occurredAt: 3_000,1564 receivedAt: 3_000,1565 });1566 1567 await expect(1568 applySubscriptionEventHandler(makeCtx(db), {1569 projectId: PROJECT_ID as never,1570 eventId: resubscribedId as never,1571 }),1572 ).resolves.toMatchObject({ transition: "Uncanceled", active: true });1573 expect(1574 db1575 .rows("commerceEvents")1576 .filter((event) => event.sourceEventId === resubscribedId)1577 .map((event) => event.eventType),1578 ).toEqual(["subscription.uncanceled"]);1579 });1580 1581 it("recovers a linked inactive predecessor regardless of verification order", async () => {1582 const db = new MemDb();1583 const startedId = await seedWebhookEvent(db, {1584 type: "SubscriptionStarted",1585 notificationId: "google-order-start",1586 occurredAt: 1_000,1587 });1588 await applySubscriptionEventHandler(makeCtx(db), {1589 projectId: PROJECT_ID as never,1590 eventId: startedId as never,1591 });1592 const expiredId = await seedWebhookEvent(db, {1593 type: "SubscriptionExpired",1594 notificationId: "google-order-expired",1595 occurredAt: 2_000,1596 });1597 await applySubscriptionEventHandler(makeCtx(db), {1598 projectId: PROJECT_ID as never,1599 eventId: expiredId as never,1600 });1601 await recordVerifiedSubscriptionHandler(makeCtx(db), {1602 projectId: PROJECT_ID as never,1603 platform: "Android",1604 purchaseToken: "purchase_token_2",1605 productId: "premium_monthly",1606 purchaseState: "ENTITLED",1607 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",1608 expiresAt: 1_900_000_000_000,1609 });1610 const recoveredId = await db.insert("webhookEvents", {1611 projectId: PROJECT_ID,1612 type: "SubscriptionStarted",1613 source: "GooglePlayRealTimeDeveloperNotifications",1614 platform: "Android",1615 environment: "Production",1616 purchaseToken: "purchase_token_2",1617 linkedPurchaseToken: TOKEN,1618 productKind: "subscription",1619 productId: "premium_monthly",1620 subscriptionState: "Active",1621 expiresAt: 1_900_000_000_000,1622 willRenew: true,1623 sourceNotificationId: "google-order-linked",1624 occurredAt: 3_000,1625 receivedAt: 3_000,1626 });1627 1628 await expect(1629 applySubscriptionEventHandler(makeCtx(db), {1630 projectId: PROJECT_ID as never,1631 eventId: recoveredId as never,1632 }),1633 ).resolves.toMatchObject({ transition: "Recovered", active: true });1634 expect(1635 db1636 .rows("commerceEvents")1637 .filter((event) => event.sourceEventId === recoveredId)1638 .map((event) => event.eventType),1639 ).toEqual(["subscription.recovered"]);1640 });1641 1642 it("recovers an inactive predecessor through a linked Google purchase", async () => {1643 const db = new MemDb();1644 const startedId = await seedWebhookEvent(db, {1645 type: "SubscriptionStarted",1646 notificationId: "google-linked-inactive-start",1647 occurredAt: 1_000,1648 });1649 await applySubscriptionEventHandler(makeCtx(db), {1650 projectId: PROJECT_ID as never,1651 eventId: startedId as never,1652 });1653 const expiredId = await seedWebhookEvent(db, {1654 type: "SubscriptionExpired",1655 notificationId: "google-linked-inactive-expired",1656 occurredAt: 2_000,1657 });1658 await applySubscriptionEventHandler(makeCtx(db), {1659 projectId: PROJECT_ID as never,1660 eventId: expiredId as never,1661 });1662 const recoveredId = await db.insert("webhookEvents", {1663 projectId: PROJECT_ID,1664 type: "SubscriptionStarted",1665 source: "GooglePlayRealTimeDeveloperNotifications",1666 platform: "Android",1667 environment: "Production",1668 purchaseToken: "purchase_token_2",1669 linkedPurchaseToken: TOKEN,1670 productKind: "subscription",1671 productId: "premium_monthly",1672 subscriptionState: "Active",1673 sourceNotificationId: "google-linked-inactive-repurchase",1674 occurredAt: 3_000,1675 receivedAt: 3_000,1676 });1677 1678 await expect(1679 applySubscriptionEventHandler(makeCtx(db), {1680 projectId: PROJECT_ID as never,1681 eventId: recoveredId as never,1682 }),1683 ).resolves.toMatchObject({ transition: "Recovered", active: true });1684 expect(1685 db1686 .rows("commerceEvents")1687 .filter((event) => event.sourceEventId === recoveredId)1688 .map((event) => event.eventType),1689 ).toEqual(["subscription.recovered"]);1690 });1691 1692 it("applies a delayed replacement after the predecessor expires", async () => {1693 const db = new MemDb();1694 const startedId = await seedWebhookEvent(db, {1695 type: "SubscriptionStarted",1696 notificationId: "google-predecessor-started",1697 occurredAt: 1_000,1698 });1699 await applySubscriptionEventHandler(makeCtx(db), {1700 projectId: PROJECT_ID as never,1701 eventId: startedId as never,1702 });1703 const expiredId = await seedWebhookEvent(db, {1704 type: "SubscriptionExpired",1705 notificationId: "google-predecessor-expired",1706 occurredAt: 3_000,1707 });1708 await applySubscriptionEventHandler(makeCtx(db), {1709 projectId: PROJECT_ID as never,1710 eventId: expiredId as never,1711 });1712 const changedId = await db.insert("webhookEvents", {1713 projectId: PROJECT_ID,1714 type: "SubscriptionProductChanged",1715 source: "GooglePlayRealTimeDeveloperNotifications",1716 platform: "Android",1717 environment: "Production",1718 purchaseToken: "purchase_token_2",1719 linkedPurchaseToken: TOKEN,1720 productKind: "subscription",1721 productId: "premium_yearly",1722 subscriptionState: "Active",1723 sourceNotificationId: "google-delayed-replacement",1724 occurredAt: 2_000,1725 receivedAt: 4_000,1726 });1727 1728 await applySubscriptionEventHandler(makeCtx(db), {1729 projectId: PROJECT_ID as never,1730 eventId: changedId as never,1731 });1732 1733 expect(db.rows("subscriptions")).toHaveLength(1);1734 expect(db.rows("subscriptions")[0]).toMatchObject({1735 purchaseToken: "purchase_token_2",1736 productId: "premium_yearly",1737 state: "Active",1738 lastEventId: changedId,1739 });1740 expect(1741 db.rows("webhookEvents").find((row) => row._id === changedId),1742 ).toHaveProperty("appliedAt");1743 expect(db.rows("commerceEvents")).toContainEqual(1744 expect.objectContaining({1745 eventType: "subscription.product_changed",1746 previousProductId: "premium_monthly",1747 productId: "premium_yearly",1748 }),1749 );1750 });1751 1752 it("keeps verification-first replacement state over a later predecessor expiry", async () => {1753 const db = new MemDb();1754 db.seedProduct({1755 projectId: PROJECT_ID,1756 platform: "Android",1757 productId: "premium_monthly",1758 billingPeriod: "P1M",1759 });1760 db.seedProduct({1761 projectId: PROJECT_ID,1762 platform: "Android",1763 productId: "premium_yearly",1764 billingPeriod: "P1Y",1765 });1766 const startedId = await seedWebhookEvent(db, {1767 type: "SubscriptionStarted",1768 notificationId: "google-old-token",1769 occurredAt: 1_000,1770 });1771 await applySubscriptionEventHandler(makeCtx(db), {1772 projectId: PROJECT_ID as never,1773 eventId: startedId as never,1774 });1775 await bindSubscriptionToUserHandler(makeCtx(db), {1776 projectId: PROJECT_ID as never,1777 purchaseToken: TOKEN,1778 userId: "user_1",1779 });1780 await recordVerifiedSubscriptionHandler(makeCtx(db), {1781 projectId: PROJECT_ID as never,1782 platform: "Android",1783 purchaseToken: "purchase_token_2",1784 productId: "premium_yearly",1785 purchaseState: "ENTITLED",1786 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",1787 });1788 expect(db.rows("subscriptions")).toHaveLength(2);1789 expect(1790 db1791 .rows("subscriptionStats")1792 .reduce((total, row) => total + Number(row.activeSubs), 0),1793 ).toBe(2);1794 1795 const expiredId = await db.insert("webhookEvents", {1796 projectId: PROJECT_ID,1797 type: "SubscriptionExpired",1798 source: "GooglePlayRealTimeDeveloperNotifications",1799 platform: "Android",1800 environment: "Production",1801 purchaseToken: TOKEN,1802 productKind: "subscription",1803 productId: "premium_monthly",1804 subscriptionState: "Expired",1805 sourceNotificationId: "google-old-token-expired",1806 occurredAt: 3_000,1807 receivedAt: 3_000,1808 });1809 await applySubscriptionEventHandler(makeCtx(db), {1810 projectId: PROJECT_ID as never,1811 eventId: expiredId as never,1812 });1813 1814 const changedId = await db.insert("webhookEvents", {1815 projectId: PROJECT_ID,1816 type: "SubscriptionProductChanged",1817 source: "GooglePlayRealTimeDeveloperNotifications",1818 platform: "Android",1819 environment: "Production",1820 purchaseToken: "purchase_token_2",1821 linkedPurchaseToken: TOKEN,1822 productKind: "subscription",1823 productId: "premium_yearly",1824 subscriptionState: "Active",1825 sourceNotificationId: "google-new-token",1826 occurredAt: 2_000,1827 receivedAt: 2_000,1828 });1829 await applySubscriptionEventHandler(makeCtx(db), {1830 projectId: PROJECT_ID as never,1831 eventId: changedId as never,1832 });1833 1834 expect(db.rows("subscriptions")).toHaveLength(1);1835 expect(db.rows("subscriptions")[0]).toMatchObject({1836 purchaseToken: "purchase_token_2",1837 productId: "premium_yearly",1838 userId: "user_1",1839 state: "Active",1840 lastEventId: changedId,1841 });1842 expect(1843 db1844 .rows("subscriptionStats")1845 .reduce((total, row) => total + Number(row.activeSubs), 0),1846 ).toBe(1);1847 });1848 1849 it("preserves active predecessor semantics after replacement verification", async () => {1850 const db = new MemDb();1851 const startedId = await seedWebhookEvent(db, {1852 type: "SubscriptionStarted",1853 notificationId: "google-active-predecessor",1854 occurredAt: 1_000,1855 });1856 await applySubscriptionEventHandler(makeCtx(db), {1857 projectId: PROJECT_ID as never,1858 eventId: startedId as never,1859 });1860 await recordVerifiedSubscriptionHandler(makeCtx(db), {1861 projectId: PROJECT_ID as never,1862 platform: "Android",1863 purchaseToken: "purchase_token_2",1864 productId: "premium_yearly",1865 purchaseState: "ENTITLED",1866 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",1867 });1868 const changedId = await db.insert("webhookEvents", {1869 projectId: PROJECT_ID,1870 type: "SubscriptionProductChanged",1871 source: "GooglePlayRealTimeDeveloperNotifications",1872 platform: "Android",1873 environment: "Production",1874 purchaseToken: "purchase_token_2",1875 linkedPurchaseToken: TOKEN,1876 productKind: "subscription",1877 productId: "premium_yearly",1878 subscriptionState: "Active",1879 sourceNotificationId: "google-verified-replacement",1880 occurredAt: 2_000,1881 receivedAt: 2_000,1882 });1883 1884 await applySubscriptionEventHandler(makeCtx(db), {1885 projectId: PROJECT_ID as never,1886 eventId: changedId as never,1887 });1888 1889 expect(db.rows("subscriptions")).toHaveLength(1);1890 expect(db.rows("subscriptions")[0]).toMatchObject({1891 purchaseToken: "purchase_token_2",1892 productId: "premium_yearly",1893 state: "Active",1894 lastEventId: changedId,1895 });1896 expect(1897 db1898 .rows("commerceEvents")1899 .filter((event) => event.sourceEventId === changedId),1900 ).toEqual([1901 expect.objectContaining({1902 eventType: "subscription.product_changed",1903 previousProductId: "premium_monthly",1904 productId: "premium_yearly",1905 }),1906 ]);1907 });1908 1909 it("emits a verified replacement when multi-item enrichment omits the product", async () => {1910 const db = new MemDb();1911 const startedId = await seedWebhookEvent(db, {1912 type: "SubscriptionStarted",1913 notificationId: "google-active-predecessor-ambiguous",1914 occurredAt: 1_000,1915 });1916 await applySubscriptionEventHandler(makeCtx(db), {1917 projectId: PROJECT_ID as never,1918 eventId: startedId as never,1919 });1920 await recordVerifiedSubscriptionHandler(makeCtx(db), {1921 projectId: PROJECT_ID as never,1922 platform: "Android",1923 purchaseToken: "purchase_token_2",1924 productId: "premium_yearly",1925 purchaseState: "ENTITLED",1926 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",1927 });1928 const changedId = await db.insert("webhookEvents", {1929 projectId: PROJECT_ID,1930 type: "SubscriptionProductChanged",1931 source: "GooglePlayRealTimeDeveloperNotifications",1932 platform: "Android",1933 environment: "Production",1934 purchaseToken: "purchase_token_2",1935 linkedPurchaseToken: TOKEN,1936 productKind: "subscription",1937 subscriptionState: "Active",1938 sourceNotificationId: "google-verified-ambiguous-replacement",1939 occurredAt: 2_000,1940 receivedAt: 2_000,1941 });1942 1943 await applySubscriptionEventHandler(makeCtx(db), {1944 projectId: PROJECT_ID as never,1945 eventId: changedId as never,1946 });1947 1948 expect(db.rows("subscriptions")).toHaveLength(1);1949 expect(db.rows("subscriptions")[0]).toMatchObject({1950 purchaseToken: "purchase_token_2",1951 productId: "premium_yearly",1952 state: "Active",1953 lastEventId: changedId,1954 });1955 expect(1956 db1957 .rows("commerceEvents")1958 .filter((event) => event.sourceEventId === changedId),1959 ).toEqual([1960 expect.objectContaining({1961 eventType: "subscription.product_changed",1962 previousProductId: "premium_monthly",1963 productId: "premium_yearly",1964 }),1965 ]);1966 });1967 1968 it("rejects linked Google tokens bound to different users", async () => {1969 const db = new MemDb();1970 const startedId = await seedWebhookEvent(db, {1971 type: "SubscriptionStarted",1972 notificationId: "google-old-token",1973 occurredAt: 1_000,1974 });1975 await applySubscriptionEventHandler(makeCtx(db), {1976 projectId: PROJECT_ID as never,1977 eventId: startedId as never,1978 });1979 await bindSubscriptionToUserHandler(makeCtx(db), {1980 projectId: PROJECT_ID as never,1981 purchaseToken: TOKEN,1982 userId: "user_1",1983 });1984 await recordVerifiedSubscriptionHandler(makeCtx(db), {1985 projectId: PROJECT_ID as never,1986 platform: "Android",1987 purchaseToken: "purchase_token_2",1988 productId: "premium_yearly",1989 purchaseState: "ENTITLED",1990 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",1991 });1992 await bindSubscriptionToUserHandler(makeCtx(db), {1993 projectId: PROJECT_ID as never,1994 purchaseToken: "purchase_token_2",1995 userId: "user_2",1996 });1997 const changedId = await db.insert("webhookEvents", {1998 projectId: PROJECT_ID,1999 type: "SubscriptionProductChanged",2000 source: "GooglePlayRealTimeDeveloperNotifications",2001 platform: "Android",2002 environment: "Production",2003 purchaseToken: "purchase_token_2",2004 linkedPurchaseToken: TOKEN,2005 productKind: "subscription",2006 productId: "premium_yearly",2007 sourceNotificationId: "google-conflicting-token",2008 occurredAt: 2_000,2009 receivedAt: 2_000,2010 });2011 2012 await expect(2013 applySubscriptionEventHandler(makeCtx(db), {2014 projectId: PROJECT_ID as never,2015 eventId: changedId as never,2016 }),2017 ).rejects.toThrow("different users");2018 expect(db.rows("subscriptions")).toHaveLength(2);2019 });2020 2021 it("keeps a newer store-governed replacement row over an older linked event", async () => {2022 const db = new MemDb();2023 const oldStartedId = await seedWebhookEvent(db, {2024 type: "SubscriptionStarted",2025 notificationId: "google-old-token",2026 occurredAt: 1_000,2027 });2028 await applySubscriptionEventHandler(makeCtx(db), {2029 projectId: PROJECT_ID as never,2030 eventId: oldStartedId as never,2031 });2032 const newStartedId = await db.insert("webhookEvents", {2033 projectId: PROJECT_ID,2034 type: "SubscriptionStarted",2035 source: "GooglePlayRealTimeDeveloperNotifications",2036 platform: "Android",2037 environment: "Production",2038 purchaseToken: "purchase_token_2",2039 productKind: "subscription",2040 productId: "premium_monthly",2041 subscriptionState: "Active",2042 sourceNotificationId: "google-new-token-started",2043 occurredAt: 3_000,2044 receivedAt: 3_000,2045 });2046 await applySubscriptionEventHandler(makeCtx(db), {2047 projectId: PROJECT_ID as never,2048 eventId: newStartedId as never,2049 });2050 const olderLinkedId = await db.insert("webhookEvents", {2051 projectId: PROJECT_ID,2052 type: "SubscriptionProductChanged",2053 source: "GooglePlayRealTimeDeveloperNotifications",2054 platform: "Android",2055 environment: "Production",2056 purchaseToken: "purchase_token_2",2057 linkedPurchaseToken: TOKEN,2058 productKind: "subscription",2059 productId: "premium_yearly",2060 subscriptionState: "Active",2061 sourceNotificationId: "google-older-linked-event",2062 occurredAt: 2_000,2063 receivedAt: 4_000,2064 });2065 2066 await applySubscriptionEventHandler(makeCtx(db), {2067 projectId: PROJECT_ID as never,2068 eventId: olderLinkedId as never,2069 });2070 2071 expect(db.rows("subscriptions")).toHaveLength(1);2072 expect(db.rows("subscriptions")[0]).toMatchObject({2073 purchaseToken: "purchase_token_2",2074 productId: "premium_monthly",2075 lastEventId: newStartedId,2076 });2077 expect(2078 db.rows("webhookEvents").find((row) => row._id === olderLinkedId),2079 ).toHaveProperty("appliedAt");2080 expect(db.rows("subscriptionTokenAliases")).toMatchObject([2081 {2082 purchaseToken: TOKEN,2083 successorPurchaseToken: "purchase_token_2",2084 },2085 ]);2086 2087 const latePredecessorExpiryId = await db.insert("webhookEvents", {2088 projectId: PROJECT_ID,2089 type: "SubscriptionExpired",2090 source: "GooglePlayRealTimeDeveloperNotifications",2091 platform: "Android",2092 environment: "Production",2093 purchaseToken: TOKEN,2094 productKind: "subscription",2095 productId: "premium_monthly",2096 subscriptionState: "Expired",2097 sourceNotificationId: "google-late-predecessor-expired",2098 occurredAt: 4_000,2099 receivedAt: 4_000,2100 });2101 await expect(2102 applySubscriptionEventHandler(makeCtx(db), {2103 projectId: PROJECT_ID as never,2104 eventId: latePredecessorExpiryId as never,2105 }),2106 ).resolves.toMatchObject({ transition: null, active: true });2107 expect(db.rows("subscriptions")).toHaveLength(1);2108 expect(2109 db2110 .rows("commerceEvents")2111 .filter((event) => event.sourceEventId === latePredecessorExpiryId),2112 ).toEqual([]);2113 });2114 2115 it("keeps current-token state when the predecessor expires later", async () => {2116 const db = new MemDb();2117 const oldStartedId = await seedWebhookEvent(db, {2118 type: "SubscriptionStarted",2119 notificationId: "google-predecessor-started",2120 occurredAt: 1_000,2121 });2122 await applySubscriptionEventHandler(makeCtx(db), {2123 projectId: PROJECT_ID as never,2124 eventId: oldStartedId as never,2125 });2126 const replacementStartedId = await db.insert("webhookEvents", {2127 projectId: PROJECT_ID,2128 type: "SubscriptionStarted",2129 source: "GooglePlayRealTimeDeveloperNotifications",2130 platform: "Android",2131 environment: "Production",2132 purchaseToken: "purchase_token_2",2133 productKind: "subscription",2134 productId: "premium_monthly",2135 subscriptionState: "Active",2136 sourceNotificationId: "google-replacement-started",2137 occurredAt: 2_000,2138 receivedAt: 2_000,2139 });2140 await applySubscriptionEventHandler(makeCtx(db), {2141 projectId: PROJECT_ID as never,2142 eventId: replacementStartedId as never,2143 });2144 const predecessorExpiredId = await db.insert("webhookEvents", {2145 projectId: PROJECT_ID,2146 type: "SubscriptionExpired",2147 source: "GooglePlayRealTimeDeveloperNotifications",2148 platform: "Android",2149 environment: "Production",2150 purchaseToken: TOKEN,2151 productKind: "subscription",2152 productId: "premium_monthly",2153 subscriptionState: "Expired",2154 sourceNotificationId: "google-predecessor-expired",2155 occurredAt: 3_000,2156 receivedAt: 3_000,2157 });2158 await applySubscriptionEventHandler(makeCtx(db), {2159 projectId: PROJECT_ID as never,2160 eventId: predecessorExpiredId as never,2161 });2162 const linkedId = await db.insert("webhookEvents", {2163 projectId: PROJECT_ID,2164 type: "SubscriptionProductChanged",2165 source: "GooglePlayRealTimeDeveloperNotifications",2166 platform: "Android",2167 environment: "Production",2168 purchaseToken: "purchase_token_2",2169 linkedPurchaseToken: TOKEN,2170 productKind: "subscription",2171 productId: "premium_yearly",2172 subscriptionState: "Active",2173 sourceNotificationId: "google-linked-replacement",2174 occurredAt: 4_000,2175 receivedAt: 4_000,2176 });2177 2178 await applySubscriptionEventHandler(makeCtx(db), {2179 projectId: PROJECT_ID as never,2180 eventId: linkedId as never,2181 });2182 2183 expect(db.rows("subscriptions")).toHaveLength(1);2184 expect(db.rows("subscriptions")[0]).toMatchObject({2185 purchaseToken: "purchase_token_2",2186 productId: "premium_yearly",2187 state: "Active",2188 lastEventId: linkedId,2189 });2190 });2191 2192 it("moves a multi-item Google replacement token without guessing a product", async () => {2193 const db = new MemDb();2194 const startedId = await seedWebhookEvent(db, {2195 type: "SubscriptionStarted",2196 notificationId: "google-old-token",2197 occurredAt: 1_000,2198 });2199 await applySubscriptionEventHandler(makeCtx(db), {2200 projectId: PROJECT_ID as never,2201 eventId: startedId as never,2202 });2203 const changedId = await db.insert("webhookEvents", {2204 projectId: PROJECT_ID,2205 type: "SubscriptionProductChanged",2206 source: "GooglePlayRealTimeDeveloperNotifications",2207 platform: "Android",2208 environment: "Production",2209 purchaseToken: "purchase_token_2",2210 linkedPurchaseToken: TOKEN,2211 productKind: "subscription",2212 sourceNotificationId: "google-bundle-change",2213 occurredAt: 2_000,2214 receivedAt: 2_000,2215 });2216 2217 await applySubscriptionEventHandler(makeCtx(db), {2218 projectId: PROJECT_ID as never,2219 eventId: changedId as never,2220 });2221 2222 expect(db.rows("subscriptions")).toHaveLength(1);2223 expect(db.rows("subscriptions")[0]).toMatchObject({2224 purchaseToken: "purchase_token_2",2225 productId: "premium_monthly",2226 lastEventId: changedId,2227 });2228 expect(db.rows("commerceEvents")).toHaveLength(1);2229 });2230});2231 2232describe("buildVerifiedSubscriptionSnapshot", () => {2233 it("bootstraps an active subscription from an entitled Google verification", () => {2234 const snapshot = buildVerifiedSubscriptionSnapshot({2235 platform: "Android",2236 productId: "premium_monthly",2237 purchaseState: HarmonizedPurchaseState.ENTITLED,2238 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2239 expiresAt: 2_000_000_000_000,2240 });2241 2242 expect(snapshot).toEqual({2243 productId: "premium_monthly",2244 state: "Active",2245 expiresAt: 2_000_000_000_000,2246 renewsAt: undefined,2247 willRenew: true,2248 cancellationReason: undefined,2249 clearCancellationReason: true,2250 currency: undefined,2251 priceAmountMicros: undefined,2252 });2253 });2254 2255 it("treats pending-acknowledgment subscriptions as entitled while still bindable", () => {2256 const snapshot = buildVerifiedSubscriptionSnapshot({2257 platform: "Android",2258 productId: "premium_monthly",2259 purchaseState: HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT,2260 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2261 });2262 2263 expect(snapshot).toMatchObject({2264 productId: "premium_monthly",2265 state: "Active",2266 willRenew: true,2267 clearCancellationReason: true,2268 });2269 });2270 2271 it("stamps Refunded only when Apple says the revocation was a refund", () => {2272 // revocationReason 1 is Apple's app-issue refund; 0 also covers Family2273 // Sharing loss, where asserting money moved back would be false.2274 const refunded = buildVerifiedSubscriptionSnapshot({2275 platform: "IOS",2276 productId: "premium_monthly",2277 purchaseState: HarmonizedPurchaseState.CANCELED,2278 revocationReasonIOS: 1,2279 });2280 expect(refunded).toMatchObject({2281 state: "Revoked",2282 willRenew: false,2283 cancellationReason: "Refunded",2284 });2285 2286 const familySharingLoss = buildVerifiedSubscriptionSnapshot({2287 platform: "IOS",2288 productId: "premium_monthly",2289 purchaseState: HarmonizedPurchaseState.CANCELED,2290 revocationReasonIOS: 0,2291 });2292 expect(familySharingLoss).toMatchObject({2293 state: "Revoked",2294 willRenew: false,2295 });2296 expect(familySharingLoss?.cancellationReason).toBeUndefined();2297 2298 const unknown = buildVerifiedSubscriptionSnapshot({2299 platform: "IOS",2300 productId: "premium_monthly",2301 purchaseState: HarmonizedPurchaseState.CANCELED,2302 });2303 expect(unknown?.cancellationReason).toBeUndefined();2304 });2305 2306 it("preserves access for canceled Google subscriptions until expiry", () => {2307 const snapshot = buildVerifiedSubscriptionSnapshot({2308 platform: "Android",2309 productId: "premium_monthly",2310 purchaseState: HarmonizedPurchaseState.CANCELED,2311 subscriptionState: "SUBSCRIPTION_STATE_CANCELED",2312 expiresAt: 2_000_000_000_000,2313 });2314 2315 expect(snapshot).toMatchObject({2316 productId: "premium_monthly",2317 state: "Active",2318 willRenew: false,2319 expiresAt: 2_000_000_000_000,2320 });2321 expect(snapshot?.cancellationReason).toBeUndefined();2322 });2323 2324 it("maps on-hold Google subscriptions to billing retry", () => {2325 const snapshot = buildVerifiedSubscriptionSnapshot({2326 platform: "Android",2327 productId: "premium_monthly",2328 purchaseState: HarmonizedPurchaseState.PENDING,2329 subscriptionState: "SUBSCRIPTION_STATE_ON_HOLD",2330 });2331 2332 expect(snapshot).toMatchObject({2333 productId: "premium_monthly",2334 state: "InBillingRetry",2335 cancellationReason: "BillingError",2336 });2337 });2338 2339 it("does not infer renewal status when Google omits subscriptionState", () => {2340 const snapshot = buildVerifiedSubscriptionSnapshot({2341 platform: "Android",2342 productId: "premium_monthly",2343 purchaseState: HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT,2344 });2345 2346 expect(snapshot).toMatchObject({2347 productId: "premium_monthly",2348 state: "Active",2349 });2350 expect(snapshot?.willRenew).toBeUndefined();2351 expect(snapshot?.cancellationReason).toBeUndefined();2352 expect(snapshot?.clearCancellationReason).toBeUndefined();2353 });2354 2355 it("does not create a subscription row for expected-product mismatches", () => {2356 const snapshot = buildVerifiedSubscriptionSnapshot({2357 platform: "Android",2358 productId: "premium_monthly",2359 purchaseState: HarmonizedPurchaseState.INAUTHENTIC,2360 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2361 });2362 2363 expect(snapshot).toBeNull();2364 });2365 2366 it("does not create a subscription row when Google omits the product id", () => {2367 const snapshot = buildVerifiedSubscriptionSnapshot({2368 platform: "Android",2369 productId: "unknown",2370 purchaseState: HarmonizedPurchaseState.ENTITLED,2371 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2372 });2373 2374 expect(snapshot).toBeNull();2375 });2376 2377 it("bootstraps an Apple subscription without guessing auto-renew status", () => {2378 const snapshot = buildVerifiedSubscriptionSnapshot({2379 platform: "IOS",2380 productId: "com.example.premium",2381 purchaseState: HarmonizedPurchaseState.ENTITLED,2382 expiresAt: 2_000_000_000_000,2383 currency: "USD",2384 priceAmountMicros: 9_990_000,2385 });2386 2387 expect(snapshot).toEqual({2388 productId: "com.example.premium",2389 state: "Active",2390 expiresAt: 2_000_000_000_000,2391 renewsAt: undefined,2392 cancellationReason: undefined,2393 clearCancellationReason: true,2394 currency: "USD",2395 priceAmountMicros: 9_990_000,2396 });2397 });2398});2399 2400describe("mergeVerifiedSubscriptionSnapshot", () => {2401 it("preserves existing timing and price fields when verify omits them", () => {2402 const snapshot = mergeVerifiedSubscriptionSnapshot(2403 {2404 expiresAt: 2_000_000_000_000,2405 renewsAt: 2_000_000_000_000,2406 willRenew: true,2407 cancellationReason: undefined,2408 currency: "USD",2409 priceAmountMicros: 9_990_000,2410 },2411 {2412 productId: "premium_monthly",2413 state: "Active",2414 willRenew: true,2415 },2416 );2417 2418 expect(snapshot).toEqual({2419 productId: "premium_monthly",2420 state: "Active",2421 expiresAt: 2_000_000_000_000,2422 renewsAt: 2_000_000_000_000,2423 willRenew: true,2424 cancellationReason: undefined,2425 currency: "USD",2426 priceAmountMicros: 9_990_000,2427 });2428 });2429 2430 it("clears stale cancellation reason when verified snapshots request it", () => {2431 const existing = {2432 expiresAt: undefined,2433 renewsAt: undefined,2434 willRenew: false,2435 cancellationReason: "UserCanceled" as const,2436 currency: undefined,2437 priceAmountMicros: undefined,2438 };2439 const appleSnapshot = buildVerifiedSubscriptionSnapshot({2440 platform: "IOS",2441 productId: "premium_monthly",2442 purchaseState: HarmonizedPurchaseState.ENTITLED,2443 });2444 const graceSnapshot = buildVerifiedSubscriptionSnapshot({2445 platform: "Android",2446 productId: "premium_monthly",2447 purchaseState: HarmonizedPurchaseState.ENTITLED,2448 subscriptionState: "SUBSCRIPTION_STATE_IN_GRACE_PERIOD",2449 });2450 2451 expect(appleSnapshot?.clearCancellationReason).toBe(true);2452 expect(graceSnapshot?.clearCancellationReason).toBe(true);2453 2454 const appleMerged = mergeVerifiedSubscriptionSnapshot(2455 existing,2456 appleSnapshot!,2457 );2458 const graceMerged = mergeVerifiedSubscriptionSnapshot(2459 existing,2460 graceSnapshot!,2461 );2462 2463 expect(appleMerged.cancellationReason).toBeUndefined();2464 expect(graceMerged.cancellationReason).toBeUndefined();2465 expect(graceMerged.willRenew).toBe(true);2466 });2467 2468 it("preserves cancellation reason when verify cannot prove auto-renew is enabled", () => {2469 const snapshot = mergeVerifiedSubscriptionSnapshot(2470 {2471 expiresAt: undefined,2472 renewsAt: undefined,2473 willRenew: false,2474 cancellationReason: "UserCanceled",2475 currency: undefined,2476 priceAmountMicros: undefined,2477 },2478 {2479 productId: "premium_monthly",2480 state: "Active",2481 },2482 );2483 2484 expect(snapshot.cancellationReason).toBe("UserCanceled");2485 expect(snapshot.willRenew).toBe(false);2486 });2487});2488 2489describe("recordVerifiedSubscriptionHandler", () => {2490 beforeEach(() => {2491 vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));2492 });2493 2494 afterEach(() => {2495 vi.useRealTimers();2496 });2497 2498 it("rejects a receipt-verification write once project deletion starts", async () => {2499 const db = new MemDb();2500 await db.patch(PROJECT_ID, { pendingDeletion: true });2501 2502 await expect(2503 recordVerifiedSubscriptionHandler(makeCtx(db), {2504 projectId: PROJECT_ID as never,2505 platform: "Android",2506 purchaseToken: TOKEN,2507 productId: "premium_monthly",2508 purchaseState: HarmonizedPurchaseState.ENTITLED,2509 }),2510 ).rejects.toThrow("Project not found");2511 expect(db.rows("subscriptions")).toEqual([]);2512 expect(db.rows("subscriptionStats")).toEqual([]);2513 });2514 2515 it("creates a bindable subscription row from Google receipt verification", async () => {2516 const db = new MemDb();2517 db.seedProduct({2518 projectId: PROJECT_ID,2519 platform: "Android",2520 productId: "premium_monthly",2521 billingPeriod: "P1M",2522 });2523 2524 const subscriptionId = await recordVerifiedSubscriptionHandler(2525 makeCtx(db),2526 {2527 projectId: PROJECT_ID as never,2528 platform: "Android",2529 purchaseToken: TOKEN,2530 productId: "premium_monthly",2531 purchaseState: HarmonizedPurchaseState.ENTITLED,2532 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2533 expiresAt: 1_769_904_000_000,2534 renewsAt: 1_769_904_000_000,2535 currency: "USD",2536 priceAmountMicros: 9_990_000,2537 },2538 );2539 2540 expect(subscriptionId).toBe("subscriptions_2");2541 expect(db.rows("subscriptions")).toMatchObject([2542 {2543 _id: "subscriptions_2",2544 projectId: PROJECT_ID,2545 purchaseToken: TOKEN,2546 productId: "premium_monthly",2547 platform: "Android",2548 state: "Active",2549 willRenew: true,2550 currency: "USD",2551 priceAmountMicros: 9_990_000,2552 },2553 ]);2554 expect(db.rows("subscriptionStats")).toMatchObject([2555 {2556 projectId: PROJECT_ID,2557 currency: "USD",2558 activeSubs: 1,2559 inGracePeriod: 0,2560 inBillingRetry: 0,2561 mrrMicros: 9_990_000,2562 },2563 ]);2564 });2565 2566 it("keeps a verified Google prepaid subscription non-renewing", async () => {2567 const db = new MemDb();2568 2569 await recordVerifiedSubscriptionHandler(makeCtx(db), {2570 projectId: PROJECT_ID as never,2571 platform: "Android",2572 purchaseToken: "prepaid-token",2573 productId: "prepaid_monthly",2574 purchaseState: HarmonizedPurchaseState.ENTITLED,2575 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2576 expiresAt: 1_769_904_000_000,2577 willRenew: false,2578 });2579 2580 expect(db.rows("subscriptions")).toMatchObject([2581 {2582 purchaseToken: "prepaid-token",2583 state: "Active",2584 expiresAt: 1_769_904_000_000,2585 willRenew: false,2586 renewsAt: undefined,2587 },2588 ]);2589 });2590 2591 it("clears a prior renewal date when verification becomes non-renewing", async () => {2592 const db = new MemDb();2593 const input = {2594 projectId: PROJECT_ID as never,2595 platform: "Android" as const,2596 purchaseToken: "prepaid-transition-token",2597 productId: "premium_monthly",2598 purchaseState: HarmonizedPurchaseState.ENTITLED,2599 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2600 expiresAt: 1_769_904_000_000,2601 };2602 2603 await recordVerifiedSubscriptionHandler(makeCtx(db), {2604 ...input,2605 renewsAt: 1_769_904_000_000,2606 willRenew: true,2607 });2608 await recordVerifiedSubscriptionHandler(makeCtx(db), {2609 ...input,2610 willRenew: false,2611 });2612 2613 expect(db.rows("subscriptions")).toMatchObject([2614 {2615 purchaseToken: "prepaid-transition-token",2616 state: "Active",2617 willRenew: false,2618 renewsAt: undefined,2619 },2620 ]);2621 expect(db.rows("subscriptions")).toHaveLength(1);2622 });2623 2624 it("creates an active bindable row for pending-acknowledgment Google subscriptions", async () => {2625 const db = new MemDb();2626 db.seedProduct({2627 projectId: PROJECT_ID,2628 platform: "Android",2629 productId: "premium_monthly",2630 billingPeriod: "P1M",2631 });2632 2633 await recordVerifiedSubscriptionHandler(makeCtx(db), {2634 projectId: PROJECT_ID as never,2635 platform: "Android",2636 purchaseToken: TOKEN,2637 productId: "premium_monthly",2638 purchaseState: HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT,2639 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2640 expiresAt: 1_769_904_000_000,2641 currency: "USD",2642 priceAmountMicros: 9_990_000,2643 });2644 2645 expect(db.rows("subscriptions")).toMatchObject([2646 {2647 purchaseToken: TOKEN,2648 productId: "premium_monthly",2649 platform: "Android",2650 state: "Active",2651 willRenew: true,2652 },2653 ]);2654 expect(db.rows("subscriptionStats")).toMatchObject([2655 {2656 activeSubs: 1,2657 mrrMicros: 9_990_000,2658 },2659 ]);2660 });2661 2662 it("keeps repeated verification idempotent for subscription stats", async () => {2663 const db = new MemDb();2664 db.seedProduct({2665 projectId: PROJECT_ID,2666 platform: "Android",2667 productId: "premium_monthly",2668 billingPeriod: "P1M",2669 });2670 const args = {2671 projectId: PROJECT_ID as never,2672 platform: "Android" as const,2673 purchaseToken: TOKEN,2674 productId: "premium_monthly",2675 purchaseState: HarmonizedPurchaseState.ENTITLED,2676 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2677 expiresAt: 1_769_904_000_000,2678 currency: "USD",2679 priceAmountMicros: 9_990_000,2680 };2681 2682 await recordVerifiedSubscriptionHandler(makeCtx(db), args);2683 await recordVerifiedSubscriptionHandler(makeCtx(db), args);2684 2685 expect(db.rows("subscriptions")).toHaveLength(1);2686 expect(db.rows("subscriptionStats")).toMatchObject([2687 {2688 activeSubs: 1,2689 mrrMicros: 9_990_000,2690 },2691 ]);2692 });2693 2694 it("does not let an older verification overwrite webhook-governed state", async () => {2695 const db = new MemDb();2696 const startedId = await seedWebhookEvent(db, {2697 type: "SubscriptionStarted",2698 notificationId: "apple-monthly",2699 occurredAt: 1_000,2700 platform: "IOS",2701 productId: "premium_monthly",2702 });2703 await applySubscriptionEventHandler(makeCtx(db), {2704 projectId: PROJECT_ID as never,2705 eventId: startedId as never,2706 });2707 const renewedId = await seedWebhookEvent(db, {2708 type: "SubscriptionRenewed",2709 notificationId: "apple-yearly",2710 occurredAt: 2_000,2711 platform: "IOS",2712 productId: "premium_yearly",2713 });2714 await db.patch(renewedId, { expiresAt: 1_900_000_000_000 });2715 await applySubscriptionEventHandler(makeCtx(db), {2716 projectId: PROJECT_ID as never,2717 eventId: renewedId as never,2718 });2719 2720 await recordVerifiedSubscriptionHandler(makeCtx(db), {2721 projectId: PROJECT_ID as never,2722 platform: "IOS",2723 purchaseToken: TOKEN,2724 productId: "premium_monthly",2725 purchaseState: HarmonizedPurchaseState.ENTITLED,2726 expiresAt: 1_700_000_000_000,2727 });2728 expect(db.rows("subscriptions")[0]).toMatchObject({2729 productId: "premium_yearly",2730 expiresAt: 1_900_000_000_000,2731 lastEventSourceNotificationId: "apple-yearly",2732 });2733 });2734 2735 it("creates a bindable Apple subscription row from receipt verification", async () => {2736 const db = new MemDb();2737 db.seedProduct({2738 projectId: PROJECT_ID,2739 platform: "IOS",2740 productId: "com.example.premium",2741 billingPeriod: "P1M",2742 });2743 2744 const subscriptionId = await recordVerifiedSubscriptionHandler(2745 makeCtx(db),2746 {2747 projectId: PROJECT_ID as never,2748 platform: "IOS",2749 purchaseToken: "original_transaction_1",2750 productId: "com.example.premium",2751 purchaseState: HarmonizedPurchaseState.ENTITLED,2752 expiresAt: 1_769_904_000_000,2753 currency: "USD",2754 priceAmountMicros: 9_990_000,2755 },2756 );2757 2758 expect(subscriptionId).toBe("subscriptions_2");2759 expect(db.rows("subscriptions")).toMatchObject([2760 {2761 _id: "subscriptions_2",2762 projectId: PROJECT_ID,2763 purchaseToken: "original_transaction_1",2764 productId: "com.example.premium",2765 platform: "IOS",2766 state: "Active",2767 willRenew: undefined,2768 },2769 ]);2770 expect(db.rows("subscriptionStats")).toMatchObject([2771 {2772 projectId: PROJECT_ID,2773 currency: "USD",2774 activeSubs: 1,2775 mrrMicros: 9_990_000,2776 },2777 ]);2778 });2779 2780 it("supports the verify -> bind flow for SDK clients", async () => {2781 const db = new MemDb();2782 db.seedProduct({2783 projectId: PROJECT_ID,2784 platform: "Android",2785 productId: "premium_monthly",2786 billingPeriod: "P1M",2787 });2788 2789 const subscriptionId = await recordVerifiedSubscriptionHandler(2790 makeCtx(db),2791 {2792 projectId: PROJECT_ID as never,2793 platform: "Android",2794 purchaseToken: TOKEN,2795 productId: "premium_monthly",2796 purchaseState: HarmonizedPurchaseState.ENTITLED,2797 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2798 expiresAt: 1_769_904_000_000,2799 renewsAt: 1_769_904_000_000,2800 currency: "USD",2801 priceAmountMicros: 9_990_000,2802 },2803 );2804 2805 const boundId = await bindSubscriptionToUserHandler(makeCtx(db), {2806 projectId: PROJECT_ID as never,2807 purchaseToken: TOKEN,2808 userId: "user-1",2809 });2810 2811 expect(boundId).toBe(subscriptionId);2812 expect(db.rows("subscriptions")).toMatchObject([2813 {2814 _id: subscriptionId,2815 projectId: PROJECT_ID,2816 purchaseToken: TOKEN,2817 productId: "premium_monthly",2818 platform: "Android",2819 state: "Active",2820 userId: "user-1",2821 willRenew: true,2822 currency: "USD",2823 priceAmountMicros: 9_990_000,2824 },2825 ]);2826 2827 const eventId = await seedWebhookEvent(db, {2828 type: "SubscriptionStarted",2829 notificationId: "message-after-bind",2830 occurredAt: 2_000,2831 });2832 await applySubscriptionEventHandler(makeCtx(db), {2833 projectId: PROJECT_ID as never,2834 eventId: eventId as never,2835 });2836 expect(db.rows("commerceEvents")).toMatchObject([2837 { eventType: "subscription.started", userId: "user-1" },2838 { eventType: "entitlement.granted", userId: "user-1" },2839 ]);2840 });2841 2842 it("rejects unbounded user ids at the Convex storage boundary", async () => {2843 const db = new MemDb();2844 2845 for (const userId of [" ", "u".repeat(257)]) {2846 await expect(2847 bindSubscriptionToUserHandler(makeCtx(db), {2848 projectId: PROJECT_ID as never,2849 purchaseToken: TOKEN,2850 userId,2851 }),2852 ).rejects.toThrow("userId must be nonblank and at most 256 characters");2853 }2854 expect(db.rows("subscriptions")).toHaveLength(0);2855 expect(db.rows("commerceEvents")).toHaveLength(0);2856 });2857 2858 it("emits a correlated grant when the webhook arrives before binding", async () => {2859 const db = new MemDb();2860 db.seedProduct({2861 projectId: PROJECT_ID,2862 platform: "Android",2863 productId: "premium_monthly",2864 billingPeriod: "P1M",2865 });2866 const eventId = await seedWebhookEvent(db, {2867 type: "SubscriptionStarted",2868 notificationId: "message-before-bind",2869 occurredAt: 2_000,2870 });2871 await applySubscriptionEventHandler(makeCtx(db), {2872 projectId: PROJECT_ID as never,2873 eventId: eventId as never,2874 });2875 2876 await bindSubscriptionToUserHandler(makeCtx(db), {2877 projectId: PROJECT_ID as never,2878 purchaseToken: TOKEN,2879 userId: "user-1",2880 });2881 2882 expect(db.rows("commerceEvents").at(-1)).toMatchObject({2883 eventType: "entitlement.granted",2884 userId: "user-1",2885 });2886 });2887 2888 it("does not replay an unbound grant that expired before binding", async () => {2889 const db = new MemDb();2890 db.seedProduct({2891 projectId: PROJECT_ID,2892 platform: "Android",2893 productId: "premium_monthly",2894 billingPeriod: "P1M",2895 });2896 const started = await seedWebhookEvent(db, {2897 type: "SubscriptionStarted",2898 notificationId: "unbound-started",2899 occurredAt: 1_000,2900 });2901 await applySubscriptionEventHandler(makeCtx(db), {2902 projectId: PROJECT_ID as never,2903 eventId: started as never,2904 });2905 const expired = await seedWebhookEvent(db, {2906 type: "SubscriptionExpired",2907 notificationId: "unbound-expired",2908 occurredAt: 2_000,2909 });2910 await applySubscriptionEventHandler(makeCtx(db), {2911 projectId: PROJECT_ID as never,2912 eventId: expired as never,2913 });2914 2915 const beforeBind = db.rows("commerceEvents").map((row) => row.eventType);2916 expect(beforeBind).toEqual([2917 "subscription.started",2918 "subscription.expired",2919 ]);2920 2921 await bindSubscriptionToUserHandler(makeCtx(db), {2922 projectId: PROJECT_ID as never,2923 purchaseToken: TOKEN,2924 userId: "user-after-expiry",2925 });2926 2927 expect(db.rows("commerceEvents").map((row) => row.eventType)).toEqual(2928 beforeBind,2929 );2930 expect(db.rows("subscriptions")[0]).toMatchObject({2931 state: "Expired",2932 userId: "user-after-expiry",2933 });2934 });2935 2936 it("uses the compact source snapshot after the webhook row is pruned", async () => {2937 const db = new MemDb();2938 const eventId = await seedWebhookEvent(db, {2939 type: "SubscriptionStarted",2940 notificationId: "old-webhook",2941 occurredAt: 2_000,2942 });2943 await applySubscriptionEventHandler(makeCtx(db), {2944 projectId: PROJECT_ID as never,2945 eventId: eventId as never,2946 });2947 await db.delete(eventId);2948 2949 await bindSubscriptionToUserHandler(makeCtx(db), {2950 projectId: PROJECT_ID as never,2951 purchaseToken: TOKEN,2952 userId: "user-after-retention",2953 });2954 expect(db.rows("commerceEvents").at(-1)).toMatchObject({2955 eventType: "entitlement.granted",2956 productId: "premium_monthly",2957 userId: "user-after-retention",2958 sourceStoreNotificationId: "old-webhook",2959 });2960 });2961});2962 2963describe("user binding authorization", () => {2964 const seedBound = async (db: MemDb, userId?: string) => {2965 db.seedProduct({2966 projectId: PROJECT_ID,2967 platform: "Android",2968 productId: "premium_monthly",2969 billingPeriod: "P1M",2970 });2971 await db.insert("subscriptions", {2972 projectId: PROJECT_ID,2973 platform: "Android",2974 purchaseToken: TOKEN,2975 productId: "premium_monthly",2976 state: "Active",2977 willRenew: true,2978 updatedAt: 0,2979 ...(userId ? { userId } : {}),2980 });2981 };2982 2983 // Token possession is not proof of ownership, and a distinct rejection would2984 // tell any holder of the app-embedded publishable key that a token exists2985 // and belongs to someone.2986 it("reports an already-bound subscription the same as an unknown token", async () => {2987 const owned = new MemDb();2988 await seedBound(owned, "victim");2989 const unknown = new MemDb();2990 await seedBound(unknown, "victim");2991 await unknown.delete(unknown.rows("subscriptions")[0]._id);2992 2993 const attacker = {2994 projectId: PROJECT_ID as never,2995 purchaseToken: TOKEN,2996 userId: "attacker",2997 };2998 await expect(2999 bindSubscriptionToUserHandler(makeCtx(owned) as never, attacker),3000 ).resolves.toBeNull();3001 await expect(3002 bindSubscriptionToUserHandler(makeCtx(unknown) as never, attacker),3003 ).resolves.toBeNull();3004 expect(owned.rows("subscriptions")[0].userId).toBe("victim");3005 });3006 3007 // Erasure unlinked the previous owner. Once the job is gone the record is3008 // unowned, and an operator may associate it again exactly as a purchase can.3009 it("rebinds a record whose erased owner has already been unlinked", async () => {3010 const db = new MemDb();3011 await seedBound(db, undefined);3012 await db.patch(db.rows("subscriptions")[0]._id, { accountErased: true });3013 expect(3014 await rebindSubscriptionToUserHandler(makeCtx(db), {3015 projectId: PROJECT_ID as never,3016 purchaseToken: TOKEN,3017 userId: "real-owner",3018 }),3019 ).not.toBeNull();3020 const row = db.rows("subscriptions")[0];3021 expect(row.userId).toBe("real-owner");3022 expect(row.accountErased).toBeUndefined();3023 });3024 3025 it("still refuses to move a record away from its live owner", async () => {3026 const db = new MemDb();3027 await seedBound(db, "real-owner");3028 expect(3029 await bindSubscriptionToUserHandler(makeCtx(db), {3030 projectId: PROJECT_ID as never,3031 purchaseToken: TOKEN,3032 userId: "someone-else",3033 }),3034 ).toBeNull();3035 expect(db.rows("subscriptions")[0].userId).toBe("real-owner");3036 });3037 3038 it.each(["queued", "running", "completed"])(3039 "refuses rebind into or out of an account with a %s erasure job",3040 async (status) => {3041 for (const erasedUser of ["source-user", "target-user"]) {3042 const db = new MemDb();3043 await seedBound(db, "source-user");3044 const hashKey = "erasure-test-key";3045 await db.patch(PROJECT_ID, { userErasureHashKey: hashKey });3046 await db.insert("subscriptionUserErasureJobs", {3047 projectId: PROJECT_ID,3048 userIdHash: await hmacSha256Hex(hashKey, erasedUser),3049 status,3050 });3051 const before = structuredClone(db.rows("subscriptions"));3052 expect(3053 await rebindSubscriptionToUserHandler(makeCtx(db), {3054 projectId: PROJECT_ID as never,3055 purchaseToken: TOKEN,3056 userId: "target-user",3057 }),3058 ).toBeNull();3059 expect(db.rows("subscriptions")).toEqual(before);3060 expect(db.rows("commerceEvents")).toEqual([]);3061 }3062 },3063 );3064 3065 // A consumer gating access on commerce events must be told the purchase3066 // moved, or the wrong user keeps access and the real one never gets it.3067 it("revokes the old user and grants the new one on a rebind", async () => {3068 const db = new MemDb();3069 db.seedProduct({3070 projectId: PROJECT_ID,3071 platform: "Android",3072 productId: "premium_monthly",3073 billingPeriod: "P1M",3074 });3075 const priorId = await seedWebhookEvent(db, {3076 type: "SubscriptionStarted",3077 notificationId: "rebind-source",3078 occurredAt: 1_000,3079 });3080 await db.insert("subscriptions", {3081 projectId: PROJECT_ID,3082 platform: "Android",3083 purchaseToken: TOKEN,3084 productId: "premium_monthly",3085 state: "Active",3086 willRenew: true,3087 userId: "wrong-user",3088 lastEventId: priorId,3089 lastEventOccurredAt: 1_000,3090 lastEventSourceNotificationId: "rebind-source",3091 lastEventSource: {3092 type: "SubscriptionStarted",3093 environment: "Production",3094 productId: "premium_monthly",3095 },3096 expiresAt: Date.now() + 86_400_000,3097 updatedAt: 0,3098 });3099 3100 await rebindSubscriptionToUserHandler(makeCtx(db), {3101 projectId: PROJECT_ID as never,3102 purchaseToken: TOKEN,3103 userId: "real-owner",3104 });3105 3106 const emitted = db3107 .rows("commerceEvents")3108 .map((r) => [r.eventType, r.userId]);3109 expect(emitted).toEqual([3110 ["entitlement.revoked", "wrong-user"],3111 ["entitlement.granted", "real-owner"],3112 ]);3113 });3114 3115 // A rebind that cannot attribute events leaves the developer backend3116 // believing the old user still owns the purchase. Say so rather than3117 // reporting plain success.3118 it("reports a rebind it could not notify about", async () => {3119 const db = new MemDb();3120 db.seedProduct({3121 projectId: PROJECT_ID,3122 platform: "Android",3123 productId: "premium_monthly",3124 billingPeriod: "P1M",3125 });3126 await db.insert("subscriptions", {3127 projectId: PROJECT_ID,3128 platform: "Android",3129 purchaseToken: TOKEN,3130 productId: "premium_monthly",3131 state: "Active",3132 willRenew: true,3133 userId: "wrong-user",3134 expiresAt: Date.now() + 86_400_000,3135 updatedAt: 0,3136 });3137 3138 const outcome = await rebindSubscriptionToUserHandler(makeCtx(db), {3139 projectId: PROJECT_ID as never,3140 purchaseToken: TOKEN,3141 userId: "real-owner",3142 });3143 expect(outcome?.notified).toBe(false);3144 expect(db.rows("subscriptions")[0].userId).toBe("real-owner");3145 expect(db.rows("commerceEvents")).toEqual([]);3146 });3147 3148 it("lets an operator move a wrong binding", async () => {3149 const db = new MemDb();3150 await seedBound(db, "wrong-user");3151 await expect(3152 rebindSubscriptionToUserHandler(makeCtx(db) as never, {3153 projectId: PROJECT_ID as never,3154 purchaseToken: TOKEN,3155 userId: "real-owner",3156 }),3157 ).resolves.not.toBeNull();3158 expect(db.rows("subscriptions")[0].userId).toBe("real-owner");3159 });3160});3161