From 7cc8baa3a34984337956130f6df92637d0793360 Mon Sep 17 00:00:00 2001 From: Fayner Brack Date: Mon, 6 Jul 2026 00:36:12 +1000 Subject: [PATCH 1/3] fix(stripe): read-only reconciliation CLI + ERROR-level dropped-webhook logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a read-only Stripe-vs-DynamoDB reconciliation CLI (pnpm --dir projects/hutch stripe-reconcile) that pages through Stripe subscriptions and a full subscription-table scan, then reports five categories of drift via a pure, fully-tested reconcile() domain function. Customer emails are masked before they enter any finding. Also upgrade the customer.subscription.deleted "no subscription row found" log from WARN to a structured ERROR carrying subscriptionId, customerId, and eventType. The handler returns 200 so Stripe never retries and the Lambda-Errors alarm never fires — a dropped entitlement event is action-required, which is ERROR, not informational. Claude-Session: https://claude.ai/code/session_01En2uUiiwQoRQKWenCnkPi9 --- projects/hutch/package.json | 1 + .../domain/stripe-reconcile/reconcile.test.ts | 255 ++++++++++++++++++ .../domain/stripe-reconcile/reconcile.ts | 179 ++++++++++++ .../stripe-subscriptions.test.ts | 125 +++++++++ .../stripe-subscriptions.ts | 74 ++++- .../dynamodb-subscription-providers.test.ts | 98 +++++++ .../stripe-reconcile.cli.main.ts | 34 +++ .../customer-subscription-deleted.test.ts | 54 +++- .../handlers/customer-subscription-deleted.ts | 16 +- .../src/subscription-billing.ts | 22 ++ .../src/subscription-providers.ts | 2 + .../src/dynamodb-subscription-read.ts | 15 +- 12 files changed, 861 insertions(+), 14 deletions(-) create mode 100644 projects/hutch/src/runtime/domain/stripe-reconcile/reconcile.test.ts create mode 100644 projects/hutch/src/runtime/domain/stripe-reconcile/reconcile.ts create mode 100644 projects/hutch/src/runtime/stripe-reconcile/stripe-reconcile.cli.main.ts diff --git a/projects/hutch/package.json b/projects/hutch/package.json index 2194b598e..a4c9b812b 100644 --- a/projects/hutch/package.json +++ b/projects/hutch/package.json @@ -24,6 +24,7 @@ "permalink": "tsx src/runtime/web/pages/save/save-permalink.cli.main.ts", "backfill-registered-at": "tsx src/runtime/providers/auth/backfill-registered-at.cli.main.ts", "send-checkout-recovery-emails": "tsx src/runtime/checkout-recovery/send-checkout-recovery-emails.cli.main.ts", + "stripe-reconcile": "tsx src/runtime/stripe-reconcile/stripe-reconcile.cli.main.ts", "e2e-server": "node dist/e2e/e2e-server.main.js", "test": "jest --testMatch='**/dist/**/*.test.js' --testTimeout=10000", "test:sequential": "jest --testMatch='**/dist/**/*.test.js' --testTimeout=10000 --runInBand", diff --git a/projects/hutch/src/runtime/domain/stripe-reconcile/reconcile.test.ts b/projects/hutch/src/runtime/domain/stripe-reconcile/reconcile.test.ts new file mode 100644 index 000000000..2b9b71717 --- /dev/null +++ b/projects/hutch/src/runtime/domain/stripe-reconcile/reconcile.test.ts @@ -0,0 +1,255 @@ +import assert from "node:assert/strict"; +import { UserIdSchema } from "@packages/domain/user"; +import type { StripeSubscriptionSummary } from "@packages/provider-contracts/subscription-billing"; +import type { SubscriptionRecord } from "@packages/provider-contracts/subscription-providers"; +import { formatReconcileReport, maskEmail, reconcile } from "./reconcile"; + +const NOW = new Date("2026-07-05T00:00:00.000Z"); + +function appRow(overrides: Partial): SubscriptionRecord { + return { + userId: UserIdSchema.parse("usr_default"), + provider: "stripe", + status: "active", + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + ...overrides, + }; +} + +function stripeSub(overrides: Partial): StripeSubscriptionSummary { + return { + subscriptionId: "sub_default", + customerId: "cus_default", + status: "active", + ...overrides, + }; +} + +describe("maskEmail", () => { + it("masks a normal email to first-letter-only local and domain", () => { + assert.equal(maskEmail("jessika@gmail.com"), "j***@g***"); + }); + + it("returns *** when there is no @", () => { + assert.equal(maskEmail("nodomain"), "***"); + }); + + it("returns *** when the local part is empty", () => { + assert.equal(maskEmail("@gmail.com"), "***"); + }); + + it("returns *** when the domain part is empty", () => { + assert.equal(maskEmail("jessika@"), "***"); + }); +}); + +describe("reconcile", () => { + it("flags a Stripe sub with no app row and masks its customer email", () => { + const findings = reconcile({ + now: NOW, + appRows: [], + stripeSubs: [ + stripeSub({ + subscriptionId: "sub_orphan", + customerId: "cus_orphan", + status: "incomplete_expired", + customerEmail: "jessika@gmail.com", + }), + ], + }); + assert.equal(findings.stripeSubsMissingAppRow.length, 1); + assert.deepEqual(findings.stripeSubsMissingAppRow[0], { + subscriptionId: "sub_orphan", + customerId: "cus_orphan", + stripeStatus: "incomplete_expired", + maskedCustomerEmail: "j***@g***", + }); + }); + + it("omits maskedCustomerEmail when the Stripe sub has no customer email", () => { + const findings = reconcile({ + now: NOW, + appRows: [], + stripeSubs: [stripeSub({ subscriptionId: "sub_no_email" })], + }); + assert.equal(findings.stripeSubsMissingAppRow.length, 1); + assert.equal(findings.stripeSubsMissingAppRow[0].maskedCustomerEmail, undefined); + }); + + it("produces no findings for an active app row matched to a live Stripe sub", () => { + const findings = reconcile({ + now: NOW, + appRows: [appRow({ subscriptionId: "sub_ok", customerId: "cus_ok", status: "active" })], + stripeSubs: [stripeSub({ subscriptionId: "sub_ok", customerId: "cus_ok", status: "active" })], + }); + assert.equal(findings.stripeSubsMissingAppRow.length, 0); + assert.equal(findings.liveAppRowsMissingLiveStripeSub.length, 0); + assert.equal(findings.liveStripeSubsWithCancelledAppRow.length, 0); + assert.equal(findings.trialingRowsPastTrialEnd.length, 0); + assert.equal(findings.rowsMissingSubscriptionId.length, 0); + }); + + it("flags an active row with no subscriptionId in both liveAppRowsMissingLiveStripeSub and rowsMissingSubscriptionId", () => { + const userId = UserIdSchema.parse("usr_no_sub"); + const findings = reconcile({ + now: NOW, + appRows: [appRow({ userId, status: "active", subscriptionId: undefined })], + stripeSubs: [], + }); + assert.equal(findings.liveAppRowsMissingLiveStripeSub.length, 1); + assert.deepEqual(findings.liveAppRowsMissingLiveStripeSub[0], { + userId, + status: "active", + }); + assert.equal(findings.rowsMissingSubscriptionId.length, 1); + assert.deepEqual(findings.rowsMissingSubscriptionId[0], { userId, status: "active" }); + }); + + it("flags an active row whose Stripe sub is canceled as missing a live Stripe sub", () => { + const userId = UserIdSchema.parse("usr_dead_sub"); + const findings = reconcile({ + now: NOW, + appRows: [appRow({ userId, status: "active", subscriptionId: "sub_dead" })], + stripeSubs: [stripeSub({ subscriptionId: "sub_dead", status: "canceled" })], + }); + assert.equal(findings.liveAppRowsMissingLiveStripeSub.length, 1); + assert.deepEqual(findings.liveAppRowsMissingLiveStripeSub[0], { + userId, + status: "active", + subscriptionId: "sub_dead", + }); + }); + + it("flags a live Stripe sub whose app row is cancelled (paying customer without entitlement)", () => { + const userId = UserIdSchema.parse("usr_unentitled"); + const findings = reconcile({ + now: NOW, + appRows: [appRow({ userId, status: "cancelled", subscriptionId: "sub_live" })], + stripeSubs: [stripeSub({ subscriptionId: "sub_live", status: "active" })], + }); + assert.equal(findings.liveStripeSubsWithCancelledAppRow.length, 1); + assert.deepEqual(findings.liveStripeSubsWithCancelledAppRow[0], { + subscriptionId: "sub_live", + userId, + appStatus: "cancelled", + stripeStatus: "active", + }); + }); + + it("flags trialing rows with a past or absent trialEndsAt but not a future one", () => { + const past = UserIdSchema.parse("usr_past"); + const future = UserIdSchema.parse("usr_future"); + const noEnd = UserIdSchema.parse("usr_no_end"); + const findings = reconcile({ + now: NOW, + appRows: [ + appRow({ userId: past, status: "trialing", trialEndsAt: "2026-06-01T00:00:00.000Z" }), + appRow({ userId: future, status: "trialing", trialEndsAt: "2026-08-01T00:00:00.000Z" }), + appRow({ userId: noEnd, status: "trialing", trialEndsAt: undefined }), + ], + stripeSubs: [], + }); + const flagged = findings.trialingRowsPastTrialEnd.map((f) => f.userId); + assert.deepEqual(flagged.sort(), [past, noEnd].sort()); + const pastEntry = findings.trialingRowsPastTrialEnd.find((f) => f.userId === past); + assert.equal(pastEntry?.trialEndsAt, "2026-06-01T00:00:00.000Z"); + const noEndEntry = findings.trialingRowsPastTrialEnd.find((f) => f.userId === noEnd); + assert.equal(noEndEntry?.trialEndsAt, undefined); + }); + + it("puts a trialing row without subscriptionId in rowsMissingSubscriptionId but NOT liveAppRowsMissingLiveStripeSub", () => { + const userId = UserIdSchema.parse("usr_trial"); + const findings = reconcile({ + now: NOW, + appRows: [ + appRow({ + userId, + status: "trialing", + subscriptionId: undefined, + trialEndsAt: "2026-08-01T00:00:00.000Z", + }), + ], + stripeSubs: [], + }); + assert.equal(findings.liveAppRowsMissingLiveStripeSub.length, 0); + assert.equal(findings.rowsMissingSubscriptionId.length, 1); + assert.deepEqual(findings.rowsMissingSubscriptionId[0], { userId, status: "trialing" }); + }); +}); + +describe("formatReconcileReport", () => { + it("emits a header, per-entry lines with masked emails, and a summary — never a raw email", () => { + const findings = reconcile({ + now: NOW, + appRows: [ + appRow({ + userId: UserIdSchema.parse("usr_active_nosub"), + status: "active", + subscriptionId: undefined, + }), + appRow({ + userId: UserIdSchema.parse("usr_active_dead"), + status: "active", + subscriptionId: "sub_dead", + }), + appRow({ + userId: UserIdSchema.parse("usr_cancelled"), + status: "cancelled", + subscriptionId: "sub_live", + }), + appRow({ + userId: UserIdSchema.parse("usr_trial_stuck"), + status: "trialing", + subscriptionId: undefined, + trialEndsAt: "2026-06-01T00:00:00.000Z", + }), + ], + stripeSubs: [ + stripeSub({ + subscriptionId: "sub_orphan", + status: "incomplete_expired", + customerEmail: "jessika@gmail.com", + }), + stripeSub({ subscriptionId: "sub_orphan2", status: "canceled" }), + stripeSub({ subscriptionId: "sub_dead", status: "canceled" }), + stripeSub({ subscriptionId: "sub_live", status: "active" }), + ], + }); + + const lines = formatReconcileReport(findings); + + assert.equal(lines[0], "[stripe-reconcile] report (read-only, no writes)"); + for (const line of lines) { + assert.ok(!line.includes("jessika@"), `raw email leaked in: ${line}`); + } + assert.ok(lines.some((l) => l.includes("email=j***@g***"))); + assert.ok(lines.some((l) => l.includes("email=(none)"))); + assert.ok(lines.some((l) => l.includes("subscriptionId=(none)"))); + assert.ok(lines.some((l) => l.includes("subscriptionId=sub_dead"))); + assert.ok(lines.some((l) => l.includes("trialEndsAt=2026-06-01T00:00:00.000Z"))); + const summary = lines[lines.length - 1]; + assert.ok(summary.includes("stripeSubsMissingAppRow=2")); + assert.ok(summary.includes("liveAppRowsMissingLiveStripeSub=2")); + assert.ok(summary.includes("liveStripeSubsWithCancelledAppRow=1")); + assert.ok(summary.includes("trialingRowsPastTrialEnd=1")); + assert.ok(summary.includes("rowsMissingSubscriptionId=2")); + }); + + it("renders (none) for an absent trialEndsAt in the report", () => { + const findings = reconcile({ + now: NOW, + appRows: [ + appRow({ + userId: UserIdSchema.parse("usr_no_end"), + status: "trialing", + subscriptionId: undefined, + trialEndsAt: undefined, + }), + ], + stripeSubs: [], + }); + const lines = formatReconcileReport(findings); + assert.ok(lines.some((l) => l.includes("trialEndsAt=(none)"))); + }); +}); diff --git a/projects/hutch/src/runtime/domain/stripe-reconcile/reconcile.ts b/projects/hutch/src/runtime/domain/stripe-reconcile/reconcile.ts new file mode 100644 index 000000000..a2b518823 --- /dev/null +++ b/projects/hutch/src/runtime/domain/stripe-reconcile/reconcile.ts @@ -0,0 +1,179 @@ +import type { UserId } from "@packages/domain/user"; +import type { + StripeSubscriptionStatus, + StripeSubscriptionSummary, +} from "@packages/provider-contracts/subscription-billing"; +import type { + SubscriptionRecord, + SubscriptionStatus, +} from "@packages/provider-contracts/subscription-providers"; + +export function maskEmail(email: string): string { + const [local, domain] = email.split("@"); + if (!local || !domain) return "***"; + return `${local[0]}***@${domain[0]}***`; +} + +/** Statuses under which Stripe still considers a subscription entitled or + * payment-attempting — everything except the two terminal-dead ones + * (canceled, incomplete_expired). */ +const LIVE_STRIPE_STATUSES: ReadonlySet = new Set([ + "incomplete", + "trialing", + "active", + "past_due", + "unpaid", + "paused", +]); + +export type ReconcileFindings = { + stripeSubsMissingAppRow: { + subscriptionId: string; + customerId: string; + stripeStatus: StripeSubscriptionStatus; + maskedCustomerEmail?: string; + }[]; + liveAppRowsMissingLiveStripeSub: { + userId: UserId; + status: "active" | "pending_cancellation"; + subscriptionId?: string; + }[]; + liveStripeSubsWithCancelledAppRow: { + subscriptionId: string; + userId: UserId; + appStatus: SubscriptionStatus; + stripeStatus: StripeSubscriptionStatus; + }[]; + trialingRowsPastTrialEnd: { userId: UserId; trialEndsAt?: string }[]; + rowsMissingSubscriptionId: { userId: UserId; status: SubscriptionStatus }[]; +}; + +export function reconcile(params: { + now: Date; + appRows: SubscriptionRecord[]; + stripeSubs: StripeSubscriptionSummary[]; +}): ReconcileFindings { + const { now, appRows, stripeSubs } = params; + + const appBySubscriptionId = new Map(); + for (const row of appRows) { + if (row.subscriptionId !== undefined) appBySubscriptionId.set(row.subscriptionId, row); + } + const stripeBySubscriptionId = new Map(); + for (const sub of stripeSubs) stripeBySubscriptionId.set(sub.subscriptionId, sub); + + const stripeSubsMissingAppRow: ReconcileFindings["stripeSubsMissingAppRow"] = []; + for (const sub of stripeSubs) { + if (appBySubscriptionId.has(sub.subscriptionId)) continue; + stripeSubsMissingAppRow.push({ + subscriptionId: sub.subscriptionId, + customerId: sub.customerId, + stripeStatus: sub.status, + ...(sub.customerEmail ? { maskedCustomerEmail: maskEmail(sub.customerEmail) } : {}), + }); + } + + const liveAppRowsMissingLiveStripeSub: ReconcileFindings["liveAppRowsMissingLiveStripeSub"] = []; + for (const row of appRows) { + if (row.status !== "active" && row.status !== "pending_cancellation") continue; + const matched = + row.subscriptionId !== undefined + ? stripeBySubscriptionId.get(row.subscriptionId) + : undefined; + if (!matched || !LIVE_STRIPE_STATUSES.has(matched.status)) { + liveAppRowsMissingLiveStripeSub.push({ + userId: row.userId, + status: row.status, + ...(row.subscriptionId !== undefined ? { subscriptionId: row.subscriptionId } : {}), + }); + } + } + + const liveStripeSubsWithCancelledAppRow: ReconcileFindings["liveStripeSubsWithCancelledAppRow"] = + []; + for (const sub of stripeSubs) { + if (!LIVE_STRIPE_STATUSES.has(sub.status)) continue; + const matched = appBySubscriptionId.get(sub.subscriptionId); + if (matched && matched.status === "cancelled") { + liveStripeSubsWithCancelledAppRow.push({ + subscriptionId: sub.subscriptionId, + userId: matched.userId, + appStatus: matched.status, + stripeStatus: sub.status, + }); + } + } + + const trialingRowsPastTrialEnd: ReconcileFindings["trialingRowsPastTrialEnd"] = []; + for (const row of appRows) { + if (row.status !== "trialing") continue; + const expired = + row.trialEndsAt === undefined || Date.parse(row.trialEndsAt) < now.getTime(); + if (expired) { + trialingRowsPastTrialEnd.push({ + userId: row.userId, + ...(row.trialEndsAt !== undefined ? { trialEndsAt: row.trialEndsAt } : {}), + }); + } + } + + const rowsMissingSubscriptionId: ReconcileFindings["rowsMissingSubscriptionId"] = []; + for (const row of appRows) { + if (row.subscriptionId === undefined) { + rowsMissingSubscriptionId.push({ userId: row.userId, status: row.status }); + } + } + + return { + stripeSubsMissingAppRow, + liveAppRowsMissingLiveStripeSub, + liveStripeSubsWithCancelledAppRow, + trialingRowsPastTrialEnd, + rowsMissingSubscriptionId, + }; +} + +export function formatReconcileReport(findings: ReconcileFindings): string[] { + const lines: string[] = ["[stripe-reconcile] report (read-only, no writes)"]; + + lines.push(`Stripe subs with no matching app row: ${findings.stripeSubsMissingAppRow.length}`); + for (const entry of findings.stripeSubsMissingAppRow) { + lines.push( + ` sub=${entry.subscriptionId} customer=${entry.customerId} stripeStatus=${entry.stripeStatus} email=${entry.maskedCustomerEmail ?? "(none)"}`, + ); + } + + lines.push( + `Live app rows with no live Stripe sub: ${findings.liveAppRowsMissingLiveStripeSub.length}`, + ); + for (const entry of findings.liveAppRowsMissingLiveStripeSub) { + lines.push( + ` userId=${entry.userId} status=${entry.status} subscriptionId=${entry.subscriptionId ?? "(none)"}`, + ); + } + + lines.push( + `Live Stripe subs with cancelled app row: ${findings.liveStripeSubsWithCancelledAppRow.length}`, + ); + for (const entry of findings.liveStripeSubsWithCancelledAppRow) { + lines.push( + ` sub=${entry.subscriptionId} userId=${entry.userId} appStatus=${entry.appStatus} stripeStatus=${entry.stripeStatus}`, + ); + } + + lines.push(`Trialing rows past trial end: ${findings.trialingRowsPastTrialEnd.length}`); + for (const entry of findings.trialingRowsPastTrialEnd) { + lines.push(` userId=${entry.userId} trialEndsAt=${entry.trialEndsAt ?? "(none)"}`); + } + + lines.push(`Rows missing subscriptionId: ${findings.rowsMissingSubscriptionId.length}`); + for (const entry of findings.rowsMissingSubscriptionId) { + lines.push(` userId=${entry.userId} status=${entry.status}`); + } + + lines.push( + `[stripe-reconcile] summary: stripeSubsMissingAppRow=${findings.stripeSubsMissingAppRow.length} liveAppRowsMissingLiveStripeSub=${findings.liveAppRowsMissingLiveStripeSub.length} liveStripeSubsWithCancelledAppRow=${findings.liveStripeSubsWithCancelledAppRow.length} trialingRowsPastTrialEnd=${findings.trialingRowsPastTrialEnd.length} rowsMissingSubscriptionId=${findings.rowsMissingSubscriptionId.length}`, + ); + + return lines; +} diff --git a/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.test.ts b/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.test.ts index 60bd276ca..f74b6d3f5 100644 --- a/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.test.ts +++ b/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.test.ts @@ -255,6 +255,131 @@ describe("initStripeSubscriptions", () => { }); }); + describe("listAllSubscriptions", () => { + it("issues GET /v1/subscriptions with status=all, limit=100, expand data.customer and the pinned Stripe-Version", async () => { + let receivedUrl: string | undefined; + let receivedInit: RequestInit | undefined; + const fakeFetch: typeof globalThis.fetch = async (input, init) => { + receivedUrl = typeof input === "string" ? input : input.toString(); + receivedInit = init; + return jsonResponse(200, { has_more: false, data: [] }); + }; + + const stripe = initStripeSubscriptions({ apiKey: "sk_test_abc", fetch: fakeFetch }); + + const result = await stripe.listAllSubscriptions(); + + assert.deepEqual(result, []); + assert.ok(receivedUrl); + const url = new URL(receivedUrl); + assert.equal(url.origin + url.pathname, "https://api.stripe.com/v1/subscriptions"); + assert.equal(url.searchParams.get("status"), "all"); + assert.equal(url.searchParams.get("limit"), "100"); + assert.equal(url.searchParams.get("expand[]"), "data.customer"); + const headers = new Headers(receivedInit?.headers); + assert.equal(headers.get("Authorization"), "Bearer sk_test_abc"); + assert.equal(headers.get("Stripe-Version"), "2026-04-22.dahlia"); + }); + + it("paginates via starting_after until has_more is false", async () => { + const receivedUrls: string[] = []; + let call = 0; + const fakeFetch: typeof globalThis.fetch = async (input) => { + receivedUrls.push(typeof input === "string" ? input : input.toString()); + call++; + if (call === 1) { + return jsonResponse(200, { + has_more: true, + data: [{ id: "sub_1", status: "active", customer: "cus_1" }], + }); + } + return jsonResponse(200, { + has_more: false, + data: [{ id: "sub_2", status: "active", customer: "cus_2" }], + }); + }; + + const stripe = initStripeSubscriptions({ apiKey: "sk_test_abc", fetch: fakeFetch }); + + const result = await stripe.listAllSubscriptions(); + + assert.equal(result.length, 2); + assert.equal(new URL(receivedUrls[0]).searchParams.get("starting_after"), null); + assert.equal(new URL(receivedUrls[1]).searchParams.get("starting_after"), "sub_1"); + }); + + it("maps an expanded customer object to customerId + customerEmail", async () => { + const fakeFetch: typeof globalThis.fetch = async () => + jsonResponse(200, { + has_more: false, + data: [ + { + id: "sub_2", + status: "canceled", + customer: { id: "cus_2", email: "payer@example.com" }, + }, + ], + }); + + const stripe = initStripeSubscriptions({ apiKey: "sk_test_abc", fetch: fakeFetch }); + + const result = await stripe.listAllSubscriptions(); + + assert.deepEqual(result, [ + { + subscriptionId: "sub_2", + customerId: "cus_2", + status: "canceled", + customerEmail: "payer@example.com", + }, + ]); + }); + + it("omits customerEmail for the string-customer form and for an expanded customer without an email", async () => { + const fakeFetch: typeof globalThis.fetch = async () => + jsonResponse(200, { + has_more: false, + data: [ + { id: "sub_str", status: "active", customer: "cus_str" }, + { id: "sub_noemail", status: "active", customer: { id: "cus_noemail" } }, + ], + }); + + const stripe = initStripeSubscriptions({ apiKey: "sk_test_abc", fetch: fakeFetch }); + + const result = await stripe.listAllSubscriptions(); + + assert.equal(result[0].customerEmail, undefined); + assert.equal(result[0].customerId, "cus_str"); + assert.equal(result[1].customerEmail, undefined); + assert.equal(result[1].customerId, "cus_noemail"); + }); + + it("throws with the Stripe error message when the list API returns a non-2xx", async () => { + const fakeFetch: typeof globalThis.fetch = async () => + jsonResponse(500, { error: { code: "api_error", message: "Stripe is down" } }); + + const stripe = initStripeSubscriptions({ apiKey: "sk_test_abc", fetch: fakeFetch }); + + await assert.rejects( + () => stripe.listAllSubscriptions(), + /Stripe listAllSubscriptions failed \(500\): Stripe is down/, + ); + }); + + it("rejects when a subscription carries an unknown status", async () => { + const fakeFetch: typeof globalThis.fetch = async () => + jsonResponse(200, { + has_more: false, + data: [{ id: "sub_weird", status: "some_new_status", customer: "cus_weird" }], + }); + + const stripe = initStripeSubscriptions({ apiKey: "sk_test_abc", fetch: fakeFetch }); + + await assert.rejects(() => stripe.listAllSubscriptions()); + }); + }); + describe("reverseScheduledCancellation", () => { it("issues POST /v1/subscriptions/ with cancel_at_period_end=false", async () => { let receivedUrl: string | undefined; diff --git a/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.ts b/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.ts index 502a0c1dd..cc1757f25 100644 --- a/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.ts +++ b/projects/hutch/src/runtime/providers/stripe-subscriptions/stripe-subscriptions.ts @@ -1,10 +1,13 @@ +import assert from "node:assert"; import { z } from "zod"; -import type { - CancelSubscriptionImmediately, - CreateSubscriptionOnExistingCustomer, - DeleteCustomer, - ReverseScheduledCancellation, - ScheduleCancellationAtPeriodEnd, +import { + STRIPE_SUBSCRIPTION_STATUSES, + type CancelSubscriptionImmediately, + type CreateSubscriptionOnExistingCustomer, + type DeleteCustomer, + type ListAllStripeSubscriptions, + type ReverseScheduledCancellation, + type ScheduleCancellationAtPeriodEnd, } from "@packages/provider-contracts/subscription-billing"; const STRIPE_API = "https://api.stripe.com/v1"; @@ -41,6 +44,20 @@ const StripeReversedSubscriptionResponse = z.object({ trial_end: z.number().nullish(), }); +const StripeSubscriptionListResponse = z.object({ + has_more: z.boolean(), + data: z.array( + z.object({ + id: z.string(), + status: z.enum(STRIPE_SUBSCRIPTION_STATUSES), + customer: z.union([ + z.string(), + z.object({ id: z.string(), email: z.string().nullish() }), + ]), + }), + ), +}); + export function initStripeSubscriptions(deps: { apiKey: string; fetch: typeof globalThis.fetch; @@ -50,6 +67,7 @@ export function initStripeSubscriptions(deps: { scheduleCancellationAtPeriodEnd: ScheduleCancellationAtPeriodEnd; reverseScheduledCancellation: ReverseScheduledCancellation; deleteCustomer: DeleteCustomer; + listAllSubscriptions: ListAllStripeSubscriptions; } { const stripeHeaders = { Authorization: `Bearer ${deps.apiKey}`, @@ -211,11 +229,55 @@ export function initStripeSubscriptions(deps: { } }; + const listAllSubscriptions: ListAllStripeSubscriptions = async () => { + const summaries: Awaited> = []; + let startingAfter: string | undefined; + let hasMore = true; + while (hasMore) { + const params = new URLSearchParams({ status: "all", limit: "100" }); + params.append("expand[]", "data.customer"); + if (startingAfter) params.set("starting_after", startingAfter); + + const response = await deps.fetch(`${STRIPE_API}/subscriptions?${params}`, { + headers: stripeHeaders, + }); + + if (!response.ok) { + const message = await readStripeErrorMessage(response); + throw new Error(`Stripe listAllSubscriptions failed (${response.status}): ${message}`); + } + + const json = await response.json(); + const page = StripeSubscriptionListResponse.parse(json); + for (const item of page.data) { + const customerId = typeof item.customer === "string" ? item.customer : item.customer.id; + const customerEmail = + typeof item.customer !== "string" && item.customer.email + ? item.customer.email + : undefined; + summaries.push({ + subscriptionId: item.id, + customerId, + status: item.status, + ...(customerEmail ? { customerEmail } : {}), + }); + } + + hasMore = page.has_more; + if (hasMore) { + assert(page.data.length > 0, "Stripe reported has_more with an empty page"); + startingAfter = page.data[page.data.length - 1].id; + } + } + return summaries; + }; + return { cancelImmediately, createSubscriptionOnExistingCustomer, scheduleCancellationAtPeriodEnd, reverseScheduledCancellation, deleteCustomer, + listAllSubscriptions, }; } diff --git a/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.test.ts b/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.test.ts index 2fc8da230..553ed91f6 100644 --- a/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.test.ts +++ b/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.test.ts @@ -470,6 +470,104 @@ describe("initDynamoDbSubscriptionProviders", () => { }); }); + describe("listAllSubscriptionRows", () => { + it("returns every row on a single page with optional fields mapped", async () => { + const client = createFakeClient(() => ({ + Items: [ + { + userId: "u-trial", + provider: "stripe", + status: "trialing", + trialEndsAt: "2026-06-05T00:00:00.000Z", + createdAt: "2026-05-22T10:00:00.000Z", + updatedAt: "2026-05-22T10:00:00.000Z", + }, + { + userId: "u-active", + provider: "stripe", + status: "active", + subscriptionId: "sub_1", + customerId: "cus_1", + createdAt: "2026-05-22T10:00:00.000Z", + updatedAt: "2026-05-22T10:00:00.000Z", + }, + ], + LastEvaluatedKey: undefined, + })); + const subs = initDynamoDbSubscriptionProviders({ + client: client as DynamoDBDocumentClient, + tableName: TABLE, + now: NOW, + }); + + const rows = await subs.listAllSubscriptionRows(); + + assert.equal(rows.length, 2); + expect(rows[0].subscriptionId).toBeUndefined(); + expect(rows[0].trialEndsAt).toBe("2026-06-05T00:00:00.000Z"); + expect(rows[1].subscriptionId).toBe("sub_1"); + expect(rows[1].customerId).toBe("cus_1"); + }); + + it("paginates using ExclusiveStartKey until LastEvaluatedKey is absent", async () => { + const receivedKeys: unknown[] = []; + const client = createFakeClient((input) => { + const command = input as { input: { ExclusiveStartKey?: Record } }; + receivedKeys.push(command.input.ExclusiveStartKey); + if (command.input.ExclusiveStartKey === undefined) { + return { + Items: [ + { + userId: "u-1", + provider: "stripe", + status: "active", + subscriptionId: "sub_1", + customerId: "cus_1", + createdAt: "2026-05-22T10:00:00.000Z", + updatedAt: "2026-05-22T10:00:00.000Z", + }, + ], + LastEvaluatedKey: { userId: "u-1" }, + }; + } + return { + Items: [ + { + userId: "u-2", + provider: "stripe", + status: "cancelled", + createdAt: "2026-05-22T10:00:00.000Z", + updatedAt: "2026-05-22T10:00:00.000Z", + }, + ], + LastEvaluatedKey: undefined, + }; + }); + const subs = initDynamoDbSubscriptionProviders({ + client: client as DynamoDBDocumentClient, + tableName: TABLE, + now: NOW, + }); + + const rows = await subs.listAllSubscriptionRows(); + + assert.equal(rows.length, 2); + expect(rows.map((r) => r.userId)).toEqual(["u-1", "u-2"]); + assert.deepEqual(receivedKeys, [undefined, { userId: "u-1" }]); + }); + + it("returns an empty array when the table is empty", async () => { + const client = createFakeClient(() => ({ Items: [], LastEvaluatedKey: undefined })); + const subs = initDynamoDbSubscriptionProviders({ + client: client as DynamoDBDocumentClient, + tableName: TABLE, + now: NOW, + }); + + assert.deepEqual(await subs.listAllSubscriptionRows(), []); + }); + }); + describe("findByUserId with trialFeedbackEmailSentAt", () => { it("parses a cancelled trial row that carries trialFeedbackEmailSentAt", async () => { const client = createFakeClient(() => ({ diff --git a/projects/hutch/src/runtime/stripe-reconcile/stripe-reconcile.cli.main.ts b/projects/hutch/src/runtime/stripe-reconcile/stripe-reconcile.cli.main.ts new file mode 100644 index 000000000..fd6b3c92c --- /dev/null +++ b/projects/hutch/src/runtime/stripe-reconcile/stripe-reconcile.cli.main.ts @@ -0,0 +1,34 @@ +import { HutchLogger, consoleLogger } from "@packages/hutch-logger"; +import { createDynamoDocumentClient } from "@packages/hutch-storage-client"; +import { requireEnv } from "@packages/require-env"; +import { formatReconcileReport, reconcile } from "../domain/stripe-reconcile/reconcile"; +import { initStripeSubscriptions } from "../providers/stripe-subscriptions/stripe-subscriptions"; +import { initDynamoDbSubscriptionRead } from "../providers/subscription-providers/dynamodb-subscription-read"; + +const logger = HutchLogger.from(consoleLogger); + +async function main(): Promise { + const tableName = requireEnv("DYNAMODB_SUBSCRIPTION_PROVIDERS_TABLE"); + const stripeApiKey = requireEnv("STRIPE_SECRET_KEY"); + + const reads = initDynamoDbSubscriptionRead({ + client: createDynamoDocumentClient(), + tableName, + }); + const stripe = initStripeSubscriptions({ apiKey: stripeApiKey, fetch: globalThis.fetch }); + + logger.info(`[stripe-reconcile] Scanning ${tableName}…`); + const appRows = await reads.listAllSubscriptionRows(); + logger.info(`[stripe-reconcile] Found ${appRows.length} app subscription row(s).`); + + const stripeSubs = await stripe.listAllSubscriptions(); + logger.info(`[stripe-reconcile] Listed ${stripeSubs.length} Stripe subscription(s).`); + + const findings = reconcile({ now: new Date(), appRows, stripeSubs }); + for (const line of formatReconcileReport(findings)) logger.info(line); +} + +main().catch((err) => { + logger.error("[stripe-reconcile] Fatal:", err); + process.exit(1); +}); diff --git a/projects/hutch/src/runtime/stripe-webhook-receiver/handlers/customer-subscription-deleted.test.ts b/projects/hutch/src/runtime/stripe-webhook-receiver/handlers/customer-subscription-deleted.test.ts index f151f7a5d..7142cdf33 100644 --- a/projects/hutch/src/runtime/stripe-webhook-receiver/handlers/customer-subscription-deleted.test.ts +++ b/projects/hutch/src/runtime/stripe-webhook-receiver/handlers/customer-subscription-deleted.test.ts @@ -125,19 +125,67 @@ describe("initHandleCustomerSubscriptionDeleted", () => { }); }); - it("skips emission when the subscriptionId has no matching row (already removed)", async () => { + it("logs a structured ERROR (not WARN) with customerId and a reconcile pointer, and skips emission, when no row matches", async () => { const published: unknown[] = []; + const errorCalls: unknown[][] = []; + const logger = HutchLogger.from({ + info: () => {}, + warn: () => {}, + debug: () => {}, + error: (...args: unknown[]) => { + errorCalls.push(args); + }, + }); const handle = initHandleCustomerSubscriptionDeleted({ findSubscriptionBySubscriptionId: async () => undefined, publishEvent: async (event, detail) => { published.push({ event, detail }); }, }); await handle({ - stripeEvent: buildStripeEvent("sub_gone"), - logger: HutchLogger.from(noopLogger), + stripeEvent: { + type: "customer.subscription.deleted", + data: { object: { id: "sub_gone", customer: "cus_gone" } }, + }, + logger, }); assert.equal(published.length, 0); + assert.equal(errorCalls.length, 1); + assert.match(String(errorCalls[0][0]), /no subscription row found/); + assert.match(String(errorCalls[0][0]), /stripe-reconcile/); + assert.deepStrictEqual(errorCalls[0][1], { + subscriptionId: "sub_gone", + customerId: "cus_gone", + eventType: "customer.subscription.deleted", + }); + }); + + it("falls back to customerId 'unknown' when the deleted event carries no customer field", async () => { + const errorCalls: unknown[][] = []; + const logger = HutchLogger.from({ + info: () => {}, + warn: () => {}, + debug: () => {}, + error: (...args: unknown[]) => { + errorCalls.push(args); + }, + }); + const handle = initHandleCustomerSubscriptionDeleted({ + findSubscriptionBySubscriptionId: async () => undefined, + publishEvent: async () => {}, + }); + + await handle({ + stripeEvent: buildStripeEvent("sub_no_customer"), + logger, + }); + + assert.equal(errorCalls.length, 1); + assert.deepStrictEqual(errorCalls[0][1], { + subscriptionId: "sub_no_customer", + customerId: "unknown", + eventType: "customer.subscription.deleted", + }); }); it("propagates EventBridge failures so the caller bubbles a 5xx and Stripe retries", async () => { diff --git a/projects/hutch/src/runtime/stripe-webhook-receiver/handlers/customer-subscription-deleted.ts b/projects/hutch/src/runtime/stripe-webhook-receiver/handlers/customer-subscription-deleted.ts index 393efd32c..233a957b4 100644 --- a/projects/hutch/src/runtime/stripe-webhook-receiver/handlers/customer-subscription-deleted.ts +++ b/projects/hutch/src/runtime/stripe-webhook-receiver/handlers/customer-subscription-deleted.ts @@ -1,7 +1,7 @@ -import { z } from "zod"; import { SubscriptionCancelledEvent } from "@packages/hutch-infra-components"; import type { PublishEvent } from "@packages/hutch-infra-components/runtime"; import type { FindSubscriptionBySubscriptionId } from "@packages/provider-contracts/subscription-providers"; +import { z } from "zod"; import type { StripeEventHandler } from "../stripe-webhook-receiver-handler"; const CancellationDetails = z.object({ @@ -19,6 +19,8 @@ function cancelReason( : "stripe_webhook"; } +const StripeCustomerField = z.object({ customer: z.string() }); + export type CustomerSubscriptionDeletedDeps = { findSubscriptionBySubscriptionId: FindSubscriptionBySubscriptionId; publishEvent: PublishEvent; @@ -31,9 +33,15 @@ export function initHandleCustomerSubscriptionDeleted( const subscriptionId = stripeEvent.data.object.id; const row = await deps.findSubscriptionBySubscriptionId(subscriptionId); if (!row) { - logger.warn("[stripe-webhook] no subscription row found — skipping event emission", { - subscriptionId, - }); + const customerField = StripeCustomerField.safeParse(stripeEvent.data.object); + logger.error( + "[stripe-webhook] no subscription row found for entitlement-affecting event — returning 200 so Stripe will NOT retry; run 'pnpm --dir projects/hutch stripe-reconcile' to diff app rows against Stripe", + { + subscriptionId, + customerId: customerField.success ? customerField.data.customer : "unknown", + eventType: stripeEvent.type, + }, + ); return; } const reason = cancelReason(stripeEvent.data.object); diff --git a/src/packages/provider-contracts/src/subscription-billing.ts b/src/packages/provider-contracts/src/subscription-billing.ts index 56fb97361..bb8eb0173 100644 --- a/src/packages/provider-contracts/src/subscription-billing.ts +++ b/src/packages/provider-contracts/src/subscription-billing.ts @@ -19,3 +19,25 @@ export type ReverseScheduledCancellation = (input: { }) => Promise<{ trialEndsAt?: string }>; export type DeleteCustomer = (input: { customerId: string }) => Promise; + +export const STRIPE_SUBSCRIPTION_STATUSES = [ + "incomplete", + "incomplete_expired", + "trialing", + "active", + "past_due", + "canceled", + "unpaid", + "paused", +] as const; + +export type StripeSubscriptionStatus = (typeof STRIPE_SUBSCRIPTION_STATUSES)[number]; + +export type StripeSubscriptionSummary = { + subscriptionId: string; + customerId: string; + status: StripeSubscriptionStatus; + customerEmail?: string; +}; + +export type ListAllStripeSubscriptions = () => Promise; diff --git a/src/packages/provider-contracts/src/subscription-providers.ts b/src/packages/provider-contracts/src/subscription-providers.ts index 4d5c78102..0af0a097a 100644 --- a/src/packages/provider-contracts/src/subscription-providers.ts +++ b/src/packages/provider-contracts/src/subscription-providers.ts @@ -65,3 +65,5 @@ export type MarkTrialReminderEmailSent = (input: { userId: UserId; sentAt: string; }) => Promise; + +export type ListAllSubscriptionRows = () => Promise; diff --git a/src/packages/subscription-access/src/dynamodb-subscription-read.ts b/src/packages/subscription-access/src/dynamodb-subscription-read.ts index b00245338..cdc3a4034 100644 --- a/src/packages/subscription-access/src/dynamodb-subscription-read.ts +++ b/src/packages/subscription-access/src/dynamodb-subscription-read.ts @@ -5,6 +5,7 @@ import { import type { FindSubscriptionBySubscriptionId, FindSubscriptionByUserId, + ListAllSubscriptionRows, } from "@packages/provider-contracts/subscription-providers"; import { SubscriptionProviderRow, toRecord } from "./subscription-provider-row"; @@ -18,6 +19,7 @@ export function initDynamoDbSubscriptionRead(deps: { }): { findByUserId: FindSubscriptionByUserId; findBySubscriptionId: FindSubscriptionBySubscriptionId; + listAllSubscriptionRows: ListAllSubscriptionRows; } { const table = defineDynamoTable({ client: deps.client, @@ -41,5 +43,16 @@ export function initDynamoDbSubscriptionRead(deps: { return row ? toRecord(row) : undefined; }; - return { findByUserId, findBySubscriptionId }; + const listAllSubscriptionRows: ListAllSubscriptionRows = async () => { + const records = []; + let lastEvaluatedKey: Record | undefined; + do { + const page = await table.scan({ ExclusiveStartKey: lastEvaluatedKey }); + for (const row of page.items) records.push(toRecord(row)); + lastEvaluatedKey = page.lastEvaluatedKey; + } while (lastEvaluatedKey !== undefined); + return records; + }; + + return { findByUserId, findBySubscriptionId, listAllSubscriptionRows }; } From 2376b26189a8cc846b12b3ed8cee6dd0d554a540 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:27:11 +0000 Subject: [PATCH 2/3] fix: resolve CI failure (attempt #2) No-op commit to re-trigger CI. The failure was a flaky firefox-extension reader-link E2E timeout (30s waiting for the login #email field), not a defect in this Stripe-only diff: - The sibling "OAuth login flow" E2E passed in the same CI run using the same #email field, so the login page renders fine in this build. - The reader-link E2E passes locally in ~10s and nx auto-flagged firefox-extension:check as a flaky task (three separate local runs). - This PR changes only Stripe files; the one server-path change adds listAllSubscriptionRows, called exclusively by the reconcile CLI, never in the login/reader request path. Full `pnpm check` is green locally (37 projects, 48 tasks). No API permission to re-run the workflow, so re-triggering CI with this commit. Co-authored-by: Fayner Brack Co-Authored-By: Claude Opus 4.8 (1M context) From f238698d192035070e9c4738829d13f5cd857fd8 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:34:51 +0000 Subject: [PATCH 3/3] fix(stripe): adapt reconcile CLI + coverage to relocated subscription read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto main picked up the inbox extraction (#950, 963345c2), which moved initDynamoDbSubscriptionRead out of hutch into @packages/subscription-access. Git's rename detection reapplied this PR's listAllSubscriptionRows addition onto the relocated file, but left two loose ends the rebase could not see: - The reconcile CLI still imported initDynamoDbSubscriptionRead from the now-deleted hutch-local path. Repoint it to @packages/subscription-access, matching app.ts and the composed dynamodb-subscription-providers. - listAllSubscriptionRows coverage lived only in hutch's composed test, so the relocated function body was uncovered by its new package's own suite. Add the matching unit tests (single page, ExclusiveStartKey pagination, empty table) to subscription-access — restoring 100% coverage there. No Stripe logic changed; this only re-integrates the PR's additions with main's module move. Full `pnpm check` green (42 projects / 60 tasks). Co-authored-by: Fayner Brack Co-Authored-By: Claude Opus 4.8 (1M context) --- .../stripe-reconcile.cli.main.ts | 2 +- .../src/dynamodb-subscription-read.test.ts | 87 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/projects/hutch/src/runtime/stripe-reconcile/stripe-reconcile.cli.main.ts b/projects/hutch/src/runtime/stripe-reconcile/stripe-reconcile.cli.main.ts index fd6b3c92c..16362de68 100644 --- a/projects/hutch/src/runtime/stripe-reconcile/stripe-reconcile.cli.main.ts +++ b/projects/hutch/src/runtime/stripe-reconcile/stripe-reconcile.cli.main.ts @@ -1,9 +1,9 @@ import { HutchLogger, consoleLogger } from "@packages/hutch-logger"; import { createDynamoDocumentClient } from "@packages/hutch-storage-client"; import { requireEnv } from "@packages/require-env"; +import { initDynamoDbSubscriptionRead } from "@packages/subscription-access"; import { formatReconcileReport, reconcile } from "../domain/stripe-reconcile/reconcile"; import { initStripeSubscriptions } from "../providers/stripe-subscriptions/stripe-subscriptions"; -import { initDynamoDbSubscriptionRead } from "../providers/subscription-providers/dynamodb-subscription-read"; const logger = HutchLogger.from(consoleLogger); diff --git a/src/packages/subscription-access/src/dynamodb-subscription-read.test.ts b/src/packages/subscription-access/src/dynamodb-subscription-read.test.ts index b9ccc8d85..52bb27f32 100644 --- a/src/packages/subscription-access/src/dynamodb-subscription-read.test.ts +++ b/src/packages/subscription-access/src/dynamodb-subscription-read.test.ts @@ -81,4 +81,91 @@ describe("initDynamoDbSubscriptionRead", () => { expect(await read.findBySubscriptionId("sub_missing")).toBeUndefined(); }); }); + + describe("listAllSubscriptionRows", () => { + it("returns every row on a single page with optional fields mapped", async () => { + const client = createFakeClient(() => ({ + Items: [ + { + userId: "u-trial", + provider: "stripe", + status: "trialing", + trialEndsAt: "2026-06-05T00:00:00.000Z", + createdAt: "2026-05-22T10:00:00.000Z", + updatedAt: "2026-05-22T10:00:00.000Z", + }, + ACTIVE_ROW, + ], + LastEvaluatedKey: undefined, + })); + const read = initDynamoDbSubscriptionRead({ + client: client as DynamoDBDocumentClient, + tableName: TABLE, + }); + + const rows = await read.listAllSubscriptionRows(); + + assert.equal(rows.length, 2); + expect(rows[0].subscriptionId).toBeUndefined(); + expect(rows[0].trialEndsAt).toBe("2026-06-05T00:00:00.000Z"); + expect(rows[1].subscriptionId).toBe("sub_123"); + expect(rows[1].customerId).toBe("cus_123"); + }); + + it("paginates using ExclusiveStartKey until LastEvaluatedKey is absent", async () => { + const receivedKeys: unknown[] = []; + const client = createFakeClient((input) => { + const command = input as { input: { ExclusiveStartKey?: Record } }; + receivedKeys.push(command.input.ExclusiveStartKey); + if (command.input.ExclusiveStartKey === undefined) { + return { + Items: [ + { + userId: "u-1", + provider: "stripe", + status: "active", + subscriptionId: "sub_1", + customerId: "cus_1", + createdAt: "2026-05-22T10:00:00.000Z", + updatedAt: "2026-05-22T10:00:00.000Z", + }, + ], + LastEvaluatedKey: { userId: "u-1" }, + }; + } + return { + Items: [ + { + userId: "u-2", + provider: "stripe", + status: "cancelled", + createdAt: "2026-05-22T10:00:00.000Z", + updatedAt: "2026-05-22T10:00:00.000Z", + }, + ], + LastEvaluatedKey: undefined, + }; + }); + const read = initDynamoDbSubscriptionRead({ + client: client as DynamoDBDocumentClient, + tableName: TABLE, + }); + + const rows = await read.listAllSubscriptionRows(); + + assert.equal(rows.length, 2); + expect(rows.map((r) => r.userId)).toEqual(["u-1", "u-2"]); + assert.deepEqual(receivedKeys, [undefined, { userId: "u-1" }]); + }); + + it("returns an empty array when the table is empty", async () => { + const client = createFakeClient(() => ({ Items: [], LastEvaluatedKey: undefined })); + const read = initDynamoDbSubscriptionRead({ + client: client as DynamoDBDocumentClient, + tableName: TABLE, + }); + + assert.deepEqual(await read.listAllSubscriptionRows(), []); + }); + }); });