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 aab5ef2ae..3c18a7bf1 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 f97e224a6..53f9744ee 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"; @@ -36,6 +39,20 @@ const StripeScheduledCancellationResponse = z.object({ cancel_at: z.number(), }); +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; @@ -45,6 +62,7 @@ export function initStripeSubscriptions(deps: { scheduleCancellationAtPeriodEnd: ScheduleCancellationAtPeriodEnd; reverseScheduledCancellation: ReverseScheduledCancellation; deleteCustomer: DeleteCustomer; + listAllSubscriptions: ListAllStripeSubscriptions; } { const stripeHeaders = { Authorization: `Bearer ${deps.apiKey}`, @@ -200,11 +218,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..16362de68 --- /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 { initDynamoDbSubscriptionRead } from "@packages/subscription-access"; +import { formatReconcileReport, reconcile } from "../domain/stripe-reconcile/reconcile"; +import { initStripeSubscriptions } from "../providers/stripe-subscriptions/stripe-subscriptions"; + +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 54bf4de1c..ba491d303 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 @@ -51,19 +51,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 a5031d0b6..c237d5c10 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,8 +1,11 @@ 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 StripeCustomerField = z.object({ customer: z.string() }); + export type CustomerSubscriptionDeletedDeps = { findSubscriptionBySubscriptionId: FindSubscriptionBySubscriptionId; publishEvent: PublishEvent; @@ -15,9 +18,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; } await deps.publishEvent(SubscriptionCancelledEvent, { diff --git a/src/packages/provider-contracts/src/subscription-billing.ts b/src/packages/provider-contracts/src/subscription-billing.ts index 56a62b572..5f2e8ac10 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; 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.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(), []); + }); + }); }); 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 }; }