1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";2 3import { compareValues } from "convex/values";4import type { Doc, Id } from "../_generated/dataModel";5import { accessAt } from "./accessHistory";6import { addSubscriptionToCohorts, type CohortBucket } from "./revenueCohorts";7import { normalizeAppleAsn } from "../webhooks/shared";8import { HarmonizedPurchaseState } from "../purchases/purchaseState";9import { hmacSha256Hex } from "../utils/sha256";10import {11 applySubscriptionEventHandler,12 bindSubscriptionToUserHandler,13 rebindSubscriptionToUserHandler,14 drainSubscriptionUserErasurePage,15 buildVerifiedSubscriptionSnapshot,16 getCurrentProductIdByTokenHandler,17 getSourceProductIdByTokenHandler,18 mergeVerifiedSubscriptionSnapshot,19 recordVerifiedSubscriptionHandler,20 persistSubscriptionSnapshot,21} from "./internal";22 23type Row = Record<string, unknown> & { _id: string; _creationTime: number };24 25class MemQuery {26 constructor(private rows: Row[]) {}27 28 withIndex(_name: string, cb?: (q: IndexBuilder) => IndexBuilder): MemQuery {29 if (!cb) return this;30 const builder = new IndexBuilder();31 cb(builder);32 return new MemQuery(33 this.rows.filter((row) =>34 builder.predicates.every((predicate) => predicate(row)),35 ),36 );37 }38 39 async unique(): Promise<Row | null> {40 if (this.rows.length > 1) {41 throw new Error("unique() called on a query that returned > 1 row");42 }43 return this.rows[0] ?? null;44 }45 46 async collect(): Promise<Row[]> {47 return [...this.rows];48 }49 50 async paginate(options: { numItems: number; cursor: string | null }) {51 const key = (row: Row) => [row._creationTime, row._id];52 const boundary =53 options.cursor === null ? null : JSON.parse(options.cursor);54 const ordered = [...this.rows].sort((a, b) =>55 compareValues(key(a), key(b)),56 );57 const remaining = ordered.filter(58 (row) => boundary === null || compareValues(key(row), boundary) > 0,59 );60 const page = remaining.slice(0, options.numItems);61 return {62 page: structuredClone(page),63 isDone: page.length === remaining.length,64 continueCursor: page.length65 ? JSON.stringify(key(page[page.length - 1]))66 : (options.cursor ?? ""),67 };68 }69 70 async take(count: number): Promise<Row[]> {71 return this.rows.slice(0, count);72 }73}74 75class IndexBuilder {76 predicates: Array<(row: Row) => boolean> = [];77 78 eq(field: string, value: unknown): IndexBuilder {79 this.predicates.push((row) => row[field] === value);80 return this;81 }82}83 84class MemDb {85 tables = new Map<string, Map<string, Row>>();86 private counter = 0;87 88 constructor() {89 this.table("organizations").set("organizations_seed_1", {90 _id: "organizations_seed_1",91 _creationTime: Date.now(),92 });93 this.table("projects").set("projects_seed_1", {94 _id: "projects_seed_1",95 _creationTime: Date.now(),96 organizationId: "organizations_seed_1",97 });98 }99 100 private table(name: string): Map<string, Row> {101 let table = this.tables.get(name);102 if (!table) {103 table = new Map();104 this.tables.set(name, table);105 }106 return table;107 }108 109 query(tableName: string): MemQuery {110 return new MemQuery([...this.table(tableName).values()]);111 }112 113 async insert(114 tableName: string,115 doc: Record<string, unknown>,116 ): Promise<string> {117 const id = `${tableName}_${++this.counter}`;118 this.table(tableName).set(id, {119 ...doc,120 _id: id,121 _creationTime: Date.now() + this.counter / 1_000,122 });123 return id;124 }125 126 async get(id: string): Promise<Row | null> {127 for (const table of this.tables.values()) {128 const row = table.get(id);129 if (row) return row;130 }131 return null;132 }133 134 async patch(id: string, patch: Record<string, unknown>): Promise<void> {135 for (const table of this.tables.values()) {136 const row = table.get(id);137 if (row) {138 Object.assign(row, patch);139 return;140 }141 }142 throw new Error(`patch: no doc with id ${id}`);143 }144 145 async delete(id: string): Promise<void> {146 for (const table of this.tables.values()) {147 if (table.delete(id)) return;148 }149 }150 151 rows(tableName: string): Row[] {152 return [...this.table(tableName).values()];153 }154 155 seedProduct(doc: {156 projectId: string;157 platform: "IOS" | "Android";158 productId: string;159 billingPeriod?: string;160 }): void {161 this.table("products").set(`products_${++this.counter}`, {162 _id: `products_${this.counter}`,163 _creationTime: Date.now(),164 ...doc,165 });166 }167}168 169function makeCtx(db: MemDb) {170 return { db, scheduler: { runAfter: async () => null } } as never;171}172 173const PROJECT_ID = "projects_seed_1";174const TOKEN = "purchase_token_1";175 176describe("entitlement events across the expiry deadline", () => {177 it.each([false, true])(178 "revokes once when the clock expires before notifications (cancel first: %s)",179 async (cancelFirst) => {180 vi.useFakeTimers();181 try {182 vi.setSystemTime(1_000);183 const db = new MemDb();184 const started = await seedWebhookEvent(db, {185 type: "SubscriptionStarted",186 notificationId: "clock-start",187 occurredAt: 1_000,188 });189 await db.patch(started, { expiresAt: 2_000, renewsAt: 2_000 });190 await applySubscriptionEventHandler(makeCtx(db), {191 projectId: PROJECT_ID as never,192 eventId: started as never,193 });194 await bindSubscriptionToUserHandler(makeCtx(db), {195 projectId: PROJECT_ID as never,196 purchaseToken: TOKEN,197 userId: "clock-user",198 });199 vi.setSystemTime(3_000);200 if (cancelFirst) {201 const canceled = await seedWebhookEvent(db, {202 type: "SubscriptionStarted",203 notificationId: "clock-cancel",204 occurredAt: 2_500,205 });206 await db.patch(canceled, {207 type: "SubscriptionCanceled",208 expiresAt: 2_000,209 renewsAt: undefined,210 willRenew: false,211 });212 await applySubscriptionEventHandler(makeCtx(db), {213 projectId: PROJECT_ID as never,214 eventId: canceled as never,215 });216 }217 const expired = await seedWebhookEvent(db, {218 type: "SubscriptionExpired",219 notificationId: "clock-expire",220 occurredAt: 3_000,221 });222 await db.patch(expired, { expiresAt: 2_000 });223 const args = {224 projectId: PROJECT_ID as never,225 eventId: expired as never,226 };227 await applySubscriptionEventHandler(makeCtx(db), args);228 await applySubscriptionEventHandler(makeCtx(db), args);229 expect(230 db231 .rows("commerceEvents")232 .filter((row) => String(row.eventType).startsWith("entitlement."))233 .map((row) => row.eventType),234 ).toEqual(["entitlement.granted", "entitlement.revoked"]);235 } finally {236 vi.useRealTimers();237 }238 },239 );240});241 242async function seedWebhookEvent(243 db: MemDb,244 args: {245 type:246 | "SubscriptionStarted"247 | "SubscriptionRenewed"248 | "SubscriptionExpired"249 | "SubscriptionProductChanged";250 notificationId: string;251 occurredAt: number;252 platform?: "IOS" | "Android";253 productId?: string;254 environment?: "Production" | "Sandbox";255 },256): Promise<string> {257 const platform = args.platform ?? "Android";258 return await db.insert("webhookEvents", {259 projectId: PROJECT_ID,260 type: args.type,261 source:262 platform === "IOS"263 ? "AppleAppStoreServerNotificationsV2"264 : "GooglePlayRealTimeDeveloperNotifications",265 platform,266 environment: args.environment ?? "Production",267 purchaseToken: TOKEN,268 sourceNotificationId: args.notificationId,269 productId: args.productId ?? "premium_monthly",270 subscriptionState:271 args.type === "SubscriptionExpired" ? "Expired" : "Active",272 expiresAt: 1_800_000_000_000,273 renewsAt: 1_800_000_000_000,274 currency: "USD",275 priceAmountMicros: 9_990_000,276 occurredAt: args.occurredAt,277 receivedAt: args.occurredAt,278 });279}280 281describe("subscription erasure across replacement tokens", () => {282 it.each([false, true])(283 "reconciles a granted successor without restoring erased identities (successor erasure requested: %s)",284 async (successorErased) => {285 const db = new MemDb();286 const ctx = makeCtx(db);287 const hashKey = "erasure-test-key";288 await db.patch(PROJECT_ID, { userErasureHashKey: hashKey });289 async function erasureJob(userId: string): Promise<string> {290 return db.insert("subscriptionUserErasureJobs", {291 projectId: PROJECT_ID,292 userId,293 userIdHash: await hmacSha256Hex(hashKey, userId),294 status: "queued",295 subscriptionsErased: 0,296 commerceEventsErased: 0,297 });298 }299 await recordVerifiedSubscriptionHandler(ctx, {300 projectId: PROJECT_ID as never,301 platform: "Android",302 purchaseToken: TOKEN,303 productId: "premium_monthly",304 purchaseState: "ENTITLED",305 });306 await bindSubscriptionToUserHandler(ctx, {307 projectId: PROJECT_ID as never,308 purchaseToken: TOKEN,309 userId: "erased-owner",310 });311 await drainSubscriptionUserErasurePage(312 ctx,313 (await erasureJob("erased-owner")) as never,314 );315 const startedId = await seedWebhookEvent(db, {316 type: "SubscriptionStarted",317 notificationId: "successor-granted",318 occurredAt: 1_000,319 });320 await db.patch(startedId, {321 purchaseToken: "replacement",322 expiresAt: Date.now() + 60_000,323 });324 await applySubscriptionEventHandler(ctx, {325 projectId: PROJECT_ID as never,326 eventId: startedId as never,327 });328 await bindSubscriptionToUserHandler(ctx, {329 projectId: PROJECT_ID as never,330 purchaseToken: "replacement",331 userId: "successor-owner",332 });333 const grant = db334 .rows("commerceEvents")335 .find((event) => event.eventType === "entitlement.granted");336 expect(grant?.userId).toBe("successor-owner");337 338 const successorErasureJob = successorErased339 ? await erasureJob("successor-owner")340 : undefined;341 // A destination exists so an emitted revocation would be delivered;342 // none is, which is the point of the assertions below.343 await db.insert("outboundDestinations", {344 projectId: PROJECT_ID,345 enabled: true,346 eventTypes: ["entitlement.revoked"],347 });348 const linkedId = await seedWebhookEvent(db, {349 type: "SubscriptionProductChanged",350 notificationId: "late-erased-link",351 occurredAt: 2_000,352 });353 await db.patch(linkedId, {354 purchaseToken: "replacement",355 linkedPurchaseToken: TOKEN,356 });357 const args = {358 projectId: PROJECT_ID as never,359 eventId: linkedId as never,360 };361 await applySubscriptionEventHandler(ctx, args);362 await applySubscriptionEventHandler(ctx, args);363 364 // The erased owner was unlinked, not the subscription. The successor365 // bound the replacement token afterwards, so that live association366 // survives the merge and nothing is revoked.367 expect(db.rows("subscriptions")).toHaveLength(1);368 expect(db.rows("subscriptions")[0].accountErased).toBeUndefined();369 expect(db.rows("subscriptions")[0].userId).toBe("successor-owner");370 expect(371 db372 .rows("commerceEvents")373 .filter((event) => event.eventType === "entitlement.revoked"),374 ).toEqual([]);375 expect(db.rows("outboundDeliveries")).toEqual([]);376 if (successorErased) {377 await drainSubscriptionUserErasurePage(378 ctx,379 successorErasureJob as never,380 );381 expect(382 db.rows("commerceEvents").some((event) => event.userId !== undefined),383 ).toBe(false);384 }385 expect(386 JSON.stringify(387 [...db.tables.values()].map((rows) => [...rows.values()]),388 ),389 ).not.toContain("erased-owner");390 },391 );392 393 it.each([394 { erasedToken: TOKEN, successorExists: false, boundToken: undefined },395 { erasedToken: TOKEN, successorExists: true, boundToken: undefined },396 { erasedToken: TOKEN, successorExists: true, boundToken: "replacement" },397 { erasedToken: "replacement", successorExists: true, boundToken: TOKEN },398 ])(399 "merges an erased record without dropping a live binding: %j",400 async (testCase) => {401 const db = new MemDb();402 const ctx = makeCtx(db);403 for (const purchaseToken of testCase.successorExists404 ? [TOKEN, "replacement"]405 : [TOKEN]) {406 const id = await recordVerifiedSubscriptionHandler(ctx, {407 projectId: PROJECT_ID as never,408 platform: "Android",409 purchaseToken,410 productId: "premium_monthly",411 purchaseState: "ENTITLED",412 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",413 });414 if (purchaseToken === testCase.erasedToken)415 await db.patch(id!, { accountErased: true });416 if (purchaseToken === testCase.boundToken)417 await bindSubscriptionToUserHandler(ctx, {418 projectId: PROJECT_ID as never,419 purchaseToken,420 userId: "linked-owner",421 });422 }423 const eventId = await seedWebhookEvent(db, {424 type: "SubscriptionStarted",425 notificationId: "erased-replacement",426 occurredAt: Date.now(),427 });428 await db.patch(eventId, {429 purchaseToken: "replacement",430 linkedPurchaseToken: TOKEN,431 });432 await applySubscriptionEventHandler(ctx, {433 projectId: PROJECT_ID as never,434 eventId: eventId as never,435 });436 437 expect(db.rows("subscriptions")).toHaveLength(1);438 const merged = db.rows("subscriptions")[0];439 expect(merged.purchaseToken).toBe("replacement");440 // The merge must not attribute the record to anyone who did not bind it,441 // and it must not revoke the binder who did.442 expect(443 db444 .rows("commerceEvents")445 .every(446 (event) =>447 event.userId === undefined || event.userId === "linked-owner",448 ),449 ).toBe(true);450 expect(451 db452 .rows("commerceEvents")453 .some((event) => event.eventType === "entitlement.revoked"),454 ).toBe(false);455 if (testCase.boundToken) {456 // A live binding on either side is a later association than the erasure,457 // so it survives and the marker does not carry.458 expect(merged.userId).toBe("linked-owner");459 expect(merged.accountErased).toBeUndefined();460 } else {461 expect(merged.userId).toBeUndefined();462 expect(merged.accountErased).toBe(true);463 // Unowned again, so the record can be associated with someone new.464 expect(465 await bindSubscriptionToUserHandler(ctx, {466 projectId: PROJECT_ID as never,467 purchaseToken: "replacement",468 userId: "new-owner",469 }),470 ).not.toBeNull();471 expect(db.rows("subscriptions")[0].accountErased).toBeUndefined();472 }473 },474 );475});476 477describe("bindSubscriptionToUser amount handling", () => {478 it("does not repeat an amount the webhook already reported", async () => {479 const db = new MemDb();480 db.seedProduct({481 projectId: PROJECT_ID,482 platform: "Android",483 productId: "premium_monthly",484 billingPeriod: "P1M",485 });486 const eventId = await seedWebhookEvent(db, {487 type: "SubscriptionStarted",488 notificationId: "message-webhook-first",489 occurredAt: 1_000,490 });491 await applySubscriptionEventHandler(makeCtx(db), {492 projectId: PROJECT_ID as never,493 eventId: eventId as never,494 });495 const afterWebhook = db496 .rows("commerceEvents")497 .filter((row) => row.amountMicros !== undefined).length;498 499 await bindSubscriptionToUserHandler(makeCtx(db), {500 projectId: PROJECT_ID as never,501 purchaseToken: TOKEN,502 userId: "user_1",503 });504 505 const priced = db506 .rows("commerceEvents")507 .filter((row) => row.amountMicros !== undefined);508 // The bind grant correlates an existing purchase to a user; it is not a509 // second billing, so the count must not move.510 expect(priced.length).toBe(afterWebhook);511 expect(db.rows("commerceEvents").at(-1)?.eventType).toBe(512 "entitlement.granted",513 );514 expect(db.rows("commerceEvents").at(-1)?.amountMicros).toBeUndefined();515 });516});517 518describe("applySubscriptionEventHandler", () => {519 beforeEach(() => {520 vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));521 });522 523 afterEach(() => {524 vi.useRealTimers();525 });526 527 // Commerce Protocol's `whenNoPriorStoreEvent` turns on store history, not on528 // whether a record exists — a purchase learned from a client receipt but529 // never from the store still begins the story. That distinction lives here,530 // in the override, and nowhere in the state machine, so it is tested here.531 it("starts rather than recovers when a record exists with no store history", async () => {532 const db = new MemDb();533 db.seedProduct({534 projectId: PROJECT_ID,535 platform: "Android",536 productId: "premium_monthly",537 billingPeriod: "P1M",538 });539 // A client receipt created this row; no store notification ever touched it,540 // so it carries no lastEventId.541 await db.insert("subscriptions", {542 projectId: PROJECT_ID,543 platform: "Android",544 purchaseToken: TOKEN,545 productId: "premium_monthly",546 state: "Unknown",547 willRenew: true,548 updatedAt: 0,549 });550 const eventId = await seedWebhookEvent(db, {551 type: "SubscriptionStarted",552 notificationId: "message-first-store-event",553 occurredAt: 1_000,554 });555 556 await applySubscriptionEventHandler(makeCtx(db), {557 projectId: PROJECT_ID as never,558 eventId: eventId as never,559 });560 // Assert on what was emitted, not what the handler returned: the override561 // applies to the commerce event, while the return value carries the state562 // machine's own transition.563 expect(db.rows("commerceEvents").map((row) => row.eventType)).toContain(564 "subscription.started",565 );566 });567 568 it.each([["SubscriptionPriceChange"], ["SubscriptionDeferred"]])(569 "emits no commerce event when %s is the first store event for a receipt-bootstrapped row",570 async (storeEventType) => {571 // store-event-mapping.json pins whenNoPriorStoreEvent -> event: null for572 // price changes and deferrals: with no earlier store event there is no573 // baseline the event could describe.574 const db = new MemDb();575 db.seedProduct({576 projectId: PROJECT_ID,577 platform: "Android",578 productId: "premium_monthly",579 billingPeriod: "P1M",580 });581 await db.insert("subscriptions", {582 projectId: PROJECT_ID,583 platform: "Android",584 purchaseToken: TOKEN,585 productId: "premium_monthly",586 state: "Active",587 expiresAt: 9_999_999_999_999,588 willRenew: true,589 updatedAt: 0,590 });591 const eventId = await seedWebhookEvent(db, {592 type: storeEventType as never,593 notificationId: `message-${storeEventType}`,594 occurredAt: 1_000,595 });596 597 await applySubscriptionEventHandler(makeCtx(db), {598 projectId: PROJECT_ID as never,599 eventId: eventId as never,600 });601 expect(db.rows("commerceEvents")).toEqual([]);602 },603 );604 605 // Rows written before `lastEventOccurredAt` existed carry only `lastEventId`,606 // so the timestamp guard cannot judge them. Both of its fallbacks protect a607 // money path: a redelivery re-applies the transition and books it twice.608 it.each([609 ["the same event redelivered", "message-legacy", 500, "Expired"],610 ["an older event arriving late", "message-older", 200, "Expired"],611 ])(612 "drops %s against a legacy row",613 async (_name, notificationId, occurredAt, expected) => {614 const db = new MemDb();615 db.seedProduct({616 projectId: PROJECT_ID,617 platform: "Android",618 productId: "premium_monthly",619 billingPeriod: "P1M",620 });621 const priorId = await seedWebhookEvent(db, {622 type: "SubscriptionExpired",623 notificationId: "message-legacy",624 occurredAt: 500,625 });626 await db.insert("subscriptions", {627 projectId: PROJECT_ID,628 platform: "Android",629 purchaseToken: TOKEN,630 productId: "premium_monthly",631 state: "Expired",632 willRenew: false,633 lastEventId: priorId,634 updatedAt: 0,635 });636 const replayId =637 notificationId === "message-legacy"638 ? priorId639 : await seedWebhookEvent(db, {640 type: "SubscriptionStarted",641 notificationId,642 occurredAt,643 });644 645 await expect(646 applySubscriptionEventHandler(makeCtx(db), {647 projectId: PROJECT_ID as never,648 eventId: replayId as never,649 }),650 ).resolves.toMatchObject({ transition: null });651 expect(db.rows("subscriptions")).toMatchObject([{ state: expected }]);652 expect(db.rows("commerceEvents")).toEqual([]);653 },654 );655 656 it("recovers when the record already has store history", async () => {657 const db = new MemDb();658 db.seedProduct({659 projectId: PROJECT_ID,660 platform: "Android",661 productId: "premium_monthly",662 billingPeriod: "P1M",663 });664 const priorId = await seedWebhookEvent(db, {665 type: "SubscriptionExpired",666 notificationId: "message-prior",667 occurredAt: 500,668 });669 await db.insert("subscriptions", {670 projectId: PROJECT_ID,671 platform: "Android",672 purchaseToken: TOKEN,673 productId: "premium_monthly",674 state: "Expired",675 willRenew: false,676 lastEventId: priorId,677 updatedAt: 0,678 });679 const eventId = await seedWebhookEvent(db, {680 type: "SubscriptionStarted",681 notificationId: "message-after-history",682 occurredAt: 1_000,683 });684 685 await applySubscriptionEventHandler(makeCtx(db), {686 projectId: PROJECT_ID as never,687 eventId: eventId as never,688 });689 expect(db.rows("commerceEvents").map((row) => row.eventType)).toContain(690 "subscription.recovered",691 );692 });693 694 it("applies a recorded-but-unapplied event exactly once on redelivery", async () => {695 const db = new MemDb();696 db.seedProduct({697 projectId: PROJECT_ID,698 platform: "Android",699 productId: "premium_monthly",700 billingPeriod: "P1M",701 });702 const eventId = await seedWebhookEvent(db, {703 type: "SubscriptionStarted",704 notificationId: "message-a",705 occurredAt: 1_000,706 });707 const args = {708 projectId: PROJECT_ID as never,709 eventId: eventId as never,710 };711 712 await expect(713 applySubscriptionEventHandler(makeCtx(db), args),714 ).resolves.toMatchObject({ transition: "Started", active: true });715 const appliedAt = db.rows("webhookEvents")[0]?.appliedAt;716 717 await expect(718 applySubscriptionEventHandler(makeCtx(db), args),719 ).resolves.toMatchObject({ transition: null, active: true });720 expect(db.rows("webhookEvents")[0]?.appliedAt).toBe(appliedAt);721 expect(db.rows("subscriptions")).toMatchObject([722 { state: "Active", lastEventId: eventId },723 ]);724 expect(db.rows("subscriptionStats")).toMatchObject([725 { activeSubs: 1, mrrMicros: 9_990_000 },726 ]);727 });728 729 it("applies distinct same-timestamp events without replaying the old one", async () => {730 const db = new MemDb();731 db.seedProduct({732 projectId: PROJECT_ID,733 platform: "Android",734 productId: "premium_monthly",735 billingPeriod: "P1M",736 });737 const startedId = await seedWebhookEvent(db, {738 type: "SubscriptionStarted",739 notificationId: "message-a",740 occurredAt: 1_000,741 });742 const expiredId = await seedWebhookEvent(db, {743 type: "SubscriptionExpired",744 notificationId: "message-b",745 occurredAt: 1_000,746 });747 748 await applySubscriptionEventHandler(makeCtx(db), {749 projectId: PROJECT_ID as never,750 eventId: startedId as never,751 });752 await applySubscriptionEventHandler(makeCtx(db), {753 projectId: PROJECT_ID as never,754 eventId: expiredId as never,755 });756 await expect(757 applySubscriptionEventHandler(makeCtx(db), {758 projectId: PROJECT_ID as never,759 eventId: startedId as never,760 }),761 ).resolves.toMatchObject({ transition: null, active: false });762 763 expect(db.rows("subscriptions")).toMatchObject([764 { state: "Expired", lastEventId: expiredId },765 ]);766 expect(db.rows("subscriptionStats")).toMatchObject([767 { activeSubs: 0, mrrMicros: 0 },768 ]);769 });770 771 it("uses ingestion order to backfill a same-timestamp legacy event", async () => {772 const db = new MemDb();773 db.seedProduct({774 projectId: PROJECT_ID,775 platform: "Android",776 productId: "premium_monthly",777 billingPeriod: "P1M",778 });779 const startedId = await seedWebhookEvent(db, {780 type: "SubscriptionStarted",781 notificationId: "legacy-a",782 occurredAt: 1_000,783 });784 const expiredId = await seedWebhookEvent(db, {785 type: "SubscriptionExpired",786 notificationId: "legacy-b",787 occurredAt: 1_000,788 });789 await applySubscriptionEventHandler(makeCtx(db), {790 projectId: PROJECT_ID as never,791 eventId: startedId as never,792 });793 await applySubscriptionEventHandler(makeCtx(db), {794 projectId: PROJECT_ID as never,795 eventId: expiredId as never,796 });797 await db.patch(startedId, { appliedAt: undefined });798 799 await expect(800 applySubscriptionEventHandler(makeCtx(db), {801 projectId: PROJECT_ID as never,802 eventId: startedId as never,803 }),804 ).resolves.toMatchObject({ transition: null, active: false });805 806 expect(807 db.rows("webhookEvents").find((row) => row._id === startedId),808 ).toHaveProperty("appliedAt", Date.now());809 expect(db.rows("subscriptions")).toMatchObject([810 { state: "Expired", lastEventId: expiredId },811 ]);812 expect(db.rows("subscriptionStats")).toMatchObject([813 { activeSubs: 0, mrrMicros: 0 },814 ]);815 });816 817 it("keeps durable ordering after the previous webhook row is pruned", async () => {818 const db = new MemDb();819 const startedId = await seedWebhookEvent(db, {820 type: "SubscriptionStarted",821 notificationId: "newer",822 occurredAt: 2_000,823 });824 await applySubscriptionEventHandler(makeCtx(db), {825 projectId: PROJECT_ID as never,826 eventId: startedId as never,827 });828 await db.delete(startedId);829 const staleId = await seedWebhookEvent(db, {830 type: "SubscriptionExpired",831 notificationId: "older",832 occurredAt: 1_000,833 });834 835 await expect(836 applySubscriptionEventHandler(makeCtx(db), {837 projectId: PROJECT_ID as never,838 eventId: staleId as never,839 }),840 ).resolves.toMatchObject({ transition: null, active: true });841 expect(db.rows("subscriptions")).toMatchObject([842 {843 state: "Active",844 lastEventOccurredAt: 2_000,845 lastEventSourceNotificationId: "newer",846 },847 ]);848 });849 850 it("does not grant entitlement for an already-expired start event", async () => {851 const db = new MemDb();852 const eventId = await seedWebhookEvent(db, {853 type: "SubscriptionStarted",854 notificationId: "expired-start",855 occurredAt: 1_000,856 });857 await db.patch(eventId, { expiresAt: Date.now() - 1 });858 859 await expect(860 applySubscriptionEventHandler(makeCtx(db), {861 projectId: PROJECT_ID as never,862 eventId: eventId as never,863 }),864 ).resolves.toMatchObject({ transition: "Started", active: false });865 expect(db.rows("commerceEvents").map((row) => row.eventType)).toEqual([866 "subscription.started",867 ]);868 });869 870 it("records one-time events without creating subscription commerce", async () => {871 const db = new MemDb();872 const eventId = await seedWebhookEvent(db, {873 type: "SubscriptionStarted",874 notificationId: "one-time",875 occurredAt: 1_000,876 });877 await db.patch(eventId, { productKind: "one_time" });878 879 await expect(880 applySubscriptionEventHandler(makeCtx(db), {881 projectId: PROJECT_ID as never,882 eventId: eventId as never,883 }),884 ).resolves.toEqual({ transition: null, active: false });885 expect(db.rows("subscriptions")).toHaveLength(0);886 expect(db.rows("commerceEvents")).toHaveLength(0);887 });888 889 it("keeps an Apple renewal preference separate from the active product", async () => {890 const db = new MemDb();891 db.seedProduct({892 projectId: PROJECT_ID,893 platform: "IOS",894 productId: "premium_monthly",895 billingPeriod: "P1M",896 });897 const startedId = await seedWebhookEvent(db, {898 type: "SubscriptionStarted",899 notificationId: "apple-started",900 occurredAt: 1_000,901 platform: "IOS",902 });903 await applySubscriptionEventHandler(makeCtx(db), {904 projectId: PROJECT_ID as never,905 eventId: startedId as never,906 });907 const changedId = await seedWebhookEvent(db, {908 type: "SubscriptionProductChanged",909 notificationId: "apple-next-product",910 occurredAt: 2_000,911 platform: "IOS",912 productId: "premium_yearly",913 });914 await db.patch(changedId, { priceAmountMicros: 99_990_000 });915 916 await expect(917 applySubscriptionEventHandler(makeCtx(db), {918 projectId: PROJECT_ID as never,919 eventId: changedId as never,920 }),921 ).resolves.toMatchObject({ transition: "ProductChanged", active: true });922 expect(db.rows("subscriptions")).toMatchObject([923 {924 productId: "premium_monthly",925 platform: "IOS",926 priceAmountMicros: 9_990_000,927 },928 ]);929 expect(db.rows("commerceEvents").at(-1)).toMatchObject({930 eventType: "subscription.product_changed",931 productId: "premium_yearly",932 amountMicros: 99_990_000,933 subscription: { productId: "premium_monthly" },934 });935 await db.delete(changedId);936 937 await bindSubscriptionToUserHandler(makeCtx(db), {938 projectId: PROJECT_ID as never,939 purchaseToken: TOKEN,940 userId: "user-1",941 });942 expect(db.rows("commerceEvents").at(-1)).toMatchObject({943 eventType: "entitlement.granted",944 productId: "premium_monthly",945 userId: "user-1",946 });947 expect(db.rows("commerceEvents").at(-1)?.amountMicros).toBeUndefined();948 949 const renewedId = await seedWebhookEvent(db, {950 type: "SubscriptionRenewed",951 notificationId: "apple-renewed-on-new-product",952 occurredAt: 3_000,953 platform: "IOS",954 productId: "premium_yearly",955 });956 await applySubscriptionEventHandler(makeCtx(db), {957 projectId: PROJECT_ID as never,958 eventId: renewedId as never,959 });960 expect(db.rows("commerceEvents").at(-1)).toMatchObject({961 eventType: "subscription.renewed",962 productId: "premium_yearly",963 previousProductId: "premium_monthly",964 userId: "user-1",965 });966 });967 968 it("applies an Apple upgrade product immediately", async () => {969 const db = new MemDb();970 const startedId = await seedWebhookEvent(db, {971 type: "SubscriptionStarted",972 notificationId: "apple-started",973 occurredAt: 1_000,974 platform: "IOS",975 });976 await applySubscriptionEventHandler(makeCtx(db), {977 projectId: PROJECT_ID as never,978 eventId: startedId as never,979 });980 const changedId = await seedWebhookEvent(db, {981 type: "SubscriptionProductChanged",982 notificationId: "apple-upgrade",983 occurredAt: 2_000,984 platform: "IOS",985 productId: "premium_yearly",986 });987 await db.patch(changedId, { effectiveImmediately: true });988 989 await applySubscriptionEventHandler(makeCtx(db), {990 projectId: PROJECT_ID as never,991 eventId: changedId as never,992 });993 994 expect(db.rows("subscriptions")[0]).toMatchObject({995 productId: "premium_yearly",996 lastEventId: changedId,997 });998 });999 1000 it("includes the previous product for an applied Google item change", async () => {1001 const db = new MemDb();1002 const startedId = await seedWebhookEvent(db, {1003 type: "SubscriptionStarted",1004 notificationId: "google-started",1005 occurredAt: 1_000,1006 });1007 await applySubscriptionEventHandler(makeCtx(db), {1008 projectId: PROJECT_ID as never,1009 eventId: startedId as never,1010 });1011 const changedId = await seedWebhookEvent(db, {1012 type: "SubscriptionProductChanged",1013 notificationId: "google-item-change",1014 occurredAt: 2_000,1015 productId: "premium_yearly",1016 });1017 await applySubscriptionEventHandler(makeCtx(db), {1018 projectId: PROJECT_ID as never,1019 eventId: changedId as never,1020 });1021 1022 expect(db.rows("commerceEvents").at(-1)).toMatchObject({1023 eventType: "subscription.product_changed",1024 previousProductId: "premium_monthly",1025 productId: "premium_yearly",1026 subscription: { productId: "premium_yearly" },1027 });1028 });1029 1030 it("does not guess a product for a multi-item Google change", async () => {1031 const db = new MemDb();1032 const startedId = await seedWebhookEvent(db, {1033 type: "SubscriptionStarted",1034 notificationId: "google-bundle-started",1035 occurredAt: 1_000,1036 });1037 await applySubscriptionEventHandler(makeCtx(db), {1038 projectId: PROJECT_ID as never,1039 eventId: startedId as never,1040 });1041 const changedId = await db.insert("webhookEvents", {1042 projectId: PROJECT_ID,1043 type: "SubscriptionProductChanged",1044 source: "GooglePlayRealTimeDeveloperNotifications",1045 platform: "Android",1046 environment: "Production",1047 purchaseToken: TOKEN,1048 productKind: "subscription",1049 sourceNotificationId: "google-bundle-change",1050 occurredAt: 2_000,1051 receivedAt: 2_000,1052 });1053 1054 await expect(1055 applySubscriptionEventHandler(makeCtx(db), {1056 projectId: PROJECT_ID as never,1057 eventId: changedId as never,1058 }),1059 ).resolves.toMatchObject({ transition: null, active: true });1060 expect(db.rows("subscriptions")[0]).toMatchObject({1061 productId: "premium_monthly",1062 lastEventId: changedId,1063 lastEventOccurredAt: 2_000,1064 });1065 expect(db.rows("commerceEvents")).toHaveLength(1);1066 1067 const delayedId = await seedWebhookEvent(db, {1068 type: "SubscriptionExpired",1069 notificationId: "google-delayed-expiry",1070 occurredAt: 1_500,1071 });1072 await expect(1073 applySubscriptionEventHandler(makeCtx(db), {1074 projectId: PROJECT_ID as never,1075 eventId: delayedId as never,1076 }),1077 ).resolves.toMatchObject({ transition: null, active: true });1078 expect(db.rows("subscriptions")[0]).toMatchObject({1079 state: "Active",1080 lastEventId: changedId,1081 });1082 });1083 1084 it("moves a Google replacement flow onto the linked token row", async () => {1085 const db = new MemDb();1086 const startedId = await seedWebhookEvent(db, {1087 type: "SubscriptionStarted",1088 notificationId: "google-old-token",1089 occurredAt: 1_000,1090 });1091 await applySubscriptionEventHandler(makeCtx(db), {1092 projectId: PROJECT_ID as never,1093 eventId: startedId as never,1094 });1095 const changedId = await db.insert("webhookEvents", {1096 projectId: PROJECT_ID,1097 type: "SubscriptionProductChanged",1098 source: "GooglePlayRealTimeDeveloperNotifications",1099 platform: "Android",1100 environment: "Production",1101 purchaseToken: "purchase_token_2",1102 linkedPurchaseToken: TOKEN,1103 productKind: "subscription",1104 productId: "premium_yearly",1105 subscriptionState: "Active",1106 sourceNotificationId: "google-new-token",1107 occurredAt: 2_000,1108 receivedAt: 2_000,1109 });1110 await applySubscriptionEventHandler(makeCtx(db), {1111 projectId: PROJECT_ID as never,1112 eventId: changedId as never,1113 });1114 1115 expect(db.rows("subscriptions")).toHaveLength(1);1116 expect(db.rows("subscriptions")[0]).toMatchObject({1117 purchaseToken: "purchase_token_2",1118 productId: "premium_yearly",1119 });1120 expect(db.rows("commerceEvents").at(-1)).toMatchObject({1121 eventType: "subscription.product_changed",1122 previousProductId: "premium_monthly",1123 productId: "premium_yearly",1124 });1125 });1126 1127 it("classifies a linked Google purchase with a new product as a change", async () => {1128 const db = new MemDb();1129 const startedId = await seedWebhookEvent(db, {1130 type: "SubscriptionStarted",1131 notificationId: "google-linked-start-old",1132 occurredAt: 1_000,1133 });1134 await applySubscriptionEventHandler(makeCtx(db), {1135 projectId: PROJECT_ID as never,1136 eventId: startedId as never,1137 });1138 const replacementId = await db.insert("webhookEvents", {1139 projectId: PROJECT_ID,1140 type: "SubscriptionStarted",1141 source: "GooglePlayRealTimeDeveloperNotifications",1142 platform: "Android",1143 environment: "Production",1144 purchaseToken: "purchase_token_2",1145 linkedPurchaseToken: TOKEN,1146 productKind: "subscription",1147 productId: "premium_yearly",1148 subscriptionState: "Active",1149 sourceNotificationId: "google-linked-start-new",1150 occurredAt: 2_000,1151 receivedAt: 2_000,1152 });1153 1154 await expect(1155 applySubscriptionEventHandler(makeCtx(db), {1156 projectId: PROJECT_ID as never,1157 eventId: replacementId as never,1158 }),1159 ).resolves.toMatchObject({ transition: "ProductChanged", active: true });1160 1161 expect(1162 db1163 .rows("commerceEvents")1164 .filter((event) => event.sourceEventId === replacementId),1165 ).toEqual([1166 expect.objectContaining({1167 eventType: "subscription.product_changed",1168 previousProductId: "premium_monthly",1169 productId: "premium_yearly",1170 }),1171 ]);1172 });1173 1174 it("defers a same-product token handoff until the replacement renews", async () => {1175 const db = new MemDb();1176 const startedId = await seedWebhookEvent(db, {1177 type: "SubscriptionStarted",1178 notificationId: "google-deferred-start-old",1179 occurredAt: 1_000,1180 });1181 await applySubscriptionEventHandler(makeCtx(db), {1182 projectId: PROJECT_ID as never,1183 eventId: startedId as never,1184 });1185 const handoffId = await db.insert("webhookEvents", {1186 projectId: PROJECT_ID,1187 type: "SubscriptionStarted",1188 source: "GooglePlayRealTimeDeveloperNotifications",1189 platform: "Android",1190 environment: "Production",1191 purchaseToken: "purchase_token_2",1192 linkedPurchaseToken: TOKEN,1193 productKind: "subscription",1194 productId: "premium_monthly",1195 subscriptionState: "Active",1196 sourceNotificationId: "google-deferred-token-handoff",1197 occurredAt: 2_000,1198 receivedAt: 2_000,1199 });1200 await expect(1201 applySubscriptionEventHandler(makeCtx(db), {1202 projectId: PROJECT_ID as never,1203 eventId: handoffId as never,1204 }),1205 ).resolves.toMatchObject({ transition: null, active: true });1206 expect(1207 db1208 .rows("commerceEvents")1209 .filter((event) => event.sourceEventId === handoffId),1210 ).toEqual([]);1211 1212 const predecessorExpiryId = await db.insert("webhookEvents", {1213 projectId: PROJECT_ID,1214 type: "SubscriptionExpired",1215 source: "GooglePlayRealTimeDeveloperNotifications",1216 platform: "Android",1217 environment: "Production",1218 purchaseToken: TOKEN,1219 productKind: "subscription",1220 productId: "premium_monthly",1221 subscriptionState: "Expired",1222 expiresAt: 2_500,1223 sourceNotificationId: "google-deferred-predecessor-expired",1224 occurredAt: 2_500,1225 receivedAt: 2_500,1226 });1227 await expect(1228 applySubscriptionEventHandler(makeCtx(db), {1229 projectId: PROJECT_ID as never,1230 eventId: predecessorExpiryId as never,1231 }),1232 ).resolves.toMatchObject({ transition: null, active: true });1233 expect(db.rows("subscriptions")).toHaveLength(1);1234 expect(db.rows("subscriptions")[0]).toMatchObject({1235 purchaseToken: "purchase_token_2",1236 state: "Active",1237 });1238 expect(1239 db1240 .rows("commerceEvents")1241 .filter((event) => event.sourceEventId === predecessorExpiryId),1242 ).toEqual([]);1243 expect(db.rows("subscriptionTokenAliases")).toMatchObject([1244 {1245 purchaseToken: TOKEN,1246 successorPurchaseToken: "purchase_token_2",1247 },1248 ]);1249 1250 const successor = db.rows("subscriptions")[0];1251 const statsBeforeStaleVerification = structuredClone(1252 db.rows("subscriptionStats"),1253 );1254 await expect(1255 recordVerifiedSubscriptionHandler(makeCtx(db), {1256 projectId: PROJECT_ID as never,1257 platform: "Android",1258 purchaseToken: TOKEN,1259 productId: "stale_monthly",1260 purchaseState: "ENTITLED",1261 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",1262 expiresAt: 2_600,1263 willRenew: false,1264 }),1265 ).resolves.toBe(successor._id);1266 expect(db.rows("subscriptions")).toMatchObject([1267 {1268 _id: successor._id,1269 purchaseToken: "purchase_token_2",1270 productId: "premium_monthly",1271 state: "Active",1272 },1273 ]);1274 expect(db.rows("subscriptionStats")).toEqual(statsBeforeStaleVerification);1275 1276 await expect(1277 bindSubscriptionToUserHandler(makeCtx(db), {1278 projectId: PROJECT_ID as never,1279 purchaseToken: TOKEN,1280 userId: "user-after-handoff",1281 }),1282 ).resolves.toBe(successor._id);1283 expect(db.rows("subscriptions")).toMatchObject([1284 {1285 _id: successor._id,1286 purchaseToken: "purchase_token_2",1287 userId: "user-after-handoff",1288 },1289 ]);1290 expect(db.rows("subscriptions")).toHaveLength(1);1291 1292 const renewalId = await db.insert("webhookEvents", {1293 projectId: PROJECT_ID,1294 type: "SubscriptionRenewed",1295 source: "GooglePlayRealTimeDeveloperNotifications",1296 platform: "Android",1297 environment: "Production",1298 purchaseToken: "purchase_token_2",1299 linkedPurchaseToken: TOKEN,1300 productKind: "subscription",1301 productId: "premium_yearly",1302 subscriptionState: "Active",1303 sourceNotificationId: "google-deferred-first-renewal",1304 occurredAt: 3_000,1305 receivedAt: 3_000,1306 });1307 await applySubscriptionEventHandler(makeCtx(db), {1308 projectId: PROJECT_ID as never,1309 eventId: renewalId as never,1310 });1311 1312 expect(db.rows("subscriptions")[0]).toMatchObject({1313 purchaseToken: "purchase_token_2",1314 productId: "premium_yearly",1315 });1316 expect(1317 db1318 .rows("commerceEvents")1319 .filter((event) => event.sourceEventId === renewalId),1320 ).toEqual([1321 expect.objectContaining({1322 eventType: "subscription.renewed",1323 previousProductId: "premium_monthly",1324 productId: "premium_yearly",1325 }),1326 ]);1327 });1328 1329 it("resolves predecessor aliases beyond 32 replacement hops", async () => {1330 const db = new MemDb();1331 const hopCount = 40;1332 for (let index = 0; index < hopCount; index += 1) {1333 await db.insert("subscriptionTokenAliases", {1334 projectId: PROJECT_ID,1335 purchaseToken: `token_${index}`,1336 successorPurchaseToken: `token_${index + 1}`,1337 predecessorProductId:1338 index === 0 ? "premium_monthly" : "premium_yearly",1339 createdAt: index,1340 updatedAt: index,1341 });1342 }1343 const subscriptionId = await db.insert("subscriptions", {1344 projectId: PROJECT_ID,1345 purchaseToken: `token_${hopCount}`,1346 productKind: "subscription",1347 productId: "premium_yearly",1348 platform: "Android",1349 state: "Active",1350 expiresAt: 1_900_000_000_000,1351 willRenew: true,1352 startedAt: 1,1353 updatedAt: 1,1354 });1355 await db.insert("subscriptions", {1356 projectId: PROJECT_ID,1357 purchaseToken: "token_20",1358 productKind: "subscription",1359 productId: "stale_intermediate",1360 platform: "Android",1361 state: "Expired",1362 expiresAt: 2_000,1363 willRenew: false,1364 startedAt: 1,1365 updatedAt: 1,1366 });1367 const expiredId = await db.insert("webhookEvents", {1368 projectId: PROJECT_ID,1369 type: "SubscriptionExpired",1370 source: "GooglePlayRealTimeDeveloperNotifications",1371 platform: "Android",1372 environment: "Production",1373 purchaseToken: "token_0",1374 productKind: "subscription",1375 productId: "premium_monthly",1376 sourceNotificationId: "old-chain-expired",1377 occurredAt: 2_000,1378 receivedAt: 2_000,1379 });1380 1381 await expect(1382 getSourceProductIdByTokenHandler(makeCtx(db), {1383 projectId: PROJECT_ID as never,1384 purchaseToken: "token_0",1385 }),1386 ).resolves.toBe("premium_monthly");1387 await expect(1388 getCurrentProductIdByTokenHandler(makeCtx(db), {1389 projectId: PROJECT_ID as never,1390 purchaseToken: "token_0",1391 }),1392 ).resolves.toBe("premium_yearly");1393 1394 await expect(1395 applySubscriptionEventHandler(makeCtx(db), {1396 projectId: PROJECT_ID as never,1397 eventId: expiredId as never,1398 }),1399 ).resolves.toEqual({1400 transition: null,1401 active: true,1402 subscriptionId,1403 });1404 expect(db.rows("subscriptions")).toHaveLength(2);1405 expect(1406 db.rows("subscriptions").find((row) => row._id === subscriptionId),1407 ).toMatchObject({1408 purchaseToken: `token_${hopCount}`,1409 productId: "premium_yearly",1410 state: "Active",1411 });1412 expect(db.rows("commerceEvents")).toEqual([]);1413 });1414 1415 it("emits predecessor refunds and revocations without deactivating the successor", async () => {1416 const db = new MemDb();1417 const subscriptionId = await db.insert("subscriptions", {1418 projectId: PROJECT_ID,1419 purchaseToken: "current_token",1420 productKind: "subscription",1421 productId: "premium_yearly",1422 platform: "Android",1423 state: "Active",1424 expiresAt: 1_900_000_000_000,1425 willRenew: true,1426 startedAt: 1,1427 updatedAt: 1,1428 });1429 await db.insert("subscriptionTokenAliases", {1430 projectId: PROJECT_ID,1431 purchaseToken: "old_token",1432 successorPurchaseToken: "current_token",1433 predecessorProductId: "premium_monthly",1434 createdAt: 1,1435 updatedAt: 1,1436 });1437 1438 const cases = [1439 ["PurchaseRefunded", "Refunded", "subscription.refunded"],1440 ["SubscriptionRevoked", "Revoked", "subscription.revoked"],1441 ] as const;1442 for (const [type, transition, eventType] of cases) {1443 const eventId = await db.insert("webhookEvents", {1444 projectId: PROJECT_ID,1445 type,1446 source: "GooglePlayRealTimeDeveloperNotifications",1447 platform: "Android",1448 environment: "Production",1449 purchaseToken: "old_token",1450 productKind: "subscription",1451 productId: "premium_monthly",1452 sourceNotificationId: `old-token-${type}`,1453 occurredAt: 2_000,1454 receivedAt: 2_000,1455 });1456 await expect(1457 applySubscriptionEventHandler(makeCtx(db), {1458 projectId: PROJECT_ID as never,1459 eventId: eventId as never,1460 }),1461 ).resolves.toEqual({ transition, active: true, subscriptionId });1462 expect(1463 db1464 .rows("commerceEvents")1465 .filter((event) => event.sourceEventId === eventId)1466 .map((event) => [1467 event.eventType,1468 event.productId,1469 (event.subscription as { state?: string } | undefined)?.state,1470 event.entitlementActive,1471 ]),1472 ).toEqual([[eventType, "premium_monthly", transition, false]]);1473 }1474 expect(db.rows("subscriptions")).toMatchObject([1475 {1476 purchaseToken: "current_token",1477 productId: "premium_yearly",1478 state: "Active",1479 },1480 ]);1481 });1482 1483 it("classifies a same-product linked prepaid extension as a renewal", async () => {1484 const db = new MemDb();1485 const startedId = await seedWebhookEvent(db, {1486 type: "SubscriptionStarted",1487 notificationId: "google-prepaid-start",1488 occurredAt: 1_000,1489 });1490 await applySubscriptionEventHandler(makeCtx(db), {1491 projectId: PROJECT_ID as never,1492 eventId: startedId as never,1493 });1494 const topUpId = await db.insert("webhookEvents", {1495 projectId: PROJECT_ID,1496 type: "SubscriptionStarted",1497 source: "GooglePlayRealTimeDeveloperNotifications",1498 platform: "Android",1499 environment: "Production",1500 purchaseToken: "purchase_token_2",1501 linkedPurchaseToken: TOKEN,1502 productKind: "subscription",1503 productId: "premium_monthly",1504 subscriptionState: "Active",1505 expiresAt: 1_900_000_000_000,1506 willRenew: false,1507 sourceNotificationId: "google-prepaid-top-up",1508 occurredAt: 2_000,1509 receivedAt: 2_000,1510 });1511 1512 await expect(1513 applySubscriptionEventHandler(makeCtx(db), {1514 projectId: PROJECT_ID as never,1515 eventId: topUpId as never,1516 }),1517 ).resolves.toMatchObject({ transition: "Renewed", active: true });1518 expect(1519 db1520 .rows("commerceEvents")1521 .filter((event) => event.sourceEventId === topUpId),1522 ).toEqual([1523 expect.objectContaining({1524 eventType: "subscription.renewed",1525 productId: "premium_monthly",1526 subscription: expect.objectContaining({1527 expiresAt: 1_900_000_000_000,1528 willRenew: false,1529 }),1530 }),1531 ]);1532 expect(1533 db.rows("commerceEvents").find((event) => event.sourceEventId === topUpId)1534 ?.subscription,1535 ).not.toHaveProperty("renewsAt");1536 expect(db.rows("subscriptions")[0]).toMatchObject({1537 willRenew: false,1538 expiresAt: 1_900_000_000_000,1539 });1540 expect(db.rows("subscriptions")[0]?.renewsAt).toBeUndefined();1541 });1542 1543 it("classifies an active same-product linked resubscribe as uncanceled", async () => {1544 const db = new MemDb();1545 const startedId = await seedWebhookEvent(db, {1546 type: "SubscriptionStarted",1547 notificationId: "google-resubscribe-start",1548 occurredAt: 1_000,1549 });1550 await applySubscriptionEventHandler(makeCtx(db), {1551 projectId: PROJECT_ID as never,1552 eventId: startedId as never,1553 });1554 const canceledId = await db.insert("webhookEvents", {1555 projectId: PROJECT_ID,1556 type: "SubscriptionCanceled",1557 source: "GooglePlayRealTimeDeveloperNotifications",1558 platform: "Android",1559 environment: "Production",1560 purchaseToken: TOKEN,1561 productKind: "subscription",1562 productId: "premium_monthly",1563 subscriptionState: "Active",1564 expiresAt: 1_800_000_000_000,1565 willRenew: false,1566 sourceNotificationId: "google-resubscribe-canceled",1567 occurredAt: 2_000,1568 receivedAt: 2_000,1569 });1570 await applySubscriptionEventHandler(makeCtx(db), {1571 projectId: PROJECT_ID as never,1572 eventId: canceledId as never,1573 });1574 const resubscribedId = await db.insert("webhookEvents", {1575 projectId: PROJECT_ID,1576 type: "SubscriptionStarted",1577 source: "GooglePlayRealTimeDeveloperNotifications",1578 platform: "Android",1579 environment: "Production",1580 purchaseToken: "purchase_token_2",1581 linkedPurchaseToken: TOKEN,1582 productKind: "subscription",1583 productId: "premium_monthly",1584 subscriptionState: "Active",1585 expiresAt: 1_800_000_000_000,1586 willRenew: true,1587 sourceNotificationId: "google-resubscribe-linked",1588 occurredAt: 3_000,1589 receivedAt: 3_000,1590 });1591 1592 await expect(1593 applySubscriptionEventHandler(makeCtx(db), {1594 projectId: PROJECT_ID as never,1595 eventId: resubscribedId as never,1596 }),1597 ).resolves.toMatchObject({ transition: "Uncanceled", active: true });1598 expect(1599 db1600 .rows("commerceEvents")1601 .filter((event) => event.sourceEventId === resubscribedId)1602 .map((event) => event.eventType),1603 ).toEqual(["subscription.uncanceled"]);1604 });1605 1606 it("recovers a linked inactive predecessor regardless of verification order", async () => {1607 const db = new MemDb();1608 const startedId = await seedWebhookEvent(db, {1609 type: "SubscriptionStarted",1610 notificationId: "google-order-start",1611 occurredAt: 1_000,1612 });1613 await applySubscriptionEventHandler(makeCtx(db), {1614 projectId: PROJECT_ID as never,1615 eventId: startedId as never,1616 });1617 const expiredId = await seedWebhookEvent(db, {1618 type: "SubscriptionExpired",1619 notificationId: "google-order-expired",1620 occurredAt: 2_000,1621 });1622 await applySubscriptionEventHandler(makeCtx(db), {1623 projectId: PROJECT_ID as never,1624 eventId: expiredId as never,1625 });1626 await recordVerifiedSubscriptionHandler(makeCtx(db), {1627 projectId: PROJECT_ID as never,1628 platform: "Android",1629 purchaseToken: "purchase_token_2",1630 productId: "premium_monthly",1631 purchaseState: "ENTITLED",1632 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",1633 expiresAt: 1_900_000_000_000,1634 });1635 const recoveredId = await db.insert("webhookEvents", {1636 projectId: PROJECT_ID,1637 type: "SubscriptionStarted",1638 source: "GooglePlayRealTimeDeveloperNotifications",1639 platform: "Android",1640 environment: "Production",1641 purchaseToken: "purchase_token_2",1642 linkedPurchaseToken: TOKEN,1643 productKind: "subscription",1644 productId: "premium_monthly",1645 subscriptionState: "Active",1646 expiresAt: 1_900_000_000_000,1647 willRenew: true,1648 sourceNotificationId: "google-order-linked",1649 occurredAt: 3_000,1650 receivedAt: 3_000,1651 });1652 1653 await expect(1654 applySubscriptionEventHandler(makeCtx(db), {1655 projectId: PROJECT_ID as never,1656 eventId: recoveredId as never,1657 }),1658 ).resolves.toMatchObject({ transition: "Recovered", active: true });1659 expect(1660 db1661 .rows("commerceEvents")1662 .filter((event) => event.sourceEventId === recoveredId)1663 .map((event) => event.eventType),1664 ).toEqual(["subscription.recovered"]);1665 });1666 1667 it("recovers an inactive predecessor through a linked Google purchase", async () => {1668 const db = new MemDb();1669 const startedId = await seedWebhookEvent(db, {1670 type: "SubscriptionStarted",1671 notificationId: "google-linked-inactive-start",1672 occurredAt: 1_000,1673 });1674 await applySubscriptionEventHandler(makeCtx(db), {1675 projectId: PROJECT_ID as never,1676 eventId: startedId as never,1677 });1678 const expiredId = await seedWebhookEvent(db, {1679 type: "SubscriptionExpired",1680 notificationId: "google-linked-inactive-expired",1681 occurredAt: 2_000,1682 });1683 await applySubscriptionEventHandler(makeCtx(db), {1684 projectId: PROJECT_ID as never,1685 eventId: expiredId as never,1686 });1687 const recoveredId = await db.insert("webhookEvents", {1688 projectId: PROJECT_ID,1689 type: "SubscriptionStarted",1690 source: "GooglePlayRealTimeDeveloperNotifications",1691 platform: "Android",1692 environment: "Production",1693 purchaseToken: "purchase_token_2",1694 linkedPurchaseToken: TOKEN,1695 productKind: "subscription",1696 productId: "premium_monthly",1697 subscriptionState: "Active",1698 sourceNotificationId: "google-linked-inactive-repurchase",1699 occurredAt: 3_000,1700 receivedAt: 3_000,1701 });1702 1703 await expect(1704 applySubscriptionEventHandler(makeCtx(db), {1705 projectId: PROJECT_ID as never,1706 eventId: recoveredId as never,1707 }),1708 ).resolves.toMatchObject({ transition: "Recovered", active: true });1709 expect(1710 db1711 .rows("commerceEvents")1712 .filter((event) => event.sourceEventId === recoveredId)1713 .map((event) => event.eventType),1714 ).toEqual(["subscription.recovered"]);1715 });1716 1717 it("applies a delayed replacement after the predecessor expires", async () => {1718 const db = new MemDb();1719 const startedId = await seedWebhookEvent(db, {1720 type: "SubscriptionStarted",1721 notificationId: "google-predecessor-started",1722 occurredAt: 1_000,1723 });1724 await applySubscriptionEventHandler(makeCtx(db), {1725 projectId: PROJECT_ID as never,1726 eventId: startedId as never,1727 });1728 const expiredId = await seedWebhookEvent(db, {1729 type: "SubscriptionExpired",1730 notificationId: "google-predecessor-expired",1731 occurredAt: 3_000,1732 });1733 await applySubscriptionEventHandler(makeCtx(db), {1734 projectId: PROJECT_ID as never,1735 eventId: expiredId as never,1736 });1737 const changedId = await db.insert("webhookEvents", {1738 projectId: PROJECT_ID,1739 type: "SubscriptionProductChanged",1740 source: "GooglePlayRealTimeDeveloperNotifications",1741 platform: "Android",1742 environment: "Production",1743 purchaseToken: "purchase_token_2",1744 linkedPurchaseToken: TOKEN,1745 productKind: "subscription",1746 productId: "premium_yearly",1747 subscriptionState: "Active",1748 sourceNotificationId: "google-delayed-replacement",1749 occurredAt: 2_000,1750 receivedAt: 4_000,1751 });1752 1753 await applySubscriptionEventHandler(makeCtx(db), {1754 projectId: PROJECT_ID as never,1755 eventId: changedId as never,1756 });1757 1758 expect(db.rows("subscriptions")).toHaveLength(1);1759 expect(db.rows("subscriptions")[0]).toMatchObject({1760 purchaseToken: "purchase_token_2",1761 productId: "premium_yearly",1762 state: "Active",1763 lastEventId: changedId,1764 });1765 expect(1766 db.rows("webhookEvents").find((row) => row._id === changedId),1767 ).toHaveProperty("appliedAt");1768 expect(db.rows("commerceEvents")).toContainEqual(1769 expect.objectContaining({1770 eventType: "subscription.product_changed",1771 previousProductId: "premium_monthly",1772 productId: "premium_yearly",1773 }),1774 );1775 });1776 1777 it("keeps verification-first replacement state over a later predecessor expiry", async () => {1778 const db = new MemDb();1779 db.seedProduct({1780 projectId: PROJECT_ID,1781 platform: "Android",1782 productId: "premium_monthly",1783 billingPeriod: "P1M",1784 });1785 db.seedProduct({1786 projectId: PROJECT_ID,1787 platform: "Android",1788 productId: "premium_yearly",1789 billingPeriod: "P1Y",1790 });1791 const startedId = await seedWebhookEvent(db, {1792 type: "SubscriptionStarted",1793 notificationId: "google-old-token",1794 occurredAt: 1_000,1795 });1796 await applySubscriptionEventHandler(makeCtx(db), {1797 projectId: PROJECT_ID as never,1798 eventId: startedId as never,1799 });1800 await bindSubscriptionToUserHandler(makeCtx(db), {1801 projectId: PROJECT_ID as never,1802 purchaseToken: TOKEN,1803 userId: "user_1",1804 });1805 await recordVerifiedSubscriptionHandler(makeCtx(db), {1806 projectId: PROJECT_ID as never,1807 platform: "Android",1808 purchaseToken: "purchase_token_2",1809 productId: "premium_yearly",1810 purchaseState: "ENTITLED",1811 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",1812 });1813 expect(db.rows("subscriptions")).toHaveLength(2);1814 expect(1815 db1816 .rows("subscriptionStats")1817 .reduce((total, row) => total + Number(row.activeSubs), 0),1818 ).toBe(2);1819 1820 const expiredId = await db.insert("webhookEvents", {1821 projectId: PROJECT_ID,1822 type: "SubscriptionExpired",1823 source: "GooglePlayRealTimeDeveloperNotifications",1824 platform: "Android",1825 environment: "Production",1826 purchaseToken: TOKEN,1827 productKind: "subscription",1828 productId: "premium_monthly",1829 subscriptionState: "Expired",1830 sourceNotificationId: "google-old-token-expired",1831 occurredAt: 3_000,1832 receivedAt: 3_000,1833 });1834 await applySubscriptionEventHandler(makeCtx(db), {1835 projectId: PROJECT_ID as never,1836 eventId: expiredId as never,1837 });1838 1839 const changedId = await db.insert("webhookEvents", {1840 projectId: PROJECT_ID,1841 type: "SubscriptionProductChanged",1842 source: "GooglePlayRealTimeDeveloperNotifications",1843 platform: "Android",1844 environment: "Production",1845 purchaseToken: "purchase_token_2",1846 linkedPurchaseToken: TOKEN,1847 productKind: "subscription",1848 productId: "premium_yearly",1849 subscriptionState: "Active",1850 sourceNotificationId: "google-new-token",1851 occurredAt: 2_000,1852 receivedAt: 2_000,1853 });1854 await applySubscriptionEventHandler(makeCtx(db), {1855 projectId: PROJECT_ID as never,1856 eventId: changedId as never,1857 });1858 1859 expect(db.rows("subscriptions")).toHaveLength(1);1860 expect(db.rows("subscriptions")[0]).toMatchObject({1861 purchaseToken: "purchase_token_2",1862 productId: "premium_yearly",1863 userId: "user_1",1864 state: "Active",1865 lastEventId: changedId,1866 });1867 expect(1868 db1869 .rows("subscriptionStats")1870 .reduce((total, row) => total + Number(row.activeSubs), 0),1871 ).toBe(1);1872 });1873 1874 it("preserves active predecessor semantics after replacement verification", async () => {1875 const db = new MemDb();1876 const startedId = await seedWebhookEvent(db, {1877 type: "SubscriptionStarted",1878 notificationId: "google-active-predecessor",1879 occurredAt: 1_000,1880 });1881 await applySubscriptionEventHandler(makeCtx(db), {1882 projectId: PROJECT_ID as never,1883 eventId: startedId as never,1884 });1885 await recordVerifiedSubscriptionHandler(makeCtx(db), {1886 projectId: PROJECT_ID as never,1887 platform: "Android",1888 purchaseToken: "purchase_token_2",1889 productId: "premium_yearly",1890 purchaseState: "ENTITLED",1891 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",1892 });1893 const changedId = await db.insert("webhookEvents", {1894 projectId: PROJECT_ID,1895 type: "SubscriptionProductChanged",1896 source: "GooglePlayRealTimeDeveloperNotifications",1897 platform: "Android",1898 environment: "Production",1899 purchaseToken: "purchase_token_2",1900 linkedPurchaseToken: TOKEN,1901 productKind: "subscription",1902 productId: "premium_yearly",1903 subscriptionState: "Active",1904 sourceNotificationId: "google-verified-replacement",1905 occurredAt: 2_000,1906 receivedAt: 2_000,1907 });1908 1909 await applySubscriptionEventHandler(makeCtx(db), {1910 projectId: PROJECT_ID as never,1911 eventId: changedId as never,1912 });1913 1914 expect(db.rows("subscriptions")).toHaveLength(1);1915 expect(db.rows("subscriptions")[0]).toMatchObject({1916 purchaseToken: "purchase_token_2",1917 productId: "premium_yearly",1918 state: "Active",1919 lastEventId: changedId,1920 });1921 expect(1922 db1923 .rows("commerceEvents")1924 .filter((event) => event.sourceEventId === changedId),1925 ).toEqual([1926 expect.objectContaining({1927 eventType: "subscription.product_changed",1928 previousProductId: "premium_monthly",1929 productId: "premium_yearly",1930 }),1931 ]);1932 });1933 1934 it("emits a verified replacement when multi-item enrichment omits the product", async () => {1935 const db = new MemDb();1936 const startedId = await seedWebhookEvent(db, {1937 type: "SubscriptionStarted",1938 notificationId: "google-active-predecessor-ambiguous",1939 occurredAt: 1_000,1940 });1941 await applySubscriptionEventHandler(makeCtx(db), {1942 projectId: PROJECT_ID as never,1943 eventId: startedId as never,1944 });1945 await recordVerifiedSubscriptionHandler(makeCtx(db), {1946 projectId: PROJECT_ID as never,1947 platform: "Android",1948 purchaseToken: "purchase_token_2",1949 productId: "premium_yearly",1950 purchaseState: "ENTITLED",1951 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",1952 });1953 const changedId = await db.insert("webhookEvents", {1954 projectId: PROJECT_ID,1955 type: "SubscriptionProductChanged",1956 source: "GooglePlayRealTimeDeveloperNotifications",1957 platform: "Android",1958 environment: "Production",1959 purchaseToken: "purchase_token_2",1960 linkedPurchaseToken: TOKEN,1961 productKind: "subscription",1962 subscriptionState: "Active",1963 sourceNotificationId: "google-verified-ambiguous-replacement",1964 occurredAt: 2_000,1965 receivedAt: 2_000,1966 });1967 1968 await applySubscriptionEventHandler(makeCtx(db), {1969 projectId: PROJECT_ID as never,1970 eventId: changedId as never,1971 });1972 1973 expect(db.rows("subscriptions")).toHaveLength(1);1974 expect(db.rows("subscriptions")[0]).toMatchObject({1975 purchaseToken: "purchase_token_2",1976 productId: "premium_yearly",1977 state: "Active",1978 lastEventId: changedId,1979 });1980 expect(1981 db1982 .rows("commerceEvents")1983 .filter((event) => event.sourceEventId === changedId),1984 ).toEqual([1985 expect.objectContaining({1986 eventType: "subscription.product_changed",1987 previousProductId: "premium_monthly",1988 productId: "premium_yearly",1989 }),1990 ]);1991 });1992 1993 it("rejects linked Google tokens bound to different users", async () => {1994 const db = new MemDb();1995 const startedId = await seedWebhookEvent(db, {1996 type: "SubscriptionStarted",1997 notificationId: "google-old-token",1998 occurredAt: 1_000,1999 });2000 await applySubscriptionEventHandler(makeCtx(db), {2001 projectId: PROJECT_ID as never,2002 eventId: startedId as never,2003 });2004 await bindSubscriptionToUserHandler(makeCtx(db), {2005 projectId: PROJECT_ID as never,2006 purchaseToken: TOKEN,2007 userId: "user_1",2008 });2009 await recordVerifiedSubscriptionHandler(makeCtx(db), {2010 projectId: PROJECT_ID as never,2011 platform: "Android",2012 purchaseToken: "purchase_token_2",2013 productId: "premium_yearly",2014 purchaseState: "ENTITLED",2015 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2016 });2017 await bindSubscriptionToUserHandler(makeCtx(db), {2018 projectId: PROJECT_ID as never,2019 purchaseToken: "purchase_token_2",2020 userId: "user_2",2021 });2022 const changedId = await db.insert("webhookEvents", {2023 projectId: PROJECT_ID,2024 type: "SubscriptionProductChanged",2025 source: "GooglePlayRealTimeDeveloperNotifications",2026 platform: "Android",2027 environment: "Production",2028 purchaseToken: "purchase_token_2",2029 linkedPurchaseToken: TOKEN,2030 productKind: "subscription",2031 productId: "premium_yearly",2032 sourceNotificationId: "google-conflicting-token",2033 occurredAt: 2_000,2034 receivedAt: 2_000,2035 });2036 2037 await expect(2038 applySubscriptionEventHandler(makeCtx(db), {2039 projectId: PROJECT_ID as never,2040 eventId: changedId as never,2041 }),2042 ).rejects.toThrow("different users");2043 expect(db.rows("subscriptions")).toHaveLength(2);2044 });2045 2046 it("keeps a newer store-governed replacement row over an older linked event", async () => {2047 const db = new MemDb();2048 const oldStartedId = await seedWebhookEvent(db, {2049 type: "SubscriptionStarted",2050 notificationId: "google-old-token",2051 occurredAt: 1_000,2052 });2053 await applySubscriptionEventHandler(makeCtx(db), {2054 projectId: PROJECT_ID as never,2055 eventId: oldStartedId as never,2056 });2057 const newStartedId = await db.insert("webhookEvents", {2058 projectId: PROJECT_ID,2059 type: "SubscriptionStarted",2060 source: "GooglePlayRealTimeDeveloperNotifications",2061 platform: "Android",2062 environment: "Production",2063 purchaseToken: "purchase_token_2",2064 productKind: "subscription",2065 productId: "premium_monthly",2066 subscriptionState: "Active",2067 sourceNotificationId: "google-new-token-started",2068 occurredAt: 3_000,2069 receivedAt: 3_000,2070 });2071 await applySubscriptionEventHandler(makeCtx(db), {2072 projectId: PROJECT_ID as never,2073 eventId: newStartedId as never,2074 });2075 const olderLinkedId = await db.insert("webhookEvents", {2076 projectId: PROJECT_ID,2077 type: "SubscriptionProductChanged",2078 source: "GooglePlayRealTimeDeveloperNotifications",2079 platform: "Android",2080 environment: "Production",2081 purchaseToken: "purchase_token_2",2082 linkedPurchaseToken: TOKEN,2083 productKind: "subscription",2084 productId: "premium_yearly",2085 subscriptionState: "Active",2086 sourceNotificationId: "google-older-linked-event",2087 occurredAt: 2_000,2088 receivedAt: 4_000,2089 });2090 2091 await applySubscriptionEventHandler(makeCtx(db), {2092 projectId: PROJECT_ID as never,2093 eventId: olderLinkedId as never,2094 });2095 2096 expect(db.rows("subscriptions")).toHaveLength(1);2097 expect(db.rows("subscriptions")[0]).toMatchObject({2098 purchaseToken: "purchase_token_2",2099 productId: "premium_monthly",2100 lastEventId: newStartedId,2101 });2102 expect(2103 db.rows("webhookEvents").find((row) => row._id === olderLinkedId),2104 ).toHaveProperty("appliedAt");2105 expect(db.rows("subscriptionTokenAliases")).toMatchObject([2106 {2107 purchaseToken: TOKEN,2108 successorPurchaseToken: "purchase_token_2",2109 },2110 ]);2111 2112 const latePredecessorExpiryId = await db.insert("webhookEvents", {2113 projectId: PROJECT_ID,2114 type: "SubscriptionExpired",2115 source: "GooglePlayRealTimeDeveloperNotifications",2116 platform: "Android",2117 environment: "Production",2118 purchaseToken: TOKEN,2119 productKind: "subscription",2120 productId: "premium_monthly",2121 subscriptionState: "Expired",2122 sourceNotificationId: "google-late-predecessor-expired",2123 occurredAt: 4_000,2124 receivedAt: 4_000,2125 });2126 await expect(2127 applySubscriptionEventHandler(makeCtx(db), {2128 projectId: PROJECT_ID as never,2129 eventId: latePredecessorExpiryId as never,2130 }),2131 ).resolves.toMatchObject({ transition: null, active: true });2132 expect(db.rows("subscriptions")).toHaveLength(1);2133 expect(2134 db2135 .rows("commerceEvents")2136 .filter((event) => event.sourceEventId === latePredecessorExpiryId),2137 ).toEqual([]);2138 });2139 2140 it("keeps current-token state when the predecessor expires later", async () => {2141 const db = new MemDb();2142 const oldStartedId = await seedWebhookEvent(db, {2143 type: "SubscriptionStarted",2144 notificationId: "google-predecessor-started",2145 occurredAt: 1_000,2146 });2147 await applySubscriptionEventHandler(makeCtx(db), {2148 projectId: PROJECT_ID as never,2149 eventId: oldStartedId as never,2150 });2151 const replacementStartedId = await db.insert("webhookEvents", {2152 projectId: PROJECT_ID,2153 type: "SubscriptionStarted",2154 source: "GooglePlayRealTimeDeveloperNotifications",2155 platform: "Android",2156 environment: "Production",2157 purchaseToken: "purchase_token_2",2158 productKind: "subscription",2159 productId: "premium_monthly",2160 subscriptionState: "Active",2161 sourceNotificationId: "google-replacement-started",2162 occurredAt: 2_000,2163 receivedAt: 2_000,2164 });2165 await applySubscriptionEventHandler(makeCtx(db), {2166 projectId: PROJECT_ID as never,2167 eventId: replacementStartedId as never,2168 });2169 const predecessorExpiredId = await db.insert("webhookEvents", {2170 projectId: PROJECT_ID,2171 type: "SubscriptionExpired",2172 source: "GooglePlayRealTimeDeveloperNotifications",2173 platform: "Android",2174 environment: "Production",2175 purchaseToken: TOKEN,2176 productKind: "subscription",2177 productId: "premium_monthly",2178 subscriptionState: "Expired",2179 sourceNotificationId: "google-predecessor-expired",2180 occurredAt: 3_000,2181 receivedAt: 3_000,2182 });2183 await applySubscriptionEventHandler(makeCtx(db), {2184 projectId: PROJECT_ID as never,2185 eventId: predecessorExpiredId as never,2186 });2187 const linkedId = await db.insert("webhookEvents", {2188 projectId: PROJECT_ID,2189 type: "SubscriptionProductChanged",2190 source: "GooglePlayRealTimeDeveloperNotifications",2191 platform: "Android",2192 environment: "Production",2193 purchaseToken: "purchase_token_2",2194 linkedPurchaseToken: TOKEN,2195 productKind: "subscription",2196 productId: "premium_yearly",2197 subscriptionState: "Active",2198 sourceNotificationId: "google-linked-replacement",2199 occurredAt: 4_000,2200 receivedAt: 4_000,2201 });2202 2203 await applySubscriptionEventHandler(makeCtx(db), {2204 projectId: PROJECT_ID as never,2205 eventId: linkedId as never,2206 });2207 2208 expect(db.rows("subscriptions")).toHaveLength(1);2209 expect(db.rows("subscriptions")[0]).toMatchObject({2210 purchaseToken: "purchase_token_2",2211 productId: "premium_yearly",2212 state: "Active",2213 lastEventId: linkedId,2214 });2215 });2216 2217 it("moves a multi-item Google replacement token without guessing a product", async () => {2218 const db = new MemDb();2219 const startedId = await seedWebhookEvent(db, {2220 type: "SubscriptionStarted",2221 notificationId: "google-old-token",2222 occurredAt: 1_000,2223 });2224 await applySubscriptionEventHandler(makeCtx(db), {2225 projectId: PROJECT_ID as never,2226 eventId: startedId as never,2227 });2228 const changedId = await db.insert("webhookEvents", {2229 projectId: PROJECT_ID,2230 type: "SubscriptionProductChanged",2231 source: "GooglePlayRealTimeDeveloperNotifications",2232 platform: "Android",2233 environment: "Production",2234 purchaseToken: "purchase_token_2",2235 linkedPurchaseToken: TOKEN,2236 productKind: "subscription",2237 sourceNotificationId: "google-bundle-change",2238 occurredAt: 2_000,2239 receivedAt: 2_000,2240 });2241 2242 await applySubscriptionEventHandler(makeCtx(db), {2243 projectId: PROJECT_ID as never,2244 eventId: changedId as never,2245 });2246 2247 expect(db.rows("subscriptions")).toHaveLength(1);2248 expect(db.rows("subscriptions")[0]).toMatchObject({2249 purchaseToken: "purchase_token_2",2250 productId: "premium_monthly",2251 lastEventId: changedId,2252 });2253 expect(db.rows("commerceEvents")).toHaveLength(1);2254 });2255});2256 2257describe("buildVerifiedSubscriptionSnapshot", () => {2258 it("bootstraps an active subscription from an entitled Google verification", () => {2259 const snapshot = buildVerifiedSubscriptionSnapshot({2260 platform: "Android",2261 productId: "premium_monthly",2262 purchaseState: HarmonizedPurchaseState.ENTITLED,2263 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2264 expiresAt: 2_000_000_000_000,2265 });2266 2267 expect(snapshot).toEqual({2268 productId: "premium_monthly",2269 state: "Active",2270 expiresAt: 2_000_000_000_000,2271 renewsAt: undefined,2272 willRenew: true,2273 cancellationReason: undefined,2274 clearCancellationReason: true,2275 currency: undefined,2276 priceAmountMicros: undefined,2277 });2278 });2279 2280 it("treats pending-acknowledgment subscriptions as entitled while still bindable", () => {2281 const snapshot = buildVerifiedSubscriptionSnapshot({2282 platform: "Android",2283 productId: "premium_monthly",2284 purchaseState: HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT,2285 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2286 });2287 2288 expect(snapshot).toMatchObject({2289 productId: "premium_monthly",2290 state: "Active",2291 willRenew: true,2292 clearCancellationReason: true,2293 });2294 });2295 2296 it("stamps Refunded only when Apple says the revocation was a refund", () => {2297 // revocationReason 1 is Apple's app-issue refund; 0 also covers Family2298 // Sharing loss, where asserting money moved back would be false.2299 const refunded = buildVerifiedSubscriptionSnapshot({2300 platform: "IOS",2301 productId: "premium_monthly",2302 purchaseState: HarmonizedPurchaseState.CANCELED,2303 revocationReasonIOS: 1,2304 });2305 expect(refunded).toMatchObject({2306 state: "Revoked",2307 willRenew: false,2308 cancellationReason: "Refunded",2309 });2310 2311 const familySharingLoss = buildVerifiedSubscriptionSnapshot({2312 platform: "IOS",2313 productId: "premium_monthly",2314 purchaseState: HarmonizedPurchaseState.CANCELED,2315 revocationReasonIOS: 0,2316 });2317 expect(familySharingLoss).toMatchObject({2318 state: "Revoked",2319 willRenew: false,2320 });2321 expect(familySharingLoss?.cancellationReason).toBeUndefined();2322 2323 const unknown = buildVerifiedSubscriptionSnapshot({2324 platform: "IOS",2325 productId: "premium_monthly",2326 purchaseState: HarmonizedPurchaseState.CANCELED,2327 });2328 expect(unknown?.cancellationReason).toBeUndefined();2329 });2330 2331 it("preserves access for canceled Google subscriptions until expiry", () => {2332 const snapshot = buildVerifiedSubscriptionSnapshot({2333 platform: "Android",2334 productId: "premium_monthly",2335 purchaseState: HarmonizedPurchaseState.CANCELED,2336 subscriptionState: "SUBSCRIPTION_STATE_CANCELED",2337 expiresAt: 2_000_000_000_000,2338 });2339 2340 expect(snapshot).toMatchObject({2341 productId: "premium_monthly",2342 state: "Active",2343 willRenew: false,2344 expiresAt: 2_000_000_000_000,2345 });2346 expect(snapshot?.cancellationReason).toBeUndefined();2347 });2348 2349 it("maps on-hold Google subscriptions to billing retry", () => {2350 const snapshot = buildVerifiedSubscriptionSnapshot({2351 platform: "Android",2352 productId: "premium_monthly",2353 purchaseState: HarmonizedPurchaseState.PENDING,2354 subscriptionState: "SUBSCRIPTION_STATE_ON_HOLD",2355 });2356 2357 expect(snapshot).toMatchObject({2358 productId: "premium_monthly",2359 state: "InBillingRetry",2360 cancellationReason: "BillingError",2361 });2362 });2363 2364 it("does not infer renewal status when Google omits subscriptionState", () => {2365 const snapshot = buildVerifiedSubscriptionSnapshot({2366 platform: "Android",2367 productId: "premium_monthly",2368 purchaseState: HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT,2369 });2370 2371 expect(snapshot).toMatchObject({2372 productId: "premium_monthly",2373 state: "Active",2374 });2375 expect(snapshot?.willRenew).toBeUndefined();2376 expect(snapshot?.cancellationReason).toBeUndefined();2377 expect(snapshot?.clearCancellationReason).toBeUndefined();2378 });2379 2380 it("does not create a subscription row for expected-product mismatches", () => {2381 const snapshot = buildVerifiedSubscriptionSnapshot({2382 platform: "Android",2383 productId: "premium_monthly",2384 purchaseState: HarmonizedPurchaseState.INAUTHENTIC,2385 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2386 });2387 2388 expect(snapshot).toBeNull();2389 });2390 2391 it("does not create a subscription row when Google omits the product id", () => {2392 const snapshot = buildVerifiedSubscriptionSnapshot({2393 platform: "Android",2394 productId: "unknown",2395 purchaseState: HarmonizedPurchaseState.ENTITLED,2396 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2397 });2398 2399 expect(snapshot).toBeNull();2400 });2401 2402 it("bootstraps an Apple subscription without guessing auto-renew status", () => {2403 const snapshot = buildVerifiedSubscriptionSnapshot({2404 platform: "IOS",2405 productId: "com.example.premium",2406 purchaseState: HarmonizedPurchaseState.ENTITLED,2407 expiresAt: 2_000_000_000_000,2408 currency: "USD",2409 priceAmountMicros: 9_990_000,2410 });2411 2412 expect(snapshot).toEqual({2413 productId: "com.example.premium",2414 state: "Active",2415 expiresAt: 2_000_000_000_000,2416 renewsAt: undefined,2417 cancellationReason: undefined,2418 clearCancellationReason: true,2419 currency: "USD",2420 priceAmountMicros: 9_990_000,2421 });2422 });2423});2424 2425describe("mergeVerifiedSubscriptionSnapshot", () => {2426 it("preserves existing timing and price fields when verify omits them", () => {2427 const snapshot = mergeVerifiedSubscriptionSnapshot(2428 {2429 expiresAt: 2_000_000_000_000,2430 renewsAt: 2_000_000_000_000,2431 willRenew: true,2432 cancellationReason: undefined,2433 currency: "USD",2434 priceAmountMicros: 9_990_000,2435 },2436 {2437 productId: "premium_monthly",2438 state: "Active",2439 willRenew: true,2440 },2441 );2442 2443 expect(snapshot).toEqual({2444 productId: "premium_monthly",2445 state: "Active",2446 expiresAt: 2_000_000_000_000,2447 renewsAt: 2_000_000_000_000,2448 willRenew: true,2449 cancellationReason: undefined,2450 currency: "USD",2451 priceAmountMicros: 9_990_000,2452 });2453 });2454 2455 it("clears stale cancellation reason when verified snapshots request it", () => {2456 const existing = {2457 expiresAt: undefined,2458 renewsAt: undefined,2459 willRenew: false,2460 cancellationReason: "UserCanceled" as const,2461 currency: undefined,2462 priceAmountMicros: undefined,2463 };2464 const appleSnapshot = buildVerifiedSubscriptionSnapshot({2465 platform: "IOS",2466 productId: "premium_monthly",2467 purchaseState: HarmonizedPurchaseState.ENTITLED,2468 });2469 const graceSnapshot = buildVerifiedSubscriptionSnapshot({2470 platform: "Android",2471 productId: "premium_monthly",2472 purchaseState: HarmonizedPurchaseState.ENTITLED,2473 subscriptionState: "SUBSCRIPTION_STATE_IN_GRACE_PERIOD",2474 });2475 2476 expect(appleSnapshot?.clearCancellationReason).toBe(true);2477 expect(graceSnapshot?.clearCancellationReason).toBe(true);2478 2479 const appleMerged = mergeVerifiedSubscriptionSnapshot(2480 existing,2481 appleSnapshot!,2482 );2483 const graceMerged = mergeVerifiedSubscriptionSnapshot(2484 existing,2485 graceSnapshot!,2486 );2487 2488 expect(appleMerged.cancellationReason).toBeUndefined();2489 expect(graceMerged.cancellationReason).toBeUndefined();2490 expect(graceMerged.willRenew).toBe(true);2491 });2492 2493 it("preserves cancellation reason when verify cannot prove auto-renew is enabled", () => {2494 const snapshot = mergeVerifiedSubscriptionSnapshot(2495 {2496 expiresAt: undefined,2497 renewsAt: undefined,2498 willRenew: false,2499 cancellationReason: "UserCanceled",2500 currency: undefined,2501 priceAmountMicros: undefined,2502 },2503 {2504 productId: "premium_monthly",2505 state: "Active",2506 },2507 );2508 2509 expect(snapshot.cancellationReason).toBe("UserCanceled");2510 expect(snapshot.willRenew).toBe(false);2511 });2512});2513 2514describe("recordVerifiedSubscriptionHandler", () => {2515 beforeEach(() => {2516 vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));2517 });2518 2519 afterEach(() => {2520 vi.useRealTimers();2521 });2522 2523 it("rejects a receipt-verification write once project deletion starts", async () => {2524 const db = new MemDb();2525 await db.patch(PROJECT_ID, { pendingDeletion: true });2526 2527 await expect(2528 recordVerifiedSubscriptionHandler(makeCtx(db), {2529 projectId: PROJECT_ID as never,2530 platform: "Android",2531 purchaseToken: TOKEN,2532 productId: "premium_monthly",2533 purchaseState: HarmonizedPurchaseState.ENTITLED,2534 }),2535 ).rejects.toThrow("Project not found");2536 expect(db.rows("subscriptions")).toEqual([]);2537 expect(db.rows("subscriptionStats")).toEqual([]);2538 });2539 2540 it("creates a bindable subscription row from Google receipt verification", async () => {2541 const db = new MemDb();2542 db.seedProduct({2543 projectId: PROJECT_ID,2544 platform: "Android",2545 productId: "premium_monthly",2546 billingPeriod: "P1M",2547 });2548 2549 const subscriptionId = await recordVerifiedSubscriptionHandler(2550 makeCtx(db),2551 {2552 projectId: PROJECT_ID as never,2553 platform: "Android",2554 purchaseToken: TOKEN,2555 productId: "premium_monthly",2556 purchaseState: HarmonizedPurchaseState.ENTITLED,2557 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2558 expiresAt: 1_769_904_000_000,2559 renewsAt: 1_769_904_000_000,2560 currency: "USD",2561 priceAmountMicros: 9_990_000,2562 },2563 );2564 2565 expect(subscriptionId).toBe("subscriptions_2");2566 expect(db.rows("subscriptions")).toMatchObject([2567 {2568 _id: "subscriptions_2",2569 projectId: PROJECT_ID,2570 purchaseToken: TOKEN,2571 productId: "premium_monthly",2572 platform: "Android",2573 state: "Active",2574 willRenew: true,2575 currency: "USD",2576 priceAmountMicros: 9_990_000,2577 },2578 ]);2579 expect(db.rows("subscriptionStats")).toMatchObject([2580 {2581 projectId: PROJECT_ID,2582 currency: "USD",2583 activeSubs: 1,2584 inGracePeriod: 0,2585 inBillingRetry: 0,2586 mrrMicros: 9_990_000,2587 },2588 ]);2589 });2590 2591 it("keeps a verified Google prepaid subscription non-renewing", async () => {2592 const db = new MemDb();2593 2594 await recordVerifiedSubscriptionHandler(makeCtx(db), {2595 projectId: PROJECT_ID as never,2596 platform: "Android",2597 purchaseToken: "prepaid-token",2598 productId: "prepaid_monthly",2599 purchaseState: HarmonizedPurchaseState.ENTITLED,2600 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2601 expiresAt: 1_769_904_000_000,2602 willRenew: false,2603 });2604 2605 expect(db.rows("subscriptions")).toMatchObject([2606 {2607 purchaseToken: "prepaid-token",2608 state: "Active",2609 expiresAt: 1_769_904_000_000,2610 willRenew: false,2611 renewsAt: undefined,2612 },2613 ]);2614 });2615 2616 it("marks a notification-less bootstrap row Unknown when an IOS verify turns UNKNOWN", async () => {2617 const db = new MemDb();2618 const input = {2619 projectId: PROJECT_ID as never,2620 platform: "IOS" as const,2621 purchaseToken: "ios-unknown-reason-token",2622 productId: "premium_monthly",2623 purchaseState: HarmonizedPurchaseState.ENTITLED,2624 expiresAt: 1_769_904_000_000,2625 };2626 2627 await recordVerifiedSubscriptionHandler(makeCtx(db), input);2628 expect(db.rows("subscriptions")).toMatchObject([2629 { purchaseToken: "ios-unknown-reason-token", state: "Active" },2630 ]);2631 2632 await recordVerifiedSubscriptionHandler(makeCtx(db), {2633 ...input,2634 purchaseState: HarmonizedPurchaseState.UNKNOWN,2635 });2636 expect(db.rows("subscriptions")).toMatchObject([2637 { purchaseToken: "ios-unknown-reason-token", state: "Unknown" },2638 ]);2639 });2640 2641 it("ignores an older iOS renewal verified after a newer one", async () => {2642 const db = new MemDb();2643 const day = 86_400_000;2644 const now = Date.now();2645 const input = {2646 projectId: PROJECT_ID as Id<"projects">,2647 platform: "IOS" as const,2648 purchaseToken: "ios-original-transaction",2649 productId: "pro_monthly",2650 };2651 2652 await recordVerifiedSubscriptionHandler(makeCtx(db), {2653 ...input,2654 purchaseState: HarmonizedPurchaseState.ENTITLED,2655 expiresAt: now + 20 * day,2656 verifiedPurchaseDate: now - 10 * day,2657 });2658 await recordVerifiedSubscriptionHandler(makeCtx(db), {2659 ...input,2660 purchaseState: HarmonizedPurchaseState.EXPIRED,2661 expiresAt: now - 10 * day,2662 verifiedPurchaseDate: now - 40 * day,2663 });2664 2665 expect(db.rows("subscriptions")).toMatchObject([2666 { state: "Active", expiresAt: now + 20 * day },2667 ]);2668 });2669 2670 it("applies a newer refunded plan and a refund of the stored transaction", async () => {2671 const db = new MemDb();2672 const day = 86_400_000;2673 const now = Date.now();2674 const input = {2675 projectId: PROJECT_ID as Id<"projects">,2676 platform: "IOS" as const,2677 purchaseToken: "ios-original-transaction",2678 };2679 await recordVerifiedSubscriptionHandler(makeCtx(db), {2680 ...input,2681 productId: "basic_yearly",2682 purchaseState: HarmonizedPurchaseState.ENTITLED,2683 expiresAt: now + 300 * day,2684 verifiedPurchaseDate: now - 65 * day,2685 });2686 await recordVerifiedSubscriptionHandler(makeCtx(db), {2687 ...input,2688 productId: "pro_monthly",2689 purchaseState: HarmonizedPurchaseState.EXPIRED,2690 expiresAt: now - day,2691 verifiedPurchaseDate: now - 31 * day,2692 });2693 expect(db.rows("subscriptions")).toMatchObject([2694 { productId: "pro_monthly", state: "Expired" },2695 ]);2696 2697 // Refunding a transaction keeps its purchase date.2698 await recordVerifiedSubscriptionHandler(makeCtx(db), {2699 ...input,2700 productId: "pro_monthly",2701 purchaseState: HarmonizedPurchaseState.CANCELED,2702 expiresAt: now - day,2703 verifiedPurchaseDate: now - 31 * day,2704 revocationReasonIOS: 1,2705 });2706 expect(db.rows("subscriptions")).toMatchObject([2707 {2708 productId: "pro_monthly",2709 state: "Revoked",2710 cancellationReason: "Refunded",2711 verifiedPurchaseDate: now - 31 * day,2712 },2713 ]);2714 });2715 2716 it("keeps a refund when the pre-refund verify of the same transaction commits last", async () => {2717 const db = new MemDb();2718 const day = 86_400_000;2719 const now = Date.now();2720 const input = {2721 projectId: PROJECT_ID as Id<"projects">,2722 platform: "IOS" as const,2723 purchaseToken: "ios-original-transaction",2724 productId: "pro_monthly",2725 expiresAt: now + 20 * day,2726 verifiedPurchaseDate: now - 10 * day,2727 };2728 // Stamped before signing dates were kept: the refund applies by purchase date alone.2729 await recordVerifiedSubscriptionHandler(makeCtx(db), {2730 ...input,2731 purchaseState: HarmonizedPurchaseState.ENTITLED,2732 });2733 2734 // Concurrent verifies fetched before and after the refund; the refund commits first.2735 await recordVerifiedSubscriptionHandler(makeCtx(db), {2736 ...input,2737 purchaseState: HarmonizedPurchaseState.CANCELED,2738 revocationReasonIOS: 1,2739 verifiedSignedDate: now,2740 });2741 await recordVerifiedSubscriptionHandler(makeCtx(db), {2742 ...input,2743 purchaseState: HarmonizedPurchaseState.ENTITLED,2744 verifiedSignedDate: now - 1_000,2745 });2746 // Nor can a same-transaction verify without a signing date.2747 await recordVerifiedSubscriptionHandler(makeCtx(db), {2748 ...input,2749 purchaseState: HarmonizedPurchaseState.ENTITLED,2750 });2751 2752 expect(db.rows("subscriptions")).toMatchObject([2753 {2754 state: "Revoked",2755 cancellationReason: "Refunded",2756 verifiedPurchaseDate: now - 10 * day,2757 verifiedSignedDate: now,2758 },2759 ]);2760 });2761 2762 it("applies the first verify to an iOS row stored without a purchase date and stamps it", async () => {2763 const db = new MemDb();2764 const day = 86_400_000;2765 const now = Date.now();2766 const input = {2767 projectId: PROJECT_ID as Id<"projects">,2768 platform: "IOS" as const,2769 purchaseToken: "ios-original-transaction",2770 };2771 // A row written before deploy carries no verifiedPurchaseDate.2772 await recordVerifiedSubscriptionHandler(makeCtx(db), {2773 ...input,2774 productId: "pro_monthly",2775 purchaseState: HarmonizedPurchaseState.ENTITLED,2776 expiresAt: now + 30 * day,2777 });2778 2779 // An old replay applies once, as before purchase dates were kept...2780 await recordVerifiedSubscriptionHandler(makeCtx(db), {2781 ...input,2782 productId: "basic_yearly",2783 purchaseState: HarmonizedPurchaseState.EXPIRED,2784 expiresAt: now - 200 * day,2785 verifiedPurchaseDate: now - 300 * day,2786 });2787 expect(db.rows("subscriptions")).toMatchObject([2788 {2789 productId: "basic_yearly",2790 state: "Expired",2791 verifiedPurchaseDate: now - 300 * day,2792 },2793 ]);2794 2795 // ...and the next newer verify corrects it.2796 await recordVerifiedSubscriptionHandler(makeCtx(db), {2797 ...input,2798 productId: "pro_monthly",2799 purchaseState: HarmonizedPurchaseState.ENTITLED,2800 expiresAt: now + 30 * day,2801 verifiedPurchaseDate: now,2802 });2803 expect(db.rows("subscriptions")).toMatchObject([2804 { productId: "pro_monthly", state: "Active", verifiedPurchaseDate: now },2805 ]);2806 });2807 2808 it("rejects an older verify that commits after a concurrent one stamped the row", async () => {2809 const db = new MemDb();2810 const day = 86_400_000;2811 const now = Date.now();2812 const input = {2813 projectId: PROJECT_ID as Id<"projects">,2814 platform: "IOS" as const,2815 purchaseToken: "ios-original-transaction",2816 purchaseState: HarmonizedPurchaseState.ENTITLED,2817 };2818 await recordVerifiedSubscriptionHandler(makeCtx(db), {2819 ...input,2820 productId: "basic_yearly",2821 expiresAt: now + 300 * day,2822 });2823 2824 // Both requests read the row undated; the newer one commits first.2825 await recordVerifiedSubscriptionHandler(makeCtx(db), {2826 ...input,2827 productId: "pro_monthly",2828 expiresAt: now + 30 * day,2829 verifiedPurchaseDate: now,2830 });2831 await recordVerifiedSubscriptionHandler(makeCtx(db), {2832 ...input,2833 productId: "plus_monthly",2834 expiresAt: now + 20 * day,2835 verifiedPurchaseDate: now - 10 * day,2836 });2837 2838 expect(db.rows("subscriptions")).toMatchObject([2839 { productId: "pro_monthly", verifiedPurchaseDate: now },2840 ]);2841 });2842 2843 it("applies an immediate iOS upgrade that ends sooner than the plan it replaced", async () => {2844 const db = new MemDb();2845 const day = 86_400_000;2846 const now = Date.now();2847 const input = {2848 projectId: PROJECT_ID as Id<"projects">,2849 platform: "IOS" as const,2850 purchaseToken: "ios-original-transaction",2851 purchaseState: HarmonizedPurchaseState.ENTITLED,2852 };2853 2854 await recordVerifiedSubscriptionHandler(makeCtx(db), {2855 ...input,2856 productId: "basic_yearly",2857 expiresAt: now + 300 * day,2858 verifiedPurchaseDate: now - 65 * day,2859 });2860 await recordVerifiedSubscriptionHandler(makeCtx(db), {2861 ...input,2862 productId: "pro_monthly",2863 expiresAt: now + 30 * day,2864 verifiedPurchaseDate: now,2865 });2866 2867 expect(db.rows("subscriptions")).toMatchObject([2868 { productId: "pro_monthly", expiresAt: now + 30 * day },2869 ]);2870 });2871 2872 it("clears a prior renewal date when verification becomes non-renewing", async () => {2873 const db = new MemDb();2874 const input = {2875 projectId: PROJECT_ID as never,2876 platform: "Android" as const,2877 purchaseToken: "prepaid-transition-token",2878 productId: "premium_monthly",2879 purchaseState: HarmonizedPurchaseState.ENTITLED,2880 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2881 expiresAt: 1_769_904_000_000,2882 };2883 2884 await recordVerifiedSubscriptionHandler(makeCtx(db), {2885 ...input,2886 renewsAt: 1_769_904_000_000,2887 willRenew: true,2888 });2889 await recordVerifiedSubscriptionHandler(makeCtx(db), {2890 ...input,2891 willRenew: false,2892 });2893 2894 expect(db.rows("subscriptions")).toMatchObject([2895 {2896 purchaseToken: "prepaid-transition-token",2897 state: "Active",2898 willRenew: false,2899 renewsAt: undefined,2900 },2901 ]);2902 expect(db.rows("subscriptions")).toHaveLength(1);2903 });2904 2905 it("creates an active bindable row for pending-acknowledgment Google subscriptions", async () => {2906 const db = new MemDb();2907 db.seedProduct({2908 projectId: PROJECT_ID,2909 platform: "Android",2910 productId: "premium_monthly",2911 billingPeriod: "P1M",2912 });2913 2914 await recordVerifiedSubscriptionHandler(makeCtx(db), {2915 projectId: PROJECT_ID as never,2916 platform: "Android",2917 purchaseToken: TOKEN,2918 productId: "premium_monthly",2919 purchaseState: HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT,2920 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2921 expiresAt: 1_769_904_000_000,2922 currency: "USD",2923 priceAmountMicros: 9_990_000,2924 });2925 2926 expect(db.rows("subscriptions")).toMatchObject([2927 {2928 purchaseToken: TOKEN,2929 productId: "premium_monthly",2930 platform: "Android",2931 state: "Active",2932 willRenew: true,2933 },2934 ]);2935 expect(db.rows("subscriptionStats")).toMatchObject([2936 {2937 activeSubs: 1,2938 mrrMicros: 9_990_000,2939 },2940 ]);2941 });2942 2943 it("keeps repeated verification idempotent for subscription stats", async () => {2944 const db = new MemDb();2945 db.seedProduct({2946 projectId: PROJECT_ID,2947 platform: "Android",2948 productId: "premium_monthly",2949 billingPeriod: "P1M",2950 });2951 const args = {2952 projectId: PROJECT_ID as never,2953 platform: "Android" as const,2954 purchaseToken: TOKEN,2955 productId: "premium_monthly",2956 purchaseState: HarmonizedPurchaseState.ENTITLED,2957 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",2958 expiresAt: 1_769_904_000_000,2959 currency: "USD",2960 priceAmountMicros: 9_990_000,2961 };2962 2963 await recordVerifiedSubscriptionHandler(makeCtx(db), args);2964 await recordVerifiedSubscriptionHandler(makeCtx(db), args);2965 2966 expect(db.rows("subscriptions")).toHaveLength(1);2967 expect(db.rows("subscriptionStats")).toMatchObject([2968 {2969 activeSubs: 1,2970 mrrMicros: 9_990_000,2971 },2972 ]);2973 });2974 2975 it("does not let an older verification overwrite webhook-governed state", async () => {2976 const db = new MemDb();2977 const startedId = await seedWebhookEvent(db, {2978 type: "SubscriptionStarted",2979 notificationId: "apple-monthly",2980 occurredAt: 1_000,2981 platform: "IOS",2982 productId: "premium_monthly",2983 });2984 await applySubscriptionEventHandler(makeCtx(db), {2985 projectId: PROJECT_ID as never,2986 eventId: startedId as never,2987 });2988 const renewedId = await seedWebhookEvent(db, {2989 type: "SubscriptionRenewed",2990 notificationId: "apple-yearly",2991 occurredAt: 2_000,2992 platform: "IOS",2993 productId: "premium_yearly",2994 });2995 await db.patch(renewedId, { expiresAt: 1_900_000_000_000 });2996 await applySubscriptionEventHandler(makeCtx(db), {2997 projectId: PROJECT_ID as never,2998 eventId: renewedId as never,2999 });3000 3001 await recordVerifiedSubscriptionHandler(makeCtx(db), {3002 projectId: PROJECT_ID as never,3003 platform: "IOS",3004 purchaseToken: TOKEN,3005 productId: "premium_monthly",3006 purchaseState: HarmonizedPurchaseState.ENTITLED,3007 expiresAt: 1_700_000_000_000,3008 });3009 expect(db.rows("subscriptions")[0]).toMatchObject({3010 productId: "premium_yearly",3011 expiresAt: 1_900_000_000_000,3012 lastEventSourceNotificationId: "apple-yearly",3013 });3014 });3015 3016 it("creates a bindable Apple subscription row from receipt verification", async () => {3017 const db = new MemDb();3018 db.seedProduct({3019 projectId: PROJECT_ID,3020 platform: "IOS",3021 productId: "com.example.premium",3022 billingPeriod: "P1M",3023 });3024 3025 const subscriptionId = await recordVerifiedSubscriptionHandler(3026 makeCtx(db),3027 {3028 projectId: PROJECT_ID as never,3029 platform: "IOS",3030 purchaseToken: "original_transaction_1",3031 productId: "com.example.premium",3032 purchaseState: HarmonizedPurchaseState.ENTITLED,3033 expiresAt: 1_769_904_000_000,3034 currency: "USD",3035 priceAmountMicros: 9_990_000,3036 },3037 );3038 3039 expect(subscriptionId).toBe("subscriptions_2");3040 expect(db.rows("subscriptions")).toMatchObject([3041 {3042 _id: "subscriptions_2",3043 projectId: PROJECT_ID,3044 purchaseToken: "original_transaction_1",3045 productId: "com.example.premium",3046 platform: "IOS",3047 state: "Active",3048 willRenew: undefined,3049 },3050 ]);3051 expect(db.rows("subscriptionStats")).toMatchObject([3052 {3053 projectId: PROJECT_ID,3054 currency: "USD",3055 activeSubs: 1,3056 mrrMicros: 9_990_000,3057 },3058 ]);3059 });3060 3061 it("supports the verify -> bind flow for SDK clients", async () => {3062 const db = new MemDb();3063 db.seedProduct({3064 projectId: PROJECT_ID,3065 platform: "Android",3066 productId: "premium_monthly",3067 billingPeriod: "P1M",3068 });3069 3070 const subscriptionId = await recordVerifiedSubscriptionHandler(3071 makeCtx(db),3072 {3073 projectId: PROJECT_ID as never,3074 platform: "Android",3075 purchaseToken: TOKEN,3076 productId: "premium_monthly",3077 purchaseState: HarmonizedPurchaseState.ENTITLED,3078 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",3079 expiresAt: 1_769_904_000_000,3080 renewsAt: 1_769_904_000_000,3081 currency: "USD",3082 priceAmountMicros: 9_990_000,3083 },3084 );3085 3086 const boundId = await bindSubscriptionToUserHandler(makeCtx(db), {3087 projectId: PROJECT_ID as never,3088 purchaseToken: TOKEN,3089 userId: "user-1",3090 });3091 3092 expect(boundId).toBe(subscriptionId);3093 expect(db.rows("subscriptions")).toMatchObject([3094 {3095 _id: subscriptionId,3096 projectId: PROJECT_ID,3097 purchaseToken: TOKEN,3098 productId: "premium_monthly",3099 platform: "Android",3100 state: "Active",3101 userId: "user-1",3102 willRenew: true,3103 currency: "USD",3104 priceAmountMicros: 9_990_000,3105 },3106 ]);3107 3108 const eventId = await seedWebhookEvent(db, {3109 type: "SubscriptionStarted",3110 notificationId: "message-after-bind",3111 occurredAt: 2_000,3112 });3113 await applySubscriptionEventHandler(makeCtx(db), {3114 projectId: PROJECT_ID as never,3115 eventId: eventId as never,3116 });3117 expect(db.rows("commerceEvents")).toMatchObject([3118 { eventType: "subscription.started", userId: "user-1" },3119 { eventType: "entitlement.granted", userId: "user-1" },3120 ]);3121 });3122 3123 it("rejects unbounded user ids at the Convex storage boundary", async () => {3124 const db = new MemDb();3125 3126 for (const userId of [" ", "u".repeat(257)]) {3127 await expect(3128 bindSubscriptionToUserHandler(makeCtx(db), {3129 projectId: PROJECT_ID as never,3130 purchaseToken: TOKEN,3131 userId,3132 }),3133 ).rejects.toThrow("userId must be nonblank and at most 256 characters");3134 }3135 expect(db.rows("subscriptions")).toHaveLength(0);3136 expect(db.rows("commerceEvents")).toHaveLength(0);3137 });3138 3139 it("emits a correlated grant when the webhook arrives before binding", async () => {3140 const db = new MemDb();3141 db.seedProduct({3142 projectId: PROJECT_ID,3143 platform: "Android",3144 productId: "premium_monthly",3145 billingPeriod: "P1M",3146 });3147 const eventId = await seedWebhookEvent(db, {3148 type: "SubscriptionStarted",3149 notificationId: "message-before-bind",3150 occurredAt: 2_000,3151 });3152 await applySubscriptionEventHandler(makeCtx(db), {3153 projectId: PROJECT_ID as never,3154 eventId: eventId as never,3155 });3156 3157 await bindSubscriptionToUserHandler(makeCtx(db), {3158 projectId: PROJECT_ID as never,3159 purchaseToken: TOKEN,3160 userId: "user-1",3161 });3162 3163 expect(db.rows("commerceEvents").at(-1)).toMatchObject({3164 eventType: "entitlement.granted",3165 userId: "user-1",3166 });3167 });3168 3169 it("does not replay an unbound grant that expired before binding", async () => {3170 const db = new MemDb();3171 db.seedProduct({3172 projectId: PROJECT_ID,3173 platform: "Android",3174 productId: "premium_monthly",3175 billingPeriod: "P1M",3176 });3177 const started = await seedWebhookEvent(db, {3178 type: "SubscriptionStarted",3179 notificationId: "unbound-started",3180 occurredAt: 1_000,3181 });3182 await applySubscriptionEventHandler(makeCtx(db), {3183 projectId: PROJECT_ID as never,3184 eventId: started as never,3185 });3186 const expired = await seedWebhookEvent(db, {3187 type: "SubscriptionExpired",3188 notificationId: "unbound-expired",3189 occurredAt: 2_000,3190 });3191 await applySubscriptionEventHandler(makeCtx(db), {3192 projectId: PROJECT_ID as never,3193 eventId: expired as never,3194 });3195 3196 const beforeBind = db.rows("commerceEvents").map((row) => row.eventType);3197 expect(beforeBind).toEqual([3198 "subscription.started",3199 "subscription.expired",3200 ]);3201 3202 await bindSubscriptionToUserHandler(makeCtx(db), {3203 projectId: PROJECT_ID as never,3204 purchaseToken: TOKEN,3205 userId: "user-after-expiry",3206 });3207 3208 expect(db.rows("commerceEvents").map((row) => row.eventType)).toEqual(3209 beforeBind,3210 );3211 expect(db.rows("subscriptions")[0]).toMatchObject({3212 state: "Expired",3213 userId: "user-after-expiry",3214 });3215 });3216 3217 it("uses the compact source snapshot after the webhook row is pruned", async () => {3218 const db = new MemDb();3219 const eventId = await seedWebhookEvent(db, {3220 type: "SubscriptionStarted",3221 notificationId: "old-webhook",3222 occurredAt: 2_000,3223 });3224 await applySubscriptionEventHandler(makeCtx(db), {3225 projectId: PROJECT_ID as never,3226 eventId: eventId as never,3227 });3228 await db.delete(eventId);3229 3230 await bindSubscriptionToUserHandler(makeCtx(db), {3231 projectId: PROJECT_ID as never,3232 purchaseToken: TOKEN,3233 userId: "user-after-retention",3234 });3235 expect(db.rows("commerceEvents").at(-1)).toMatchObject({3236 eventType: "entitlement.granted",3237 productId: "premium_monthly",3238 userId: "user-after-retention",3239 sourceStoreNotificationId: "old-webhook",3240 });3241 });3242});3243 3244describe("user binding authorization", () => {3245 const seedBound = async (db: MemDb, userId?: string) => {3246 db.seedProduct({3247 projectId: PROJECT_ID,3248 platform: "Android",3249 productId: "premium_monthly",3250 billingPeriod: "P1M",3251 });3252 await db.insert("subscriptions", {3253 projectId: PROJECT_ID,3254 platform: "Android",3255 purchaseToken: TOKEN,3256 productId: "premium_monthly",3257 state: "Active",3258 willRenew: true,3259 updatedAt: 0,3260 ...(userId ? { userId } : {}),3261 });3262 };3263 3264 // Token possession is not proof of ownership, and a distinct rejection would3265 // tell any holder of the app-embedded publishable key that a token exists3266 // and belongs to someone.3267 it("reports an already-bound subscription the same as an unknown token", async () => {3268 const owned = new MemDb();3269 await seedBound(owned, "victim");3270 const unknown = new MemDb();3271 await seedBound(unknown, "victim");3272 await unknown.delete(unknown.rows("subscriptions")[0]._id);3273 3274 const attacker = {3275 projectId: PROJECT_ID as never,3276 purchaseToken: TOKEN,3277 userId: "attacker",3278 };3279 await expect(3280 bindSubscriptionToUserHandler(makeCtx(owned), attacker),3281 ).resolves.toBeNull();3282 await expect(3283 bindSubscriptionToUserHandler(makeCtx(unknown), attacker),3284 ).resolves.toBeNull();3285 expect(owned.rows("subscriptions")[0].userId).toBe("victim");3286 });3287 3288 // Erasure unlinked the previous owner. Once the job is gone the record is3289 // unowned, and an operator may associate it again exactly as a purchase can.3290 it("rebinds a record whose erased owner has already been unlinked", async () => {3291 const db = new MemDb();3292 await seedBound(db, undefined);3293 await db.patch(db.rows("subscriptions")[0]._id, { accountErased: true });3294 expect(3295 await rebindSubscriptionToUserHandler(makeCtx(db), {3296 projectId: PROJECT_ID as never,3297 purchaseToken: TOKEN,3298 userId: "real-owner",3299 }),3300 ).not.toBeNull();3301 const row = db.rows("subscriptions")[0];3302 expect(row.userId).toBe("real-owner");3303 expect(row.accountErased).toBeUndefined();3304 });3305 3306 it("still refuses to move a record away from its live owner", async () => {3307 const db = new MemDb();3308 await seedBound(db, "real-owner");3309 expect(3310 await bindSubscriptionToUserHandler(makeCtx(db), {3311 projectId: PROJECT_ID as never,3312 purchaseToken: TOKEN,3313 userId: "someone-else",3314 }),3315 ).toBeNull();3316 expect(db.rows("subscriptions")[0].userId).toBe("real-owner");3317 });3318 3319 it.each(["queued", "running", "completed"])(3320 "refuses rebind into or out of an account with a %s erasure job",3321 async (status) => {3322 for (const erasedUser of ["source-user", "target-user"]) {3323 const db = new MemDb();3324 await seedBound(db, "source-user");3325 const hashKey = "erasure-test-key";3326 await db.patch(PROJECT_ID, { userErasureHashKey: hashKey });3327 await db.insert("subscriptionUserErasureJobs", {3328 projectId: PROJECT_ID,3329 userIdHash: await hmacSha256Hex(hashKey, erasedUser),3330 status,3331 });3332 const before = structuredClone(db.rows("subscriptions"));3333 expect(3334 await rebindSubscriptionToUserHandler(makeCtx(db), {3335 projectId: PROJECT_ID as never,3336 purchaseToken: TOKEN,3337 userId: "target-user",3338 }),3339 ).toBeNull();3340 expect(db.rows("subscriptions")).toEqual(before);3341 expect(db.rows("commerceEvents")).toEqual([]);3342 }3343 },3344 );3345 3346 // A consumer gating access on commerce events must be told the purchase3347 // moved, or the wrong user keeps access and the real one never gets it.3348 it("revokes the old user and grants the new one on a rebind", async () => {3349 const db = new MemDb();3350 db.seedProduct({3351 projectId: PROJECT_ID,3352 platform: "Android",3353 productId: "premium_monthly",3354 billingPeriod: "P1M",3355 });3356 const priorId = await seedWebhookEvent(db, {3357 type: "SubscriptionStarted",3358 notificationId: "rebind-source",3359 occurredAt: 1_000,3360 });3361 await db.insert("subscriptions", {3362 projectId: PROJECT_ID,3363 platform: "Android",3364 purchaseToken: TOKEN,3365 productId: "premium_monthly",3366 state: "Active",3367 willRenew: true,3368 userId: "wrong-user",3369 lastEventId: priorId,3370 lastEventOccurredAt: 1_000,3371 lastEventSourceNotificationId: "rebind-source",3372 lastEventSource: {3373 type: "SubscriptionStarted",3374 environment: "Production",3375 productId: "premium_monthly",3376 },3377 expiresAt: Date.now() + 86_400_000,3378 updatedAt: 0,3379 });3380 3381 await rebindSubscriptionToUserHandler(makeCtx(db), {3382 projectId: PROJECT_ID as never,3383 purchaseToken: TOKEN,3384 userId: "real-owner",3385 });3386 3387 const emitted = db3388 .rows("commerceEvents")3389 .map((r) => [r.eventType, r.userId]);3390 expect(emitted).toEqual([3391 ["entitlement.revoked", "wrong-user"],3392 ["entitlement.granted", "real-owner"],3393 ]);3394 });3395 3396 // A rebind that cannot attribute events leaves the developer backend3397 // believing the old user still owns the purchase. Say so rather than3398 // reporting plain success.3399 it("reports a rebind it could not notify about", async () => {3400 const db = new MemDb();3401 db.seedProduct({3402 projectId: PROJECT_ID,3403 platform: "Android",3404 productId: "premium_monthly",3405 billingPeriod: "P1M",3406 });3407 await db.insert("subscriptions", {3408 projectId: PROJECT_ID,3409 platform: "Android",3410 purchaseToken: TOKEN,3411 productId: "premium_monthly",3412 state: "Active",3413 willRenew: true,3414 userId: "wrong-user",3415 expiresAt: Date.now() + 86_400_000,3416 updatedAt: 0,3417 });3418 3419 const outcome = await rebindSubscriptionToUserHandler(makeCtx(db), {3420 projectId: PROJECT_ID as never,3421 purchaseToken: TOKEN,3422 userId: "real-owner",3423 });3424 expect(outcome?.notified).toBe(false);3425 expect(db.rows("subscriptions")[0].userId).toBe("real-owner");3426 expect(db.rows("commerceEvents")).toEqual([]);3427 });3428 3429 it("lets an operator move a wrong binding", async () => {3430 const db = new MemDb();3431 await seedBound(db, "wrong-user");3432 await expect(3433 rebindSubscriptionToUserHandler(makeCtx(db), {3434 projectId: PROJECT_ID as never,3435 purchaseToken: TOKEN,3436 userId: "real-owner",3437 }),3438 ).resolves.not.toBeNull();3439 expect(db.rows("subscriptions")[0].userId).toBe("real-owner");3440 });3441});3442 3443describe("subscription environment", () => {3444 async function apply(db: MemDb, eventId: string) {3445 return applySubscriptionEventHandler(makeCtx(db), {3446 projectId: PROJECT_ID as never,3447 eventId: eventId as never,3448 });3449 }3450 3451 it("keeps a known environment when Play sends none", async () => {3452 const db = new MemDb();3453 const started = await seedWebhookEvent(db, {3454 type: "SubscriptionStarted",3455 notificationId: "tester-start",3456 occurredAt: 1_000,3457 environment: "Sandbox",3458 });3459 await apply(db, started);3460 // An expiry recorded without a service account.3461 const expired = await seedWebhookEvent(db, {3462 type: "SubscriptionExpired",3463 notificationId: "tester-expiry",3464 occurredAt: 2_000,3465 });3466 await db.patch(expired, { environmentDefaulted: true });3467 await apply(db, expired);3468 3469 expect(db.rows("subscriptions")[0]).toMatchObject({3470 state: "Expired",3471 environment: "Sandbox",3472 });3473 });3474 3475 it("keeps a legacy row's environment from its last store event", async () => {3476 const db = new MemDb();3477 await db.insert("subscriptions", {3478 projectId: PROJECT_ID,3479 purchaseToken: TOKEN,3480 productKind: "subscription",3481 productId: "premium_monthly",3482 platform: "Android",3483 state: "Active",3484 startedAt: 500,3485 updatedAt: 500,3486 lastEventSource: { environment: "Sandbox" },3487 });3488 const expired = await seedWebhookEvent(db, {3489 type: "SubscriptionExpired",3490 notificationId: "legacy-expiry",3491 occurredAt: 2_000,3492 });3493 await db.patch(expired, { environmentDefaulted: true });3494 await apply(db, expired);3495 3496 expect(db.rows("subscriptions")[0]).toMatchObject({3497 state: "Expired",3498 environment: "Sandbox",3499 });3500 });3501});3502 3503describe("subscription analytics facts", () => {3504 async function apply(db: MemDb, eventId: string) {3505 return applySubscriptionEventHandler(makeCtx(db), {3506 projectId: PROJECT_ID as never,3507 eventId: eventId as never,3508 });3509 }3510 3511 function eventRow(db: MemDb, id: string): Row | undefined {3512 return db.rows("webhookEvents").find((row) => row._id === id);3513 }3514 3515 it("reconciles the first paid marker when a late Google link merges a trial", async () => {3516 const db = new MemDb();3517 const trial = await seedWebhookEvent(db, {3518 type: "SubscriptionStarted",3519 notificationId: "linked-trial",3520 occurredAt: 1000,3521 });3522 await db.patch(trial, { freeTrial: true });3523 await apply(db, trial);3524 const paid = await seedWebhookEvent(db, {3525 type: "SubscriptionRenewed",3526 notificationId: "successor-paid",3527 occurredAt: 5000,3528 });3529 await db.patch(paid, { purchaseToken: "successor-token" });3530 await apply(db, paid);3531 expect(eventRow(db, paid)?.paidStart).toBeUndefined();3532 3533 const linked = await seedWebhookEvent(db, {3534 type: "SubscriptionStarted",3535 notificationId: "late-link",3536 occurredAt: 3000,3537 });3538 await db.patch(linked, {3539 purchaseToken: "successor-token",3540 linkedPurchaseToken: TOKEN,3541 replacementCharged: false,3542 });3543 await apply(db, linked);3544 3545 expect(db.rows("subscriptions")).toHaveLength(1);3546 expect(db.rows("subscriptions")[0].analytics).toMatchObject({3547 trialStartedAt: 1000,3548 firstPaidAt: 5000,3549 firstPaidEventId: paid,3550 });3551 expect(eventRow(db, paid)?.paidStart).toBe("trial_conversion");3552 await apply(db, linked);3553 expect(eventRow(db, paid)?.paidStart).toBe("trial_conversion");3554 });3555 3556 it("clears a removed row's paid-start marker when a late Google link merges rows", async () => {3557 const db = new MemDb();3558 const start = await seedWebhookEvent(db, {3559 type: "SubscriptionStarted",3560 notificationId: "predecessor-paid",3561 occurredAt: 1000,3562 });3563 await apply(db, start);3564 const successor = await seedWebhookEvent(db, {3565 type: "SubscriptionStarted",3566 notificationId: "successor-paid-start",3567 occurredAt: 5000,3568 });3569 await db.patch(successor, { purchaseToken: "successor-token" });3570 await apply(db, successor);3571 expect(eventRow(db, successor)?.paidStart).toBe("direct");3572 const linked = await seedWebhookEvent(db, {3573 type: "SubscriptionStarted",3574 notificationId: "late-paid-link",3575 occurredAt: 3000,3576 });3577 await db.patch(linked, {3578 purchaseToken: "successor-token",3579 linkedPurchaseToken: TOKEN,3580 replacementCharged: false,3581 });3582 await apply(db, linked);3583 expect(eventRow(db, start)?.paidStart).toBe("direct");3584 expect(eventRow(db, successor)?.paidStart).toBeUndefined();3585 });3586 3587 it("marks a trial's first paid charge and never counts a replay twice", async () => {3588 const db = new MemDb();3589 const trial = await seedWebhookEvent(db, {3590 type: "SubscriptionStarted",3591 notificationId: "trial-start",3592 occurredAt: 1_000,3593 });3594 await db.patch(trial, { freeTrial: true });3595 const paid = await seedWebhookEvent(db, {3596 type: "SubscriptionRenewed",3597 notificationId: "first-charge",3598 occurredAt: 2_000,3599 });3600 3601 await apply(db, trial);3602 await apply(db, paid);3603 await apply(db, paid);3604 3605 expect(db.rows("subscriptions")[0].analytics).toMatchObject({3606 trialStartedAt: 1_000,3607 firstPaidAt: 2_000,3608 firstPaidEventId: paid,3609 firstPaidEventType: "SubscriptionRenewed",3610 currency: "USD",3611 paidMicros: 9_990_000,3612 refundedMicros: 0,3613 });3614 expect(eventRow(db, paid)?.paidStart).toBe("trial_conversion");3615 expect(eventRow(db, trial)?.paidStart).toBeUndefined();3616 });3617 3618 it("converges when the trial and charges arrive out of order", async () => {3619 const db = new MemDb();3620 const trial = await seedWebhookEvent(db, {3621 type: "SubscriptionStarted",3622 notificationId: "trial-start",3623 occurredAt: 1_000,3624 });3625 await db.patch(trial, { freeTrial: true });3626 const firstCharge = await seedWebhookEvent(db, {3627 type: "SubscriptionRenewed",3628 notificationId: "first-charge",3629 occurredAt: 2_000,3630 });3631 const secondCharge = await seedWebhookEvent(db, {3632 type: "SubscriptionRenewed",3633 notificationId: "second-charge",3634 occurredAt: 3_000,3635 });3636 3637 await apply(db, secondCharge);3638 expect(eventRow(db, secondCharge)?.paidStart).toBeUndefined();3639 await apply(db, firstCharge);3640 await apply(db, trial);3641 3642 expect(db.rows("subscriptions")[0].analytics).toMatchObject({3643 trialStartedAt: 1_000,3644 firstPaidAt: 2_000,3645 firstPaidEventId: firstCharge,3646 paidMicros: 2 * 9_990_000,3647 });3648 expect(eventRow(db, firstCharge)?.paidStart).toBe("trial_conversion");3649 expect(eventRow(db, secondCharge)?.paidStart).toBeUndefined();3650 // State still follows the newest store event.3651 expect(db.rows("subscriptions")[0].lastEventId).toBe(secondCharge);3652 });3653 3654 it("marks a paid start without a trial as direct and moves the marker to an earlier charge", async () => {3655 const db = new MemDb();3656 const renewal = await seedWebhookEvent(db, {3657 type: "SubscriptionRenewed",3658 notificationId: "renewal",3659 occurredAt: 5_000,3660 });3661 const start = await seedWebhookEvent(db, {3662 type: "SubscriptionStarted",3663 notificationId: "start",3664 occurredAt: 1_000,3665 });3666 3667 await apply(db, renewal);3668 await apply(db, start);3669 3670 expect(eventRow(db, start)?.paidStart).toBe("direct");3671 expect(eventRow(db, renewal)?.paidStart).toBeUndefined();3672 });3673 3674 it("marks a resubscribe a paid start only after a trial IAPKit saw lapse", async () => {3675 async function seedResubscribe(db: MemDb): Promise<string> {3676 const id = await seedWebhookEvent(db, {3677 type: "SubscriptionStarted",3678 notificationId: "resubscribe",3679 occurredAt: 5_000,3680 platform: "IOS",3681 });3682 await db.patch(id, { resubscribe: true });3683 return id;3684 }3685 3686 // Paid before IAPKit saw it: a reactivation with no known start, and the3687 // same lifecycle transition as a first purchase.3688 const returning = new MemDb();3689 const resubscribe = await seedResubscribe(returning);3690 await expect(apply(returning, resubscribe)).resolves.toMatchObject({3691 transition: "Started",3692 active: true,3693 });3694 expect(returning.rows("subscriptions")[0].analytics).toMatchObject({3695 firstPaidAt: 5_000,3696 firstPaidResubscribe: true,3697 });3698 expect(eventRow(returning, resubscribe)?.paidStart).toBeUndefined();3699 3700 // A trial that lapsed before it, arriving late: the resubscribe is the3701 // subscription's first paid charge.3702 const db = new MemDb();3703 const paid = await seedResubscribe(db);3704 const trial = await seedWebhookEvent(db, {3705 type: "SubscriptionStarted",3706 notificationId: "trial-start",3707 occurredAt: 1_000,3708 platform: "IOS",3709 });3710 await db.patch(trial, { freeTrial: true });3711 await apply(db, paid);3712 await apply(db, trial);3713 expect(eventRow(db, paid)?.paidStart).toBe("direct");3714 });3715 3716 it("records the environment and keeps sandbox rows out of live stats", async () => {3717 const db = new MemDb();3718 db.seedProduct({3719 projectId: PROJECT_ID,3720 platform: "Android",3721 productId: "premium_monthly",3722 billingPeriod: "P1M",3723 });3724 const sandbox = await seedWebhookEvent(db, {3725 type: "SubscriptionStarted",3726 notificationId: "sandbox-start",3727 occurredAt: 1_000,3728 environment: "Sandbox",3729 });3730 await apply(db, sandbox);3731 expect(db.rows("subscriptions")[0].environment).toBe("Sandbox");3732 expect(db.rows("subscriptionStats")).toMatchObject([3733 { activeSubs: 0, inGracePeriod: 0, inBillingRetry: 0, mrrMicros: 0 },3734 ]);3735 3736 await recordVerifiedSubscriptionHandler(makeCtx(db), {3737 projectId: PROJECT_ID as never,3738 platform: "Android",3739 purchaseToken: "verified_sandbox_token",3740 productId: "premium_monthly",3741 purchaseState: "ENTITLED",3742 subscriptionState: "SUBSCRIPTION_STATE_ACTIVE",3743 currency: "USD",3744 priceAmountMicros: 9_990_000,3745 environment: "Sandbox",3746 });3747 expect(3748 db3749 .rows("subscriptions")3750 .find((row) => row.purchaseToken === "verified_sandbox_token")3751 ?.environment,3752 ).toBe("Sandbox");3753 expect(db.rows("subscriptionStats")).toMatchObject([3754 { activeSubs: 0, inGracePeriod: 0, inBillingRetry: 0, mrrMicros: 0 },3755 ]);3756 });3757 3758 it("never lets an analytics failure block the lifecycle transition", async () => {3759 const db = new MemDb();3760 const patch = db.patch.bind(db);3761 db.patch = async (id, fields) => {3762 if ("analytics" in fields) throw new Error("analytics write failed");3763 await patch(id, fields);3764 };3765 const errors = vi.spyOn(console, "error").mockImplementation(() => {});3766 try {3767 const start = await seedWebhookEvent(db, {3768 type: "SubscriptionStarted",3769 notificationId: "start",3770 occurredAt: 1_000,3771 });3772 3773 await expect(apply(db, start)).resolves.toMatchObject({3774 transition: "Started",3775 active: true,3776 });3777 expect(db.rows("subscriptions")).toMatchObject([3778 { state: "Active", lastEventId: start },3779 ]);3780 expect(db.rows("subscriptions")[0].analytics).toBeUndefined();3781 expect(errors).toHaveBeenCalledWith("[analyticsFacts] skipped", "Error");3782 } finally {3783 errors.mockRestore();3784 }3785 });3786});3787 3788describe("observed subscription access history", () => {3789 const at = (iso: string): number => Date.parse(iso);3790 const janEnd = at("2026-01-31T23:59:59.999Z");3791 const febEnd = at("2026-02-28T23:59:59.999Z");3792 const asOf = at("2026-03-15T12:00:00Z");3793 beforeEach(() => vi.useFakeTimers({ toFake: ["Date"] }));3794 afterEach(() => vi.useRealTimers());3795 function current(db: MemDb): Doc<"subscriptions"> {3796 return db.rows("subscriptions")[0] as Doc<"subscriptions">;3797 }3798 async function apply(3799 db: MemDb,3800 fields: Partial<Doc<"webhookEvents">> &3801 Pick<Doc<"webhookEvents">, "type" | "occurredAt">,3802 ): Promise<void> {3803 const id = await db.insert("webhookEvents", {3804 projectId: PROJECT_ID,3805 purchaseToken: TOKEN,3806 productKind: "subscription",3807 productId: "premium_monthly",3808 platform: "IOS",3809 store: "apple",3810 source: "AppleAppStoreServerNotificationsV2",3811 environment: "Production",3812 sourceNotificationId: `history-${db.rows("webhookEvents").length}`,3813 currency: "USD",3814 priceAmountMicros: 9_990_000,3815 receivedAt: Date.now(),3816 ...fields,3817 });3818 await applySubscriptionEventHandler(makeCtx(db), {3819 projectId: PROJECT_ID as Id<"projects">,3820 eventId: id as Id<"webhookEvents">,3821 });3822 }3823 function january(db: MemDb): CohortBucket | undefined {3824 const buckets = new Map<string, CohortBucket>();3825 addSubscriptionToCohorts(buckets, current(db), asOf);3826 return buckets.get("Production|2026-01|USD");3827 }3828 it.each([false, true])(3829 "keeps the February lapse after resubscribing (late expiry: %s)",3830 async (late) => {3831 const db = new MemDb();3832 const start = {3833 type: "SubscriptionStarted",3834 occurredAt: at("2026-01-01"),3835 purchaseDate: at("2026-01-01"),3836 expiresAt: at("2026-02-01"),3837 } as const;3838 const expiry = {3839 type: "SubscriptionExpired",3840 occurredAt: at("2026-02-01"),3841 expiresAt: at("2026-02-01"),3842 } as const;3843 const returned = {3844 type: "SubscriptionStarted",3845 resubscribe: true,3846 occurredAt: at("2026-03-01"),3847 purchaseDate: at("2026-03-01"),3848 expiresAt: at("2026-04-01"),3849 } as const;3850 vi.setSystemTime(late ? asOf : start.occurredAt);3851 await apply(db, start);3852 if (!late) {3853 vi.setSystemTime(expiry.occurredAt);3854 await apply(db, expiry);3855 }3856 vi.setSystemTime(asOf);3857 await apply(db, returned);3858 if (late) await apply(db, expiry);3859 expect(january(db)).toMatchObject({3860 members: 1,3861 retained: [1, 0, 1],3862 retentionObserved: [1, 1, 1],3863 });3864 expect(current(db).state).toBe("Active");3865 },3866 );3867 it("keeps the refund gap through cancellation and reversal", async () => {3868 const db = new MemDb();3869 vi.setSystemTime(at("2026-01-01"));3870 await apply(db, {3871 type: "SubscriptionStarted",3872 occurredAt: Date.now(),3873 purchaseDate: Date.now(),3874 expiresAt: at("2026-06-01"),3875 });3876 vi.setSystemTime(at("2026-02-01"));3877 await apply(db, {3878 type: "PurchaseRefunded",3879 occurredAt: Date.now(),3880 revocationDate: Date.now(),3881 expiresAt: at("2026-06-01"),3882 cancellationReason: "Refunded",3883 });3884 vi.setSystemTime(at("2026-02-15"));3885 await apply(db, {3886 type: "SubscriptionCanceled",3887 occurredAt: Date.now(),3888 purchaseDate: at("2026-01-01"),3889 expiresAt: at("2026-06-01"),3890 willRenew: false,3891 });3892 vi.setSystemTime(at("2026-03-01"));3893 await apply(db, {3894 type: "SubscriptionStarted",3895 occurredAt: Date.now(),3896 purchaseDate: at("2026-01-01"),3897 expiresAt: at("2026-06-01"),3898 refundReversal: true,3899 });3900 expect(january(db)).toMatchObject({3901 retained: [1, 0, 1],3902 retentionObserved: [1, 1, 1],3903 });3904 expect(current(db).analytics).toMatchObject({3905 paidMicros: 9_990_000,3906 refundedMicros: 0,3907 });3908 });3909 it("uses Apple purchaseDate without changing notification ordering", async () => {3910 const db = new MemDb();3911 vi.setSystemTime(at("2026-02-01T01:00:00Z"));3912 const normalized = normalizeAppleAsn({3913 payload: {3914 notificationType: "SUBSCRIBED",3915 subtype: "INITIAL_BUY",3916 notificationUUID: "late-january",3917 signedDate: Date.now(),3918 data: { environment: "Production" },3919 },3920 transaction: {3921 originalTransactionId: TOKEN,3922 transactionId: "charge",3923 type: "Auto-Renewable Subscription",3924 productId: "premium_monthly",3925 purchaseDate: at("2026-01-31T12:00:00Z"),3926 expiresDate: at("2026-03-01"),3927 currency: "USD",3928 price: 9990,3929 },3930 });3931 await apply(db, normalized);3932 expect(current(db).lastEventOccurredAt).toBe(Date.now());3933 expect(current(db).analytics?.firstPaidAt).toBe(at("2026-01-31T12:00:00Z"));3934 expect(january(db)).toMatchObject({ members: 1, retained: [1, 1, 0] });3935 });3936 it("keeps successor authority across two links and a late predecessor expiry", async () => {3937 const db = new MemDb();3938 const google = {3939 platform: "Android",3940 store: "google",3941 source: "GooglePlayRealTimeDeveloperNotifications",3942 } as const;3943 vi.setSystemTime(at("2026-01-01"));3944 await apply(db, {3945 ...google,3946 type: "SubscriptionStarted",3947 occurredAt: Date.now(),3948 expiresAt: at("2026-05-01"),3949 });3950 vi.setSystemTime(at("2026-03-01"));3951 await apply(db, {3952 ...google,3953 type: "SubscriptionStarted",3954 purchaseToken: "second",3955 linkedPurchaseToken: TOKEN,3956 occurredAt: Date.now(),3957 expiresAt: at("2026-06-01"),3958 });3959 vi.setSystemTime(at("2026-04-01"));3960 await apply(db, {3961 ...google,3962 type: "SubscriptionStarted",3963 purchaseToken: "third",3964 linkedPurchaseToken: "second",3965 occurredAt: Date.now(),3966 expiresAt: at("2026-07-01"),3967 });3968 vi.setSystemTime(at("2026-06-01"));3969 await apply(db, {3970 ...google,3971 type: "SubscriptionExpired",3972 purchaseToken: TOKEN,3973 occurredAt: at("2026-05-01"),3974 expiresAt: at("2026-05-01"),3975 });3976 const history = current(db).analytics?.accessHistory;3977 expect(accessAt(history, janEnd)).toBe(true);3978 expect(accessAt(history, febEnd)).toBe(true);3979 expect(accessAt(history, at("2026-05-31T23:59:59.999Z"))).toBe(true);3980 expect(accessAt(history, Date.now())).toBe(true);3981 expect(current(db).purchaseToken).toBe("third");3982 });3983 it("observes accepted verification without inventing old history or a new charge", async () => {3984 const db = new MemDb();3985 vi.setSystemTime(asOf);3986 const args = {3987 projectId: PROJECT_ID as Id<"projects">,3988 platform: "Android" as const,3989 purchaseToken: TOKEN,3990 existing: null,3991 next: {3992 state: "Active" as const,3993 productId: "premium_monthly",3994 expiresAt: at("2026-06-01"),3995 },3996 now: Date.now(),3997 verifiedEvidenceAt: asOf - 1000,3998 };3999 await persistSubscriptionSnapshot(makeCtx(db), args);4000 const first = current(db);4001 expect(accessAt(first.analytics?.accessHistory, febEnd)).toBeNull();4002 expect(accessAt(first.analytics?.accessHistory, asOf)).toBe(true);4003 expect(first.analytics).toMatchObject({ paidMicros: 0, refundedMicros: 0 });4004 expect(first.analytics?.firstPaidAt).toBeUndefined();4005 vi.setSystemTime(asOf + 86_400_000);4006 await persistSubscriptionSnapshot(makeCtx(db), {4007 ...args,4008 existing: first,4009 now: Date.now(),4010 });4011 expect(current(db).analytics?.accessHistory?.latest?.effectiveAt).toBe(4012 asOf - 1000,4013 );4014 });4015 it("orders delayed Play commits by the store fetch time", async () => {4016 const db = new MemDb();4017 vi.setSystemTime(at("2026-01-03"));4018 const args = {4019 projectId: PROJECT_ID as Id<"projects">,4020 platform: "Android" as const,4021 purchaseToken: TOKEN,4022 existing: null,4023 next: {4024 state: "Active" as const,4025 productId: "premium_monthly",4026 expiresAt: at("2026-06-01"),4027 },4028 now: Date.now(),4029 verifiedEvidenceAt: at("2026-01-01"),4030 };4031 await persistSubscriptionSnapshot(makeCtx(db), args);4032 vi.setSystemTime(at("2026-01-04"));4033 await persistSubscriptionSnapshot(makeCtx(db), {4034 ...args,4035 existing: current(db),4036 next: { ...args.next, state: "Expired" },4037 now: Date.now(),4038 verifiedEvidenceAt: at("2026-01-02"),4039 });4040 expect(current(db).state).toBe("Expired");4041 expect(accessAt(current(db).analytics?.accessHistory, Date.now())).toBe(4042 false,4043 );4044 });4045 it("does not turn an old Apple receipt replay into a new grant", async () => {4046 const db = new MemDb();4047 vi.setSystemTime(at("2026-01-20"));4048 await persistSubscriptionSnapshot(makeCtx(db), {4049 projectId: PROJECT_ID as Id<"projects">,4050 platform: "IOS",4051 purchaseToken: TOKEN,4052 existing: null,4053 next: {4054 state: "Active",4055 productId: "premium_monthly",4056 expiresAt: at("2026-06-01"),4057 },4058 now: Date.now(),4059 verifiedEvidenceAt: at("2026-01-01"),4060 verifiedPurchaseDate: at("2026-01-01"),4061 verifiedSignedDate: at("2026-01-01"),4062 });4063 vi.setSystemTime(at("2026-01-21"));4064 await apply(db, {4065 type: "PurchaseRefunded",4066 occurredAt: at("2026-01-15"),4067 revocationDate: at("2026-01-15"),4068 cancellationReason: "Refunded",4069 });4070 expect(current(db).state).toBe("Refunded");4071 expect(accessAt(current(db).analytics?.accessHistory, janEnd)).toBe(false);4072 expect(4073 accessAt(4074 current(db).analytics?.accessHistory,4075 at("2026-01-14T23:59:59.999Z"),4076 ),4077 ).toBe(true);4078 });4079 it("keeps unknown verified state unverified, not confirmed inactive", async () => {4080 const db = new MemDb();4081 vi.setSystemTime(asOf);4082 await persistSubscriptionSnapshot(makeCtx(db), {4083 projectId: PROJECT_ID as Id<"projects">,4084 platform: "Android",4085 purchaseToken: TOKEN,4086 existing: null,4087 next: { state: "Unknown", productId: "premium_monthly" },4088 now: Date.now(),4089 verifiedEvidenceAt: Date.now(),4090 });4091 expect(accessAt(current(db).analytics?.accessHistory, asOf)).toBeNull();4092 });4093 it("records Apple's immediate upgrade without applying a scheduled preference", async () => {4094 for (const immediate of [false, true]) {4095 const db = new MemDb();4096 vi.setSystemTime(at("2026-01-01"));4097 await apply(db, {4098 type: "SubscriptionStarted",4099 occurredAt: Date.now(),4100 purchaseDate: Date.now(),4101 expiresAt: at("2026-02-01"),4102 });4103 vi.setSystemTime(at("2026-01-15"));4104 await apply(db, {4105 type: "SubscriptionProductChanged",4106 productId: "premium_yearly",4107 occurredAt: Date.now(),4108 purchaseDate: Date.now(),4109 expiresAt: at("2027-01-15"),4110 effectiveImmediately: immediate,4111 });4112 expect(accessAt(current(db).analytics?.accessHistory, febEnd)).toBe(4113 immediate,4114 );4115 }4116 });4117 it("does not backdate the current Play status into an old notification", async () => {4118 const db = new MemDb();4119 vi.setSystemTime(asOf);4120 await apply(db, {4121 type: "SubscriptionStarted",4122 platform: "Android",4123 store: "google",4124 source: "GooglePlayRealTimeDeveloperNotifications",4125 occurredAt: at("2026-01-01"),4126 snapshotObservedAt: asOf,4127 subscriptionState: "Active",4128 expiresAt: at("2026-04-01"),4129 });4130 expect(january(db)).toMatchObject({4131 members: 1,4132 retained: [0, 0, 1],4133 retentionObserved: [0, 0, 1],4134 });4135 });4136 it("keeps the paid period of an Apple receipt first verified after expiry", async () => {4137 const db = new MemDb();4138 const purchaseDate = at("2026-09-01");4139 const expiresAt = at("2026-10-01");4140 vi.setSystemTime(at("2026-10-05"));4141 await recordVerifiedSubscriptionHandler(makeCtx(db), {4142 projectId: PROJECT_ID as Id<"projects">,4143 platform: "IOS",4144 purchaseToken: TOKEN,4145 productId: "premium_monthly",4146 purchaseState: HarmonizedPurchaseState.EXPIRED,4147 expiresAt,4148 verifiedPurchaseDate: purchaseDate,4149 verifiedSignedDate: Date.now(),4150 evidenceAt: purchaseDate,4151 });4152 const row = current(db);4153 const history = row.analytics?.accessHistory;4154 expect(row.state).toBe("Expired");4155 expect(accessAt(history, at("2026-08-31T23:59:59.999Z"))).toBeNull();4156 expect(accessAt(history, at("2026-09-30T23:59:59.999Z"))).toBe(true);4157 expect(accessAt(history, expiresAt)).toBe(false);4158 expect(accessAt(history, Date.now())).toBe(false);4159 expect(row.analytics).toMatchObject({ paidMicros: 0, refundedMicros: 0 });4160 expect(row.analytics?.firstPaidAt).toBeUndefined();4161 });4162 it("keeps natural Apple expiry dated at expiry when a receipt is reverified", async () => {4163 const db = new MemDb();4164 const purchaseDate = at("2026-09-01");4165 const args = {4166 projectId: PROJECT_ID as Id<"projects">,4167 platform: "IOS" as const,4168 purchaseToken: TOKEN,4169 productId: "premium_monthly",4170 expiresAt: at("2026-10-01"),4171 verifiedPurchaseDate: purchaseDate,4172 evidenceAt: purchaseDate,4173 };4174 vi.setSystemTime(at("2026-09-10"));4175 await recordVerifiedSubscriptionHandler(makeCtx(db), {4176 ...args,4177 purchaseState: HarmonizedPurchaseState.ENTITLED,4178 verifiedSignedDate: Date.now(),4179 });4180 vi.setSystemTime(at("2026-10-05"));4181 await recordVerifiedSubscriptionHandler(makeCtx(db), {4182 ...args,4183 purchaseState: HarmonizedPurchaseState.EXPIRED,4184 verifiedSignedDate: Date.now(),4185 });4186 const history = current(db).analytics?.accessHistory;4187 expect(current(db).state).toBe("Expired");4188 expect(accessAt(history, at("2026-09-30T23:59:59.999Z"))).toBe(true);4189 expect(accessAt(history, at("2026-10-04T23:59:59.999Z"))).toBe(false);4190 expect(accessAt(history, Date.now())).toBe(false);4191 expect(history?.latest?.effectiveAt).toBe(purchaseDate);4192 });4193 it("keeps a lapse before a later Apple period first verified after expiry", async () => {4194 const db = new MemDb();4195 const args = {4196 projectId: PROJECT_ID as Id<"projects">,4197 platform: "IOS" as const,4198 purchaseToken: TOKEN,4199 productId: "premium_monthly",4200 purchaseState: HarmonizedPurchaseState.EXPIRED,4201 };4202 vi.setSystemTime(at("2026-03-05"));4203 await recordVerifiedSubscriptionHandler(makeCtx(db), {4204 ...args,4205 expiresAt: at("2026-02-01"),4206 verifiedPurchaseDate: at("2026-01-01"),4207 verifiedSignedDate: Date.now(),4208 evidenceAt: at("2026-01-01"),4209 });4210 vi.setSystemTime(at("2026-04-05"));4211 await recordVerifiedSubscriptionHandler(makeCtx(db), {4212 ...args,4213 expiresAt: at("2026-04-01"),4214 verifiedPurchaseDate: at("2026-03-01"),4215 verifiedSignedDate: Date.now(),4216 evidenceAt: at("2026-03-01"),4217 });4218 const history = current(db).analytics?.accessHistory;4219 expect(accessAt(history, janEnd)).toBe(true);4220 expect(accessAt(history, febEnd)).toBe(false);4221 expect(accessAt(history, at("2026-03-31T23:59:59.999Z"))).toBe(true);4222 expect(accessAt(history, Date.now())).toBe(false);4223 expect(current(db).analytics?.paidMicros).toBe(0);4224 });4225 it("keeps a re-signed change to the same Apple period's expiry undated", async () => {4226 const db = new MemDb();4227 const purchaseDate = at("2026-01-01");4228 const args = {4229 projectId: PROJECT_ID as Id<"projects">,4230 platform: "IOS" as const,4231 purchaseToken: TOKEN,4232 productId: "premium_monthly",4233 verifiedPurchaseDate: purchaseDate,4234 evidenceAt: purchaseDate,4235 };4236 vi.setSystemTime(at("2026-01-10"));4237 await recordVerifiedSubscriptionHandler(makeCtx(db), {4238 ...args,4239 purchaseState: HarmonizedPurchaseState.ENTITLED,4240 expiresAt: at("2026-06-01"),4241 verifiedSignedDate: Date.now(),4242 });4243 vi.setSystemTime(at("2026-01-20"));4244 await recordVerifiedSubscriptionHandler(makeCtx(db), {4245 ...args,4246 purchaseState: HarmonizedPurchaseState.EXPIRED,4247 expiresAt: at("2026-01-15"),4248 verifiedSignedDate: Date.now(),4249 });4250 expect(4251 accessAt(current(db).analytics?.accessHistory, Date.now()),4252 ).toBeNull();4253 vi.setSystemTime(at("2026-01-21"));4254 await recordVerifiedSubscriptionHandler(makeCtx(db), {4255 ...args,4256 purchaseState: HarmonizedPurchaseState.EXPIRED,4257 expiresAt: at("2026-01-15"),4258 verifiedSignedDate: Date.now(),4259 });4260 expect(4261 accessAt(current(db).analytics?.accessHistory, Date.now()),4262 ).toBeNull();4263 expect(4264 accessAt(4265 current(db).analytics?.accessHistory,4266 at("2026-01-14T23:59:59.999Z"),4267 ),4268 ).toBe(true);4269 });4270 it.each([HarmonizedPurchaseState.ENTITLED, HarmonizedPurchaseState.EXPIRED])(4271 "keeps undated Apple %s history unknown",4272 async (purchaseState) => {4273 const db = new MemDb();4274 vi.setSystemTime(at("2026-10-05"));4275 await recordVerifiedSubscriptionHandler(makeCtx(db), {4276 projectId: PROJECT_ID as Id<"projects">,4277 platform: "IOS",4278 purchaseToken: TOKEN,4279 productId: "premium_monthly",4280 purchaseState,4281 expiresAt: at("2026-10-01"),4282 verifiedSignedDate: Date.now(),4283 });4284 const history = current(db).analytics?.accessHistory;4285 expect(accessAt(history, at("2026-09-30T23:59:59.999Z"))).toBeNull();4286 expect(accessAt(history, Date.now())).toBeNull();4287 },4288 );4289 it("keeps an undated receipt-only refund reversal unknown at signing time", async () => {4290 const db = new MemDb();4291 vi.setSystemTime(at("2026-01-16"));4292 const args = {4293 projectId: PROJECT_ID as Id<"projects">,4294 platform: "IOS" as const,4295 purchaseToken: TOKEN,4296 productId: "premium_monthly",4297 purchaseState: HarmonizedPurchaseState.CANCELED,4298 expiresAt: at("2026-06-01"),4299 verifiedPurchaseDate: at("2026-01-01"),4300 verifiedSignedDate: at("2026-01-16"),4301 evidenceAt: at("2026-01-15"),4302 };4303 await recordVerifiedSubscriptionHandler(makeCtx(db), args);4304 expect(current(db).state).toBe("Revoked");4305 expect(accessAt(current(db).analytics?.accessHistory, Date.now())).toBe(4306 false,4307 );4308 vi.setSystemTime(at("2026-01-20"));4309 await recordVerifiedSubscriptionHandler(makeCtx(db), {4310 ...args,4311 purchaseState: HarmonizedPurchaseState.ENTITLED,4312 verifiedSignedDate: Date.now(),4313 evidenceAt: at("2026-01-01"),4314 });4315 expect(current(db).state).toBe("Active");4316 expect(4317 accessAt(current(db).analytics?.accessHistory, Date.now()),4318 ).toBeNull();4319 expect(4320 accessAt(4321 current(db).analytics?.accessHistory,4322 at("2026-01-16T23:59:59.999Z"),4323 ),4324 ).toBe(false);4325 expect(current(db).analytics?.paidMicros).toBe(0);4326 });4327});4328