diff --git a/.gitignore b/.gitignore index a82a93f..1983750 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /apps/test-init-app +docs/superpowers # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Dependencies diff --git a/e2e/cli/status.test.ts b/e2e/cli/status.test.ts index 1d0ffdc..10f1d17 100644 --- a/e2e/cli/status.test.ts +++ b/e2e/cli/status.test.ts @@ -2,7 +2,6 @@ import { sql } from "drizzle-orm"; import { Pool } from "pg"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getStripeAccountInfo } from "../../packages/paykit/src/cli/utils/format"; import { getPayKitConfig } from "../../packages/paykit/src/cli/utils/get-config"; import { createContext } from "../../packages/paykit/src/core/context"; import { @@ -61,14 +60,6 @@ describe("paykitjs status", () => { } }); - it("should connect to Stripe and retrieve account info", async () => { - const secretKey = process.env.STRIPE_SECRET_KEY!; - const info = await getStripeAccountInfo(secretKey); - - expect(info.displayName).toBeTruthy(); - expect(info.mode).toBe("test mode"); - }); - it("should report schema up to date after migration", async () => { const config = await getPayKitConfig({ cwd: fixture.cwd }); const database = resolveDatabase(config.options.database); diff --git a/e2e/smoke/setup.ts b/e2e/smoke/setup.ts index a1dc4e5..d44f0f8 100644 --- a/e2e/smoke/setup.ts +++ b/e2e/smoke/setup.ts @@ -74,18 +74,12 @@ export const extraMessagesPlan = plan({ price: { amount: 5, interval: "month" }, }); -type SmokePlans = { - free: typeof freePlan; - pro: typeof proPlan; - premium: typeof premiumPlan; - ultra: typeof ultraPlan; - extra_messages: typeof extraMessagesPlan; -}; +const smokePlans = [freePlan, proPlan, premiumPlan, ultraPlan, extraMessagesPlan] as const; type SmokePayKit = ReturnType< typeof createPayKit<{ database: Pool; - plans: SmokePlans; + plans: typeof smokePlans; provider: ReturnType; testing: { enabled: true }; }> @@ -144,13 +138,7 @@ export async function createTestPayKit(): Promise { const stripeProvider = stripe({ secretKey, webhookSecret }); const paykit = createPayKit({ database: pool, - plans: { - free: freePlan, - pro: proPlan, - premium: premiumPlan, - ultra: ultraPlan, - extra_messages: extraMessagesPlan, - }, + plans: smokePlans, provider: stripeProvider, testing: { enabled: true }, }); @@ -160,7 +148,10 @@ export async function createTestPayKit(): Promise { // Override createSubscription to use allow_incomplete. The default // payment_behavior: "default_incomplete" requires client-side payment // confirmation which isn't possible in automated tests. - ctx.stripe.createSubscription = async (data) => { + (ctx.provider as unknown as Record).createSubscription = async (data: { + providerCustomerId: string; + providerPriceId: string; + }) => { const sub = await stripeClient.subscriptions.create({ customer: data.providerCustomerId, items: [{ price: data.providerPriceId }], diff --git a/packages/paykit/package.json b/packages/paykit/package.json index 4c1101a..5bec4bd 100644 --- a/packages/paykit/package.json +++ b/packages/paykit/package.json @@ -73,7 +73,6 @@ "pino": "^10.3.1", "pino-pretty": "^13.1.3", "posthog-node": "^5.28.8", - "stripe": "^19.1.0", "typescript": "^5.9.2", "zod": "^4.0.0" }, diff --git a/packages/paykit/src/api/define-route.ts b/packages/paykit/src/api/define-route.ts index 73eca39..68c0b07 100644 --- a/packages/paykit/src/api/define-route.ts +++ b/packages/paykit/src/api/define-route.ts @@ -4,7 +4,7 @@ import * as z from "zod"; import type { PayKitContext } from "../core/context"; import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors"; -import { getCustomerByIdOrThrow, syncCustomerWithDefaults } from "../customer/customer.service"; +import { getCustomerByIdOrThrow, upsertCustomer } from "../customer/customer.service"; import type { Customer } from "../types/models"; const paykitMiddleware = createMiddleware(async () => { @@ -437,7 +437,7 @@ async function resolveCustomer( throw PayKitError.from("FORBIDDEN", PAYKIT_ERROR_CODES.CUSTOMER_ID_MISMATCH); } - return syncCustomerWithDefaults(ctx, { + return upsertCustomer(ctx, { id: identity.customerId, email: identity.email, name: identity.name, diff --git a/packages/paykit/src/cli/commands/push.ts b/packages/paykit/src/cli/commands/push.ts index 19df39a..2c09f05 100644 --- a/packages/paykit/src/cli/commands/push.ts +++ b/packages/paykit/src/cli/commands/push.ts @@ -5,7 +5,7 @@ import { Command } from "commander"; import picocolors from "picocolors"; import { - checkStripe, + checkProvider, createPool, formatProductDiffs, loadCliDeps, @@ -37,14 +37,14 @@ async function pushAction(options: { config?: string; cwd: string; yes?: boolean } const connStr = deps.getConnectionString(database as never); - const [stripeResult, pendingMigrations] = await Promise.all([ - checkStripe(deps, config.options.provider.secretKey), + const [providerResult, pendingMigrations] = await Promise.all([ + checkProvider(config.options.provider), deps.getPendingMigrationCount(database), ]); - if (!stripeResult.account.ok) { + if (!providerResult.account.ok) { s.stop(""); - p.log.error(`Stripe\n ${picocolors.red("✖")} ${stripeResult.account.message}`); + p.log.error(`Provider\n ${picocolors.red("✖")} ${providerResult.account.message}`); p.cancel("Push failed"); process.exit(1); } @@ -71,7 +71,7 @@ async function pushAction(options: { config?: string; cwd: string; yes?: boolean p.log.info(`Database\n ${picocolors.green("✔")} ${connStr}\n ${migrationStatus}`); p.log.info( - `Stripe\n ${picocolors.green("✔")} ${stripeResult.account.displayName} (${stripeResult.account.mode})`, + `Provider\n ${picocolors.green("✔")} ${providerResult.account.displayName} (${providerResult.account.mode})`, ); if (diffs.length > 0) { @@ -95,7 +95,7 @@ async function pushAction(options: { config?: string; cwd: string; yes?: boolean const changeCount = diffs.filter((d) => d.action !== "unchanged").length; if (!options.yes) { const shouldContinue = await p.confirm({ - message: `Push ${String(changeCount)} product change${changeCount === 1 ? "" : "s"} to Stripe?`, + message: `Push ${String(changeCount)} product change${changeCount === 1 ? "" : "s"}?`, }); if (p.isCancel(shouldContinue) || !shouldContinue) { p.cancel("Aborted"); diff --git a/packages/paykit/src/cli/commands/status.ts b/packages/paykit/src/cli/commands/status.ts index 5d42ec7..851e552 100644 --- a/packages/paykit/src/cli/commands/status.ts +++ b/packages/paykit/src/cli/commands/status.ts @@ -6,7 +6,7 @@ import picocolors from "picocolors"; import { checkDatabase, - checkStripe, + checkProvider, createPool, formatProductDiffs, loadCliDeps, @@ -58,13 +58,13 @@ async function statusAction(options: { process.exit(1); } - // Database + Stripe in parallel + // Database + Provider in parallel const database = createPool(deps, config.options.database); const connStr = deps.getConnectionString(database as never); - const [dbResult, stripeResult] = await Promise.all([ + const [dbResult, providerResult] = await Promise.all([ checkDatabase(database, deps), - checkStripe(deps, config.options.provider.secretKey), + checkProvider(config.options.provider), ]); if (!dbResult.ok) { @@ -75,10 +75,10 @@ async function statusAction(options: { process.exit(1); } - if (!stripeResult.account.ok) { + if (!providerResult.account.ok) { s.stop(""); - p.log.error(`Stripe\n ${picocolors.red("✖")} ${stripeResult.account.message}`); - p.outro("Fix Stripe issues before continuing"); + p.log.error(`Provider\n ${picocolors.red("✖")} ${providerResult.account.message}`); + p.outro("Fix provider issues before continuing"); await database.end(); process.exit(1); } @@ -86,15 +86,16 @@ async function statusAction(options: { const pendingMigrations = dbResult.pendingMigrations; let webhookStatus: string; - if (stripeResult.webhooks === null) { + if (providerResult.webhookEndpoints === null) { webhookStatus = `${picocolors.dim("?")} Could not check webhook status`; - } else if (stripeResult.webhooks.length > 0) { - const lines = stripeResult.webhooks.map((ep) => - picocolors.dim(`· Webhook endpoint registered (${ep.url})`), - ); + } else if (providerResult.webhookEndpoints.length > 0) { + const lines = providerResult.webhookEndpoints.map((ep) => { + const label = ep.status === "enabled" ? "registered" : `status: ${ep.status}`; + return picocolors.dim(`· Webhook endpoint ${label} (${ep.url})`); + }); webhookStatus = lines.join("\n "); } else { - webhookStatus = picocolors.dim("· No webhook endpoint (use Stripe CLI for local testing)"); + webhookStatus = picocolors.dim("· No webhook endpoint (use provider CLI for local testing)"); } // Products @@ -135,14 +136,14 @@ async function statusAction(options: { `Config\n` + ` ${picocolors.green("✔")} ${picocolors.dim(config.path)}\n` + ` ${picocolors.green("✔")} ${String(planCount)} plan${planCount === 1 ? "" : "s"} defined\n` + - ` ${picocolors.green("✔")} Stripe provider configured`, + ` ${picocolors.green("✔")} Provider configured`, ); p.log.info(`Database\n ${picocolors.green("✔")} ${connStr}\n ${migrationStatus}`); p.log.info( - `Stripe\n` + - ` ${picocolors.green("✔")} ${stripeResult.account.displayName} (${stripeResult.account.mode})\n` + + `Provider\n` + + ` ${picocolors.green("✔")} ${providerResult.account.displayName} (${providerResult.account.mode})\n` + ` ${webhookStatus}`, ); diff --git a/packages/paykit/src/cli/utils/format.ts b/packages/paykit/src/cli/utils/format.ts index f7f5c4c..baa3933 100644 --- a/packages/paykit/src/cli/utils/format.ts +++ b/packages/paykit/src/cli/utils/format.ts @@ -1,23 +1,4 @@ import picocolors from "picocolors"; -import StripeSdk from "stripe"; - -export interface StripeAccountInfo { - displayName: string; - mode: "test mode" | "live mode"; -} - -export async function getStripeAccountInfo(secretKey: string): Promise { - const mode = stripeMode(secretKey); - try { - const client = new StripeSdk(secretKey); - const account = await client.accounts.retrieve(); - const name = - account.settings?.dashboard?.display_name || account.business_profile?.name || account.id; - return { displayName: name, mode }; - } catch { - return { displayName: "unknown", mode }; - } -} export function maskConnectionString(url: string): string { try { @@ -46,12 +27,6 @@ export function formatPrice(amountCents: number, interval: string | null): strin return `${formatted}/${interval}`; } -export function stripeMode(secretKey: string): "test mode" | "live mode" { - return secretKey.startsWith("sk_test_") || secretKey.startsWith("rk_test_") - ? "test mode" - : "live mode"; -} - export function getConnectionString(pool: { options?: { connectionString?: string; diff --git a/packages/paykit/src/cli/utils/shared.ts b/packages/paykit/src/cli/utils/shared.ts index 3cbcb75..469192f 100644 --- a/packages/paykit/src/cli/utils/shared.ts +++ b/packages/paykit/src/cli/utils/shared.ts @@ -1,24 +1,18 @@ import type { Pool } from "pg"; -import type StripeSdk from "stripe"; import type { createContext, PayKitContext } from "../../core/context"; import type { getPendingMigrationCount, migrateDatabase } from "../../database/index"; import type { dryRunSyncProducts, syncProducts } from "../../product/product-sync.service"; +import type { PayKitProviderConfig } from "../../providers/provider"; import type { PayKitOptions } from "../../types/options"; import type { NormalizedPlan } from "../../types/schema"; import type { detectPackageManager, getInstallCommand, getRunCommand } from "./detect"; -import type { - formatPlanLine, - formatPrice, - getConnectionString, - getStripeAccountInfo, -} from "./format"; +import type { formatPlanLine, formatPrice, getConnectionString } from "./format"; import type { getPayKitConfig } from "./get-config"; import type { capture } from "./telemetry"; export interface CliDeps { Pool: typeof Pool; - StripeSdk: typeof StripeSdk; createContext: typeof createContext; getPendingMigrationCount: typeof getPendingMigrationCount; migrateDatabase: typeof migrateDatabase; @@ -27,7 +21,6 @@ export interface CliDeps { formatPlanLine: typeof formatPlanLine; formatPrice: typeof formatPrice; getConnectionString: typeof getConnectionString; - getStripeAccountInfo: typeof getStripeAccountInfo; getPayKitConfig: typeof getPayKitConfig; capture: typeof capture; detectPackageManager: typeof detectPackageManager; @@ -36,10 +29,9 @@ export interface CliDeps { } export async function loadCliDeps(): Promise { - const [pg, stripe, context, database, productSync, format, getConfig, telemetry, detect] = + const [pg, context, database, productSync, format, getConfig, telemetry, detect] = await Promise.all([ import("pg"), - import("stripe"), import("../../core/context"), import("../../database/index"), import("../../product/product-sync.service"), @@ -51,7 +43,6 @@ export async function loadCliDeps(): Promise { return { Pool: pg.Pool, - StripeSdk: stripe.default, createContext: context.createContext, getPendingMigrationCount: database.getPendingMigrationCount, migrateDatabase: database.migrateDatabase, @@ -60,7 +51,6 @@ export async function loadCliDeps(): Promise { formatPlanLine: format.formatPlanLine, formatPrice: format.formatPrice, getConnectionString: format.getConnectionString, - getStripeAccountInfo: format.getStripeAccountInfo, getPayKitConfig: getConfig.getPayKitConfig, capture: telemetry.capture, detectPackageManager: detect.detectPackageManager, @@ -111,44 +101,43 @@ export async function checkDatabase( } } -export interface StripeCheckResult { +export interface ProviderCheckResult { account: { ok: true; displayName: string; mode: string } | { ok: false; message: string }; - webhooks: Array<{ url: string }> | null; + webhookEndpoints: Array<{ url: string; status: string }> | null; } -export async function checkStripe( - deps: Pick, - secretKey: string, -): Promise { - const client = new deps.StripeSdk(secretKey); - const mode = - secretKey.startsWith("sk_test_") || secretKey.startsWith("rk_test_") - ? "test mode" - : "live mode"; +export async function checkProvider( + providerConfig: PayKitProviderConfig, +): Promise { + try { + const adapter = providerConfig.createAdapter(); + const result = await adapter.check?.(); + + if (!result) { + return { + account: { ok: true, displayName: providerConfig.name, mode: "unknown" }, + webhookEndpoints: null, + }; + } - const [account, webhooks] = await Promise.all([ - client.accounts - .retrieve() - .then((acc) => ({ - ok: true as const, - displayName: - acc.settings?.dashboard?.display_name || - acc.business_profile?.name || - acc.id || - "unknown", - mode, - })) - .catch((error) => ({ - ok: false as const, - message: error instanceof Error ? error.message : String(error), - })), - client.webhookEndpoints - .list({ limit: 100 }) - .then((endpoints) => endpoints.data.filter((ep) => ep.status === "enabled")) - .catch(() => null), - ]); + if (result.ok) { + return { + account: { ok: true, displayName: result.displayName, mode: result.mode }, + webhookEndpoints: result.webhookEndpoints ?? null, + }; + } - return { account, webhooks }; + return { + account: { ok: false, message: result.error ?? "Provider check failed" }, + webhookEndpoints: null, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Provider check failed"; + return { + account: { ok: false, message }, + webhookEndpoints: null, + }; + } } export async function loadProductDiffs( diff --git a/packages/paykit/src/core/__tests__/context.test.ts b/packages/paykit/src/core/__tests__/context.test.ts index 992fb72..83f607c 100644 --- a/packages/paykit/src/core/__tests__/context.test.ts +++ b/packages/paykit/src/core/__tests__/context.test.ts @@ -1,12 +1,11 @@ import type { Pool } from "pg"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { StripeRuntime } from "../../providers/provider"; +import type { PayKitProviderConfig, PaymentProvider } from "../../providers/provider"; const mocks = vi.hoisted(() => ({ createDatabase: vi.fn(), createPayKitLogger: vi.fn(), - createStripeRuntime: vi.fn(), })); vi.mock("../../database/index", () => ({ @@ -17,20 +16,14 @@ vi.mock("../logger", () => ({ createPayKitLogger: mocks.createPayKitLogger, })); -vi.mock("../../providers/stripe", () => ({ - createStripeRuntime: mocks.createStripeRuntime, -})); - import { createContext } from "../context"; describe("core/context", () => { beforeEach(() => { mocks.createDatabase.mockReset(); mocks.createPayKitLogger.mockReset(); - mocks.createStripeRuntime.mockReset(); mocks.createDatabase.mockResolvedValue({ kind: "database" }); mocks.createPayKitLogger.mockReturnValue({ kind: "logger" }); - mocks.createStripeRuntime.mockReturnValue({ kind: "stripe-runtime" }); }); it("passes logging options into the logger factory", async () => { @@ -38,15 +31,12 @@ describe("core/context", () => { level: "debug", } as const; const database = {} as Pool; - const runtime = { kind: "runtime" } as unknown as StripeRuntime; - const provider = { - currency: "usd", - id: "stripe", - kind: "stripe", - runtime, - secretKey: "sk_test_123", - webhookSecret: "whsec_test_123", - } as const; + const adapter = { id: "test", name: "Test" } as unknown as PaymentProvider; + const provider: PayKitProviderConfig = { + id: "test", + name: "Test", + createAdapter: () => adapter, + }; const context = await createContext({ database, @@ -55,8 +45,8 @@ describe("core/context", () => { }); expect(mocks.createDatabase).toHaveBeenCalledWith(database); - expect(mocks.createStripeRuntime).not.toHaveBeenCalled(); expect(mocks.createPayKitLogger).toHaveBeenCalledWith(logging); expect(context.logger).toEqual({ kind: "logger" }); + expect(context.provider).toBe(adapter); }); }); diff --git a/packages/paykit/src/core/context.ts b/packages/paykit/src/core/context.ts index ada856f..184d5bc 100644 --- a/packages/paykit/src/core/context.ts +++ b/packages/paykit/src/core/context.ts @@ -1,8 +1,7 @@ import { Pool } from "pg"; import { createDatabase, type PayKitDatabase } from "../database/index"; -import type { StripeProviderConfig, StripeRuntime } from "../providers/provider"; -import { createStripeRuntime } from "../providers/stripe"; +import type { PaymentProvider } from "../providers/provider"; import type { PayKitOptions } from "../types/options"; import { normalizeSchema, type NormalizedSchema } from "../types/schema"; import { PayKitError, PAYKIT_ERROR_CODES } from "./errors"; @@ -11,8 +10,7 @@ import { createPayKitLogger, type PayKitInternalLogger } from "./logger"; export interface PayKitContext { options: PayKitOptions; database: PayKitDatabase; - provider: StripeProviderConfig; - stripe: StripeRuntime; + provider: PaymentProvider; plans: NormalizedSchema; logger: PayKitInternalLogger; } @@ -35,13 +33,12 @@ export async function createContext(options: PayKitOptions): Promise = {}): Customer { const now = new Date("2024-01-01T00:00:00.000Z"); @@ -53,7 +49,7 @@ describe("customer/service", () => { vi.clearAllMocks(); }); - it("provisions and stores a provider customer during testing-mode sync", async () => { + it("provisions and stores a provider customer on upsert", async () => { const syncedCustomer = createCustomerRow({ email: "test@example.com", updatedAt: new Date("2024-01-02T00:00:00.000Z"), @@ -82,13 +78,14 @@ describe("customer/service", () => { scheduleSubscriptionChange: vi.fn(), syncProduct: vi.fn(), updateSubscription: vi.fn(), - upsertCustomer: vi.fn().mockResolvedValue({ + createCustomer: vi.fn().mockResolvedValue({ providerCustomer: { frozenTime: "2024-01-01T00:00:00.000Z", id: "cus_123", testClockId: "clock_123", }, }), + updateCustomer: vi.fn(), }; const ctx = { database: { @@ -108,29 +105,26 @@ describe("customer/service", () => { options: { provider: { id: "stripe", - kind: "stripe", - secretKey: "sk_test_123", - webhookSecret: "whsec_123", + name: "Stripe", + createAdapter: vi.fn(), }, testing: { enabled: true }, }, plans: { plans: [] }, provider: { id: "stripe", - kind: "stripe", - secretKey: "sk_test_123", - webhookSecret: "whsec_123", + name: "Stripe", + ...stripe, }, - stripe, } as unknown as PayKitContext; - const customer = await syncCustomerWithDefaults(ctx, { + const customer = await upsertCustomer(ctx, { email: "test@example.com", id: "customer_123", }); expect(customer).toEqual(syncedCustomer); - expect(stripe.upsertCustomer).toHaveBeenCalledWith({ + expect(stripe.createCustomer).toHaveBeenCalledWith({ createTestClock: true, email: "test@example.com", id: "customer_123", @@ -143,12 +137,95 @@ describe("customer/service", () => { frozenTime: expect.any(String), id: "cus_123", testClockId: "clock_123", + syncedEmail: "test@example.com", + syncedName: null, + syncedMetadata: null, }, }, updatedAt: expect.any(Date), }); }); + it("provisions provider customer in production mode without test clock", async () => { + const syncedCustomer = createCustomerRow({ + email: "prod@example.com", + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + }); + const syncUpdate = createUpdateChain([syncedCustomer]); + const providerUpdate = createUpdateChain(undefined); + const findFirst = vi + .fn() + .mockResolvedValueOnce(createCustomerRow()) + .mockResolvedValueOnce(syncedCustomer) + .mockResolvedValueOnce(syncedCustomer); + const stripe = { + advanceTestClock: vi.fn(), + attachPaymentMethod: vi.fn(), + cancelSubscription: vi.fn(), + createInvoice: vi.fn(), + createPortalSession: vi.fn(), + createSubscription: vi.fn(), + createSubscriptionCheckout: vi.fn(), + deleteCustomer: vi.fn(), + detachPaymentMethod: vi.fn(), + getTestClock: vi.fn(), + handleWebhook: vi.fn(), + listActiveSubscriptions: vi.fn(), + resumeSubscription: vi.fn(), + scheduleSubscriptionChange: vi.fn(), + syncProduct: vi.fn(), + updateSubscription: vi.fn(), + createCustomer: vi.fn().mockResolvedValue({ + providerCustomer: { + id: "cus_456", + }, + }), + updateCustomer: vi.fn(), + }; + const ctx = { + database: { + query: { + customer: { + findFirst, + }, + }, + update: vi + .fn() + .mockReturnValueOnce({ set: syncUpdate.set }) + .mockReturnValueOnce({ set: providerUpdate.set }), + }, + logger: { + warn: vi.fn(), + }, + options: { + provider: { + id: "stripe", + name: "Stripe", + createAdapter: vi.fn(), + }, + }, + plans: { plans: [] }, + provider: { + id: "stripe", + name: "Stripe", + ...stripe, + }, + } as unknown as PayKitContext; + + await upsertCustomer(ctx, { + email: "prod@example.com", + id: "customer_123", + }); + + expect(stripe.createCustomer).toHaveBeenCalledWith({ + createTestClock: false, + email: "prod@example.com", + id: "customer_123", + metadata: undefined, + name: undefined, + }); + }); + it("aggregates stacked feature entitlements when loading a customer", async () => { const subSelect = createSelectChain( [ @@ -267,4 +344,156 @@ describe("customer/service", () => { usage: 3, }); }); + + it("skips provider call when snapshot matches current customer data", async () => { + const existingCustomer = createCustomerRow({ + email: "same@example.com", + name: "Same", + provider: { + stripe: { + id: "cus_existing", + syncedEmail: "same@example.com", + syncedName: "Same", + syncedMetadata: null, + }, + }, + }); + const syncUpdate = createUpdateChain([existingCustomer]); + const findFirst = vi + .fn() + .mockResolvedValueOnce(existingCustomer) + .mockResolvedValueOnce(existingCustomer); + const providerMock = { + id: "stripe", + name: "Stripe", + createCustomer: vi.fn(), + updateCustomer: vi.fn(), + }; + const ctx = { + database: { + query: { customer: { findFirst } }, + update: vi.fn().mockReturnValueOnce({ set: syncUpdate.set }), + }, + logger: { warn: vi.fn() }, + options: { + provider: { id: "stripe", name: "Stripe", createAdapter: vi.fn() }, + }, + plans: { plans: [] }, + provider: providerMock, + } as unknown as PayKitContext; + + const result = await upsertCustomer(ctx, { + email: "same@example.com", + id: "customer_123", + }); + + expect(providerMock.createCustomer).not.toHaveBeenCalled(); + expect(providerMock.updateCustomer).not.toHaveBeenCalled(); + expect(result.provider).toEqual(existingCustomer.provider); + }); + + it("calls provider when email changes from snapshot", async () => { + const existingCustomer = createCustomerRow({ + email: "new@example.com", + name: "Same", + provider: { + stripe: { + id: "cus_existing", + syncedEmail: "old@example.com", + syncedName: "Same", + syncedMetadata: null, + }, + }, + }); + const syncUpdate = createUpdateChain([existingCustomer]); + const providerUpdate = createUpdateChain(undefined); + const findFirst = vi + .fn() + .mockResolvedValueOnce(createCustomerRow({ email: "old@example.com" })) + .mockResolvedValueOnce(existingCustomer) + .mockResolvedValueOnce(existingCustomer); + const providerMock = { + id: "stripe", + name: "Stripe", + createCustomer: vi.fn(), + updateCustomer: vi.fn(), + }; + const ctx = { + database: { + query: { customer: { findFirst } }, + update: vi + .fn() + .mockReturnValueOnce({ set: syncUpdate.set }) + .mockReturnValueOnce({ set: providerUpdate.set }), + }, + logger: { warn: vi.fn() }, + options: { + provider: { id: "stripe", name: "Stripe", createAdapter: vi.fn() }, + }, + plans: { plans: [] }, + provider: providerMock, + } as unknown as PayKitContext; + + await upsertCustomer(ctx, { + email: "new@example.com", + id: "customer_123", + }); + + expect(providerMock.updateCustomer).toHaveBeenCalledWith( + expect.objectContaining({ providerCustomerId: "cus_existing", email: "new@example.com" }), + ); + }); + + it("calls provider when no snapshot exists (first sync)", async () => { + const existingCustomer = createCustomerRow({ + email: "test@example.com", + provider: { + stripe: { id: "cus_existing" }, + }, + }); + const syncUpdate = createUpdateChain([existingCustomer]); + const providerUpdate = createUpdateChain(undefined); + const findFirst = vi + .fn() + .mockResolvedValueOnce(createCustomerRow()) + .mockResolvedValueOnce(existingCustomer) + .mockResolvedValueOnce(existingCustomer); + const providerMock = { + id: "stripe", + name: "Stripe", + createCustomer: vi.fn(), + updateCustomer: vi.fn(), + }; + const ctx = { + database: { + query: { customer: { findFirst } }, + update: vi + .fn() + .mockReturnValueOnce({ set: syncUpdate.set }) + .mockReturnValueOnce({ set: providerUpdate.set }), + }, + logger: { warn: vi.fn() }, + options: { + provider: { id: "stripe", name: "Stripe", createAdapter: vi.fn() }, + }, + plans: { plans: [] }, + provider: providerMock, + } as unknown as PayKitContext; + + await upsertCustomer(ctx, { + email: "test@example.com", + id: "customer_123", + }); + + expect(providerMock.updateCustomer).toHaveBeenCalled(); + expect(providerUpdate.set).toHaveBeenCalledWith( + expect.objectContaining({ + provider: expect.objectContaining({ + stripe: expect.objectContaining({ + syncedEmail: "test@example.com", + }), + }), + }), + ); + }); }); diff --git a/packages/paykit/src/customer/customer.api.ts b/packages/paykit/src/customer/customer.api.ts index a2cc121..b6884fe 100644 --- a/packages/paykit/src/customer/customer.api.ts +++ b/packages/paykit/src/customer/customer.api.ts @@ -7,7 +7,7 @@ import { getProviderCustomerIdForCustomer, hardDeleteCustomer, listCustomers, - syncCustomerWithDefaults, + upsertCustomer as upsertCustomerService, } from "./customer.service"; const upsertCustomerSchema = z.object({ @@ -30,7 +30,7 @@ const listCustomersSchema = z .optional(); export const upsertCustomer = definePayKitMethod({ input: upsertCustomerSchema }, async (ctx) => - syncCustomerWithDefaults(ctx.paykit, ctx.input), + upsertCustomerService(ctx.paykit, ctx.input), ); export const getCustomer = definePayKitMethod({ input: customerIdSchema }, async (ctx) => @@ -69,7 +69,7 @@ export const customerPortal = definePayKitMethod( throw PayKitError.from("NOT_FOUND", PAYKIT_ERROR_CODES.PROVIDER_CUSTOMER_NOT_FOUND); } - const { url } = await ctx.paykit.stripe.createPortalSession({ + const { url } = await ctx.paykit.provider.createPortalSession({ providerCustomerId, returnUrl: ctx.input.returnUrl, }); diff --git a/packages/paykit/src/customer/customer.service.ts b/packages/paykit/src/customer/customer.service.ts index 88abc6b..9716309 100644 --- a/packages/paykit/src/customer/customer.service.ts +++ b/packages/paykit/src/customer/customer.service.ts @@ -19,7 +19,6 @@ import { getScheduledSubscriptionsInGroup, insertSubscriptionRecord, } from "../subscription/subscription.service"; -import type { DeleteCustomerAction, UpsertCustomerAction } from "../types/events"; import type { Customer } from "../types/models"; import type { CustomerEntitlement, @@ -177,17 +176,15 @@ export async function ensureDefaultPlansForCustomer( } } -export async function syncCustomerWithDefaults( +export async function upsertCustomer( ctx: PayKitContext, input: Parameters[1], ): Promise { const syncedCustomer = await syncCustomer(ctx.database, input); await ensureDefaultPlansForCustomer(ctx, syncedCustomer.id); - const providerCustomer = await ensureTestingProviderCustomer(ctx, syncedCustomer.id); - - if (!providerCustomer) { - return syncedCustomer; - } + const { providerCustomer } = await upsertProviderCustomer(ctx, { + customerId: syncedCustomer.id, + }); return { ...syncedCustomer, @@ -198,19 +195,6 @@ export async function syncCustomerWithDefaults( }; } -export async function applyCustomerWebhookAction( - database: PayKitDatabase, - action: UpsertCustomerAction | DeleteCustomerAction, -): Promise { - if (action.type === "customer.upsert") { - await syncCustomer(database, action.data); - return action.data.id; - } - - await deleteCustomerFromDatabase(database, action.data.id); - return action.data.id; -} - export async function getCustomerById( database: PayKitDatabase, customerId: string, @@ -366,6 +350,17 @@ export async function findCustomerByProviderCustomerId( ); } +function providerCustomerNeedsSync( + existing: ProviderCustomer, + customer: { email: string | null; name: string | null; metadata: Record | null }, +): boolean { + if ((existing.syncedEmail ?? null) !== (customer.email ?? null)) return true; + if ((existing.syncedName ?? null) !== (customer.name ?? null)) return true; + const existingMeta = JSON.stringify(existing.syncedMetadata ?? null); + const currentMeta = JSON.stringify(customer.metadata ?? null); + return existingMeta !== currentMeta; +} + export async function upsertProviderCustomer( ctx: PayKitContext, input: { customerId: string }, @@ -376,7 +371,10 @@ export async function upsertProviderCustomer( const existingProviderCustomer = getProviderCustomer(existingCustomer, providerId); const existingProviderCustomerId = existingProviderCustomer?.id ?? null; - if (existingProviderCustomerId) { + if ( + existingProviderCustomerId && + !providerCustomerNeedsSync(existingProviderCustomer!, existingCustomer) + ) { return { customerId: input.customerId, providerCustomer: existingProviderCustomer as ProviderCustomer, @@ -384,33 +382,45 @@ export async function upsertProviderCustomer( }; } - const { providerCustomer } = await ctx.stripe.upsertCustomer({ - createTestClock: ctx.options.testing?.enabled === true, - id: existingCustomer.id, - email: existingCustomer.email ?? undefined, - name: existingCustomer.name ?? undefined, - metadata: existingCustomer.metadata ?? undefined, - }); - const providerCustomerId = providerCustomer.id; + let providerCustomer: ProviderCustomer; + + if (existingProviderCustomerId) { + await ctx.provider.updateCustomer({ + providerCustomerId: existingProviderCustomerId, + email: existingCustomer.email ?? undefined, + name: existingCustomer.name ?? undefined, + metadata: existingCustomer.metadata ?? undefined, + }); + providerCustomer = { ...existingProviderCustomer!, id: existingProviderCustomerId }; + } else { + const result = await ctx.provider.createCustomer({ + createTestClock: ctx.options.testing?.enabled === true, + id: existingCustomer.id, + email: existingCustomer.email ?? undefined, + name: existingCustomer.name ?? undefined, + metadata: existingCustomer.metadata ?? undefined, + }); + providerCustomer = result.providerCustomer; + } + + const enrichedProviderCustomer: ProviderCustomer = { + ...providerCustomer, + syncedEmail: existingCustomer.email, + syncedName: existingCustomer.name, + syncedMetadata: existingCustomer.metadata, + }; + await setProviderCustomer(ctx.database, { customerId: input.customerId, - providerCustomer, + providerCustomer: enrichedProviderCustomer, providerId, }); - return { customerId: input.customerId, providerCustomer, providerCustomerId }; -} - -export async function ensureTestingProviderCustomer( - ctx: PayKitContext, - customerId: string, -): Promise { - if (ctx.options.testing?.enabled !== true) { - return null; - } - - const { providerCustomer } = await upsertProviderCustomer(ctx, { customerId }); - return providerCustomer; + return { + customerId: input.customerId, + providerCustomer: enrichedProviderCustomer, + providerCustomerId: providerCustomer.id, + }; } export async function deleteCustomerFromDatabase( @@ -439,17 +449,17 @@ export async function hardDeleteCustomer(ctx: PayKitContext, customerId: string) const providerCustomerId = getProviderCustomerId(existingCustomer, ctx.provider.id); if (providerCustomerId) { try { - const activeSubscriptions = await ctx.stripe.listActiveSubscriptions({ + const activeSubscriptions = await ctx.provider.listActiveSubscriptions({ providerCustomerId, }); for (const sub of activeSubscriptions) { - await ctx.stripe.cancelSubscription({ + await ctx.provider.cancelSubscription({ providerSubscriptionId: sub.providerSubscriptionId, }); } - await ctx.stripe.deleteCustomer({ providerCustomerId }); + await ctx.provider.deleteCustomer({ providerCustomerId }); } catch (error) { - ctx.logger.error({ providerCustomerId, err: error }, "failed to clean up Stripe customer"); + ctx.logger.error({ providerCustomerId, err: error }, "failed to clean up provider customer"); } } diff --git a/packages/paykit/src/index.ts b/packages/paykit/src/index.ts index be096e5..0b53c48 100644 --- a/packages/paykit/src/index.ts +++ b/packages/paykit/src/index.ts @@ -25,12 +25,11 @@ export type { ReportResult, } from "./entitlement/entitlement.service"; export type { + PayKitProviderConfig, + PaymentProvider, ProviderCustomer, ProviderCustomerMap, - PayKitProvider, ProviderTestClock, - StripeProviderConfig, - StripeProviderOptions, } from "./providers/provider"; export type { Customer, diff --git a/packages/paykit/src/product/product-sync.service.ts b/packages/paykit/src/product/product-sync.service.ts index b109da1..a43ad0f 100644 --- a/packages/paykit/src/product/product-sync.service.ts +++ b/packages/paykit/src/product/product-sync.service.ts @@ -153,7 +153,7 @@ export async function syncProducts(ctx: PayKitContext): Promise | null; } export type ProviderCustomerMap = Record; @@ -60,8 +63,11 @@ export interface ProviderSubscriptionResult { subscription?: ProviderSubscription | null; } -export interface StripeRuntime { - upsertCustomer(data: { +export interface PaymentProvider { + readonly id: string; + readonly name: string; + + createCustomer(data: { createTestClock?: boolean; id: string; email?: string; @@ -69,6 +75,13 @@ export interface StripeRuntime { metadata?: Record; }): Promise<{ providerCustomer: ProviderCustomer }>; + updateCustomer(data: { + providerCustomerId: string; + email?: string; + name?: string; + metadata?: Record; + }): Promise; + deleteCustomer(data: { providerCustomerId: string }): Promise; getTestClock(data: { testClockId: string }): Promise; @@ -145,21 +158,18 @@ export interface StripeRuntime { providerCustomerId: string; returnUrl: string; }): Promise<{ url: string }>; -} -export interface StripeProviderOptions { - currency?: string; - secretKey: string; - webhookSecret: string; + check?(): Promise<{ + ok: boolean; + displayName: string; + mode: string; + webhookEndpoints?: Array<{ url: string; status: string }>; + error?: string; + }>; } -export interface StripeProviderConfig extends StripeProviderOptions { +export interface PayKitProviderConfig { id: string; - kind: "stripe"; - /** - * Internal test hook so repo tests can stub the Stripe runtime without a network client. - */ - runtime?: StripeRuntime; + name: string; + createAdapter(): PaymentProvider; } - -export type PayKitProvider = StripeProviderConfig; diff --git a/packages/paykit/src/providers/stripe.ts b/packages/paykit/src/providers/stripe.ts deleted file mode 100644 index 308187c..0000000 --- a/packages/paykit/src/providers/stripe.ts +++ /dev/null @@ -1,896 +0,0 @@ -import StripeSdk from "stripe"; - -import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors"; -import type { NormalizedWebhookEvent } from "../types/events"; -import type { ProviderTestClock, StripeProviderConfig, StripeRuntime } from "./provider"; - -type StripeInvoiceWithExtras = StripeSdk.Invoice & { - payment_intent?: StripeSdk.PaymentIntent | string | null; - subscription?: StripeSdk.Subscription | string | null; -}; - -type StripeSubscriptionWithExtras = StripeSdk.Subscription & { - latest_invoice?: StripeInvoiceWithExtras | string | null; -}; - -function toDate(value?: number | null): Date | null { - return typeof value === "number" ? new Date(value * 1000) : null; -} - -function getLatestPeriodEnd(subscription: StripeSubscriptionWithExtras): number | null { - const firstItem = subscription.items.data[0]; - if (!firstItem) { - const subscriptionWithPeriod = subscription as { current_period_end?: number | null }; - return subscriptionWithPeriod.current_period_end ?? null; - } - - return subscription.items.data.reduce((latest, item) => { - return Math.max(latest, item.current_period_end); - }, firstItem.current_period_end); -} - -function getEarliestPeriodStart(subscription: StripeSubscriptionWithExtras): number | null { - const firstItem = subscription.items.data[0]; - if (!firstItem) { - const subscriptionWithPeriod = subscription as { current_period_start?: number | null }; - return subscriptionWithPeriod.current_period_start ?? null; - } - - return subscription.items.data.reduce((earliest, item) => { - return Math.min(earliest, item.current_period_start); - }, firstItem.current_period_start); -} - -function getStripeCustomerId( - customer: string | StripeSdk.Customer | StripeSdk.DeletedCustomer | null, -): string | null { - if (!customer) { - return null; - } - - return typeof customer === "string" ? customer : customer.id; -} - -function normalizeStripePaymentMethod(paymentMethod: StripeSdk.PaymentMethod): { - expiryMonth?: number; - expiryYear?: number; - last4?: string; - providerMethodId: string; - type: string; -} { - return { - expiryMonth: paymentMethod.card?.exp_month ?? undefined, - expiryYear: paymentMethod.card?.exp_year ?? undefined, - last4: paymentMethod.card?.last4 ?? undefined, - providerMethodId: paymentMethod.id, - type: paymentMethod.type, - }; -} - -function normalizeStripePaymentIntent(paymentIntent: StripeSdk.PaymentIntent) { - const providerMethodId = - typeof paymentIntent.payment_method === "string" - ? paymentIntent.payment_method - : paymentIntent.payment_method?.id; - - return { - amount: paymentIntent.amount_received || paymentIntent.amount, - createdAt: new Date(paymentIntent.created * 1000), - currency: paymentIntent.currency, - description: paymentIntent.description, - metadata: Object.keys(paymentIntent.metadata).length > 0 ? paymentIntent.metadata : undefined, - providerMethodId, - providerPaymentId: paymentIntent.id, - status: paymentIntent.status, - }; -} - -function normalizeStripeInvoice(invoice: StripeInvoiceWithExtras) { - return { - currency: invoice.currency, - hostedUrl: invoice.hosted_invoice_url, - periodEndAt: toDate(invoice.period_end), - periodStartAt: toDate(invoice.period_start), - providerInvoiceId: invoice.id, - status: invoice.status, - totalAmount: invoice.total ?? 0, - }; -} - -function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras) { - const firstItem = subscription.items.data[0]; - const providerPriceId = - typeof firstItem?.price === "string" ? firstItem.price : firstItem?.price.id; - const periodStart = getEarliestPeriodStart(subscription); - const periodEnd = getLatestPeriodEnd(subscription); - - const cancelAt = (subscription as { cancel_at?: number | null }).cancel_at; - return { - cancelAtPeriodEnd: subscription.cancel_at_period_end || (cancelAt != null && cancelAt > 0), - canceledAt: toDate(subscription.canceled_at), - currentPeriodEndAt: toDate(periodEnd), - currentPeriodStartAt: toDate(periodStart), - endedAt: toDate(subscription.ended_at), - providerPriceId: providerPriceId ?? null, - providerSubscriptionId: subscription.id, - providerSubscriptionScheduleId: - (typeof subscription.schedule === "string" - ? subscription.schedule - : subscription.schedule?.id) ?? null, - status: subscription.status, - }; -} - -function normalizeStripeTestClock(clock: StripeSdk.TestHelpers.TestClock): ProviderTestClock { - return { - frozenTime: new Date(clock.frozen_time * 1000), - id: clock.id, - name: clock.name ?? null, - status: clock.status, - }; -} - -function assertStripeTestKey(options: StripeProviderConfig): void { - if (!options.secretKey.startsWith("sk_test_")) { - throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_TEST_KEY_REQUIRED); - } -} - -async function retrieveExpandedSubscription( - client: StripeSdk, - providerSubscriptionId: string, -): Promise { - return (await client.subscriptions.retrieve(providerSubscriptionId, { - expand: ["items.data.price", "latest_invoice.payment_intent", "schedule"], - })) as StripeSubscriptionWithExtras; -} - -function normalizeRequiredAction(paymentIntent?: StripeSdk.PaymentIntent | null) { - const nextActionType = paymentIntent?.next_action?.type; - if (!nextActionType) { - return null; - } - - return { - clientSecret: paymentIntent.client_secret ?? undefined, - paymentIntentId: paymentIntent.id, - type: nextActionType, - }; -} - -function isPaymentMethodAttachedToCustomer( - paymentMethod: StripeSdk.PaymentMethod, - stripeCustomerId: string | null, -): boolean { - if (!stripeCustomerId) { - return false; - } - - return getStripeCustomerId(paymentMethod.customer) === stripeCustomerId; -} - -async function getCheckoutPaymentDetails(client: StripeSdk, session: StripeSdk.Checkout.Session) { - const stripeCustomerId = getStripeCustomerId(session.customer); - if (!stripeCustomerId) { - return { - paymentIntent: null, - paymentMethod: null, - }; - } - - if (session.mode === "payment" || session.mode === "subscription") { - const paymentIntentId = - typeof session.payment_intent === "string" - ? session.payment_intent - : session.payment_intent?.id; - - if (paymentIntentId) { - const paymentIntent = await client.paymentIntents.retrieve(paymentIntentId, { - expand: ["payment_method"], - }); - const paymentMethod = paymentIntent.payment_method; - if (paymentMethod && typeof paymentMethod !== "string") { - return { - paymentIntent, - paymentMethod: isPaymentMethodAttachedToCustomer(paymentMethod, stripeCustomerId) - ? paymentMethod - : null, - }; - } - } - - // Subscription-mode checkouts don't have a top-level payment_intent. - // Retrieve the payment method from the subscription's default_payment_method. - if (session.mode === "subscription") { - const subscriptionId = - typeof session.subscription === "string" ? session.subscription : session.subscription?.id; - if (subscriptionId) { - const sub = await client.subscriptions.retrieve(subscriptionId, { - expand: ["default_payment_method"], - }); - const paymentMethod = sub.default_payment_method; - if (paymentMethod && typeof paymentMethod !== "string") { - return { - paymentIntent: null, - paymentMethod: isPaymentMethodAttachedToCustomer(paymentMethod, stripeCustomerId) - ? paymentMethod - : null, - }; - } - } - } - - return { - paymentIntent: null, - paymentMethod: null, - }; - } - - if (session.mode === "setup") { - const setupIntentId = - typeof session.setup_intent === "string" ? session.setup_intent : session.setup_intent?.id; - if (!setupIntentId) { - return { - paymentIntent: null, - paymentMethod: null, - }; - } - - const setupIntent = await client.setupIntents.retrieve(setupIntentId, { - expand: ["payment_method"], - }); - const paymentMethod = setupIntent.payment_method; - if (!paymentMethod || typeof paymentMethod === "string") { - return { - paymentIntent: null, - paymentMethod: null, - }; - } - - return { - paymentIntent: null, - paymentMethod: isPaymentMethodAttachedToCustomer(paymentMethod, stripeCustomerId) - ? paymentMethod - : null, - }; - } - - return { - paymentIntent: null, - paymentMethod: null, - }; -} - -async function createCheckoutCompletedEvents( - client: StripeSdk, - event: StripeSdk.Event, -): Promise { - if (event.type !== "checkout.session.completed") { - return []; - } - - const session = event.data.object; - const stripeCustomerId = getStripeCustomerId(session.customer); - const providerCustomerId = session.client_reference_id ?? stripeCustomerId; - if (!providerCustomerId) { - return []; - } - - const events: NormalizedWebhookEvent[] = []; - const { paymentIntent, paymentMethod } = await getCheckoutPaymentDetails(client, session); - const providerSubscriptionId = - typeof session.subscription === "string" - ? session.subscription - : (session.subscription?.id ?? null); - const providerInvoiceId = - typeof session.invoice === "string" ? session.invoice : (session.invoice?.id ?? null); - const expandedSubscription = - session.mode === "subscription" && providerSubscriptionId - ? await retrieveExpandedSubscription(client, providerSubscriptionId) - : null; - const expandedInvoice = - providerInvoiceId != null - ? ((await client.invoices.retrieve(providerInvoiceId, { - expand: ["payment_intent"], - })) as StripeInvoiceWithExtras) - : null; - - if (paymentMethod) { - const normalizedPaymentMethod = { - ...normalizeStripePaymentMethod(paymentMethod), - isDefault: session.mode === "subscription", - }; - events.push({ - actions: [ - { - data: { - paymentMethod: normalizedPaymentMethod, - providerCustomerId, - }, - type: "payment_method.upsert", - }, - ], - name: "payment_method.attached", - payload: { - paymentMethod: normalizedPaymentMethod, - providerCustomerId, - }, - }); - } - - if (session.mode === "payment" && paymentIntent?.status === "succeeded") { - const normalizedPayment = normalizeStripePaymentIntent(paymentIntent); - events.push({ - actions: [ - { - data: { - payment: normalizedPayment, - providerCustomerId, - }, - type: "payment.upsert", - }, - ], - name: "payment.succeeded", - payload: { - payment: normalizedPayment, - providerCustomerId, - }, - }); - } - - const sessionMetadata = session.metadata ?? {}; - - events.push({ - name: "checkout.completed", - payload: { - checkoutSessionId: session.id, - invoice: expandedInvoice ? normalizeStripeInvoice(expandedInvoice) : undefined, - metadata: Object.keys(sessionMetadata).length > 0 ? sessionMetadata : undefined, - mode: session.mode ?? undefined, - paymentStatus: session.payment_status, - providerCustomerId, - providerEventId: event.id, - providerInvoiceId: providerInvoiceId ?? undefined, - providerSubscriptionId: providerSubscriptionId ?? undefined, - status: session.status, - subscription: expandedSubscription - ? normalizeStripeSubscription(expandedSubscription) - : undefined, - }, - }); - - return events; -} - -async function createSubscriptionEvents(event: StripeSdk.Event): Promise { - if ( - event.type !== "customer.subscription.created" && - event.type !== "customer.subscription.updated" && - event.type !== "customer.subscription.deleted" - ) { - return []; - } - - const sourceSubscription = event.data.object as StripeSubscriptionWithExtras; - - // Use the webhook event's subscription data directly. Re-fetching from - // Stripe can return stale data during renewals (period dates not yet - // propagated). The webhook event is the authoritative source. - const subscription = sourceSubscription; - const providerCustomerId = getStripeCustomerId(subscription.customer); - if (!providerCustomerId) { - return []; - } - - if (event.type === "customer.subscription.deleted") { - return [ - { - actions: [ - { - data: { - providerCustomerId, - providerSubscriptionId: subscription.id, - }, - type: "subscription.delete", - }, - ], - name: "subscription.deleted", - payload: { - providerCustomerId, - providerEventId: event.id, - providerSubscriptionId: subscription.id, - }, - }, - ]; - } - - const normalizedSubscription = normalizeStripeSubscription(subscription); - const normalizedEvent: NormalizedWebhookEvent<"subscription.updated"> = { - actions: [ - { - data: { - providerCustomerId, - subscription: normalizedSubscription, - }, - type: "subscription.upsert", - }, - ], - name: "subscription.updated", - payload: { - providerCustomerId, - providerEventId: event.id, - subscription: normalizedSubscription, - }, - }; - return [normalizedEvent]; -} - -function createInvoiceEvents(event: StripeSdk.Event): NormalizedWebhookEvent[] { - if ( - event.type !== "invoice.created" && - event.type !== "invoice.finalized" && - event.type !== "invoice.paid" && - event.type !== "invoice.payment_failed" && - event.type !== "invoice.updated" - ) { - return []; - } - - const invoice = event.data.object as StripeInvoiceWithExtras; - const providerCustomerId = getStripeCustomerId(invoice.customer); - if (!providerCustomerId) { - return []; - } - - const providerSubscriptionId = - typeof invoice.subscription === "string" - ? invoice.subscription - : (invoice.subscription?.id ?? null); - - const normalizedInvoice = normalizeStripeInvoice(invoice); - const normalizedEvent: NormalizedWebhookEvent<"invoice.updated"> = { - actions: [ - { - data: { - invoice: normalizedInvoice, - providerCustomerId, - providerSubscriptionId, - }, - type: "invoice.upsert", - }, - ], - name: "invoice.updated", - payload: { - invoice: normalizedInvoice, - providerCustomerId, - providerEventId: event.id, - providerSubscriptionId, - }, - }; - return [normalizedEvent]; -} - -function createDetachedPaymentMethodEvents(event: StripeSdk.Event): NormalizedWebhookEvent[] { - if (event.type !== "payment_method.detached") { - return []; - } - - const paymentMethod = event.data.object; - - return [ - { - actions: [ - { - data: { - providerMethodId: paymentMethod.id, - }, - type: "payment_method.delete", - }, - ], - name: "payment_method.detached", - payload: { - providerEventId: event.id, - providerMethodId: paymentMethod.id, - }, - }, - ]; -} - -export function createStripeProvider( - client: StripeSdk, - options: StripeProviderConfig, -): StripeRuntime { - const currency = options.currency ?? "usd"; - - return { - async upsertCustomer(data) { - let testClock: ProviderTestClock | undefined; - if (data.createTestClock) { - assertStripeTestKey(options); - const clock = await client.testHelpers.testClocks.create({ - frozen_time: Math.floor(Date.now() / 1000), - name: data.id, - }); - testClock = normalizeStripeTestClock(clock); - } - - const customer = await client.customers.create({ - email: data.email, - metadata: { - customerId: data.id, - ...data.metadata, - }, - name: data.name, - test_clock: testClock?.id, - }); - - return { - providerCustomer: { - id: customer.id, - frozenTime: testClock?.frozenTime.toISOString(), - testClockId: testClock?.id, - }, - }; - }, - - async deleteCustomer(data) { - await client.customers.del(data.providerCustomerId); - }, - - async getTestClock(data) { - const clock = await client.testHelpers.testClocks.retrieve(data.testClockId); - return normalizeStripeTestClock(clock); - }, - - async advanceTestClock(data) { - assertStripeTestKey(options); - - await client.testHelpers.testClocks.advance(data.testClockId, { - frozen_time: Math.floor(data.frozenTime.getTime() / 1000), - }); - - for (let i = 0; i < 60; i++) { - const clock = await client.testHelpers.testClocks.retrieve(data.testClockId); - if (clock.status === "ready") { - return normalizeStripeTestClock(clock); - } - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - - throw new Error(`Test clock ${data.testClockId} did not reach 'ready' status`); - }, - - async attachPaymentMethod(data) { - const session = await client.checkout.sessions.create({ - cancel_url: data.returnURL, - client_reference_id: data.providerCustomerId, - customer: data.providerCustomerId, - mode: "setup", - success_url: data.returnURL, - }); - - if (!session.url) { - throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SESSION_INVALID); - } - - return { url: session.url }; - }, - - async createSubscriptionCheckout(data) { - const sessionParams: StripeSdk.Checkout.SessionCreateParams = { - cancel_url: data.cancelUrl ?? data.successUrl, - client_reference_id: data.providerCustomerId, - customer: data.providerCustomerId, - line_items: [{ price: data.providerPriceId, quantity: 1 }], - metadata: data.metadata, - mode: "subscription", - success_url: data.successUrl, - }; - const session = await client.checkout.sessions.create(sessionParams); - - if (!session.url) { - throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SESSION_INVALID); - } - - return { - paymentUrl: session.url, - providerCheckoutSessionId: session.id, - }; - }, - - async createSubscription(data) { - const createParams: StripeSdk.SubscriptionCreateParams = { - customer: data.providerCustomerId, - items: [{ price: data.providerPriceId }], - payment_behavior: "default_incomplete", - expand: ["latest_invoice.payment_intent"], - }; - const createdSubscription = (await client.subscriptions.create( - createParams, - )) as StripeSubscriptionWithExtras; - - const latestInvoice = createdSubscription.latest_invoice; - const invoice = - latestInvoice && typeof latestInvoice !== "string" - ? normalizeStripeInvoice(latestInvoice) - : null; - const paymentIntent = - latestInvoice && typeof latestInvoice !== "string" - ? (latestInvoice.payment_intent as StripeSdk.PaymentIntent | null | undefined) - : null; - - return { - invoice, - paymentUrl: null, - requiredAction: normalizeRequiredAction(paymentIntent ?? null), - subscription: normalizeStripeSubscription(createdSubscription), - }; - }, - - async updateSubscription(data) { - const currentSubscription = await retrieveExpandedSubscription( - client, - data.providerSubscriptionId, - ); - const currentItem = currentSubscription.items.data[0]; - if (!currentItem) { - throw PayKitError.from( - "BAD_REQUEST", - PAYKIT_ERROR_CODES.PROVIDER_SUBSCRIPTION_MISSING_ITEMS, - ); - } - - const updatedSubscription = (await client.subscriptions.update(data.providerSubscriptionId, { - items: [ - { - id: currentItem.id, - price: data.providerPriceId, - }, - ], - payment_behavior: "pending_if_incomplete", - proration_behavior: "always_invoice", - expand: ["latest_invoice.payment_intent"], - })) as StripeSubscriptionWithExtras; - - const latestInvoice = updatedSubscription.latest_invoice; - const invoice = - latestInvoice && typeof latestInvoice !== "string" - ? normalizeStripeInvoice(latestInvoice) - : null; - const paymentIntent = - latestInvoice && typeof latestInvoice !== "string" - ? (latestInvoice.payment_intent as StripeSdk.PaymentIntent | null | undefined) - : null; - - return { - invoice, - paymentUrl: null, - requiredAction: normalizeRequiredAction(paymentIntent ?? null), - subscription: normalizeStripeSubscription(updatedSubscription), - }; - }, - - async scheduleSubscriptionChange(data) { - if (!data.providerPriceId) { - throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_PRICE_REQUIRED); - } - - // Fetch the current subscription to get the current price and period end. - const currentSub = (await client.subscriptions.retrieve(data.providerSubscriptionId, { - expand: ["items"], - })) as StripeSubscriptionWithExtras; - const periodEndSeconds = getLatestPeriodEnd(currentSub); - if (typeof periodEndSeconds !== "number") { - throw PayKitError.from( - "BAD_REQUEST", - PAYKIT_ERROR_CODES.PROVIDER_SUBSCRIPTION_MISSING_PERIOD, - ); - } - - const currentItems = currentSub.items.data.map((item: { price: { id: string } }) => ({ - price: item.price.id, - quantity: 1, - })); - - let schedule: StripeSdk.SubscriptionSchedule; - if (data.providerSubscriptionScheduleId) { - schedule = await client.subscriptionSchedules.retrieve(data.providerSubscriptionScheduleId); - } else { - // Check if the subscription already has a schedule attached (e.g. from a prior failed attempt). - const existingScheduleId = - typeof currentSub.schedule === "string" - ? currentSub.schedule - : (currentSub.schedule?.id ?? null); - schedule = existingScheduleId - ? await client.subscriptionSchedules.retrieve(existingScheduleId) - : await client.subscriptionSchedules.create({ - from_subscription: data.providerSubscriptionId, - }); - } - const scheduleId = schedule.id; - - // The current phase was created by Stripe when we did `from_subscription`. - // We must preserve its original start_date (Stripe forbids modifying it). - const currentPhase = schedule.phases[0]; - const currentPhaseStart = currentPhase?.start_date ?? Math.floor(Date.now() / 1000); - - // Two-phase schedule: current plan until period end, then new plan. - await client.subscriptionSchedules.update(scheduleId, { - end_behavior: "release", - phases: [ - { - items: currentItems, - start_date: currentPhaseStart, - end_date: periodEndSeconds, - }, - { - items: [{ price: data.providerPriceId, quantity: 1 }], - start_date: periodEndSeconds, - }, - ], - }); - - const updatedSubscription = await retrieveExpandedSubscription( - client, - data.providerSubscriptionId, - ); - - return { - paymentUrl: null, - requiredAction: null, - subscription: normalizeStripeSubscription(updatedSubscription), - }; - }, - - async cancelSubscription(data) { - const currentSubscription = (await client.subscriptions.retrieve( - data.providerSubscriptionId, - )) as StripeSubscriptionWithExtras; - - // Release any attached schedule before modifying the subscription directly. - let scheduleId = data.providerSubscriptionScheduleId ?? null; - if (!scheduleId) { - scheduleId = - typeof currentSubscription.schedule === "string" - ? currentSubscription.schedule - : (currentSubscription.schedule?.id ?? null); - } - if (scheduleId) { - const schedule = await client.subscriptionSchedules.retrieve(scheduleId); - if (schedule.status !== "released" && schedule.status !== "canceled") { - await client.subscriptionSchedules.release(scheduleId); - } - } - - const updatedSubscription = (await client.subscriptions.update(data.providerSubscriptionId, { - cancel_at_period_end: true, - })) as StripeSubscriptionWithExtras; - - return { - paymentUrl: null, - requiredAction: null, - subscription: normalizeStripeSubscription(updatedSubscription), - }; - }, - - async listActiveSubscriptions(data) { - const subscriptions = await client.subscriptions.list({ - customer: data.providerCustomerId, - status: "active", - }); - return subscriptions.data.map((sub) => ({ - providerSubscriptionId: sub.id, - })); - }, - - async resumeSubscription(data) { - // Release any attached schedule (from a prior downgrade/schedule_change). - // Check both our stored ID and Stripe's subscription directly. - let scheduleId = data.providerSubscriptionScheduleId ?? null; - if (!scheduleId) { - const sub = await client.subscriptions.retrieve(data.providerSubscriptionId); - scheduleId = typeof sub.schedule === "string" ? sub.schedule : (sub.schedule?.id ?? null); - } - if (scheduleId) { - const schedule = await client.subscriptionSchedules.retrieve(scheduleId); - if (schedule.status !== "released" && schedule.status !== "canceled") { - await client.subscriptionSchedules.release(scheduleId); - } - } - - const updatedSubscription = (await client.subscriptions.update(data.providerSubscriptionId, { - cancel_at_period_end: false, - })) as StripeSubscriptionWithExtras; - - return { - paymentUrl: null, - requiredAction: null, - subscription: normalizeStripeSubscription(updatedSubscription), - }; - }, - - async detachPaymentMethod(data) { - await client.paymentMethods.detach(data.providerMethodId); - }, - - async syncProduct(data) { - let providerProductId = data.existingProviderProductId; - if (!providerProductId) { - const stripeProduct = await client.products.create({ - metadata: { paykit_product_id: data.id }, - name: data.name, - }); - providerProductId = stripeProduct.id; - } else { - await client.products.update(providerProductId, { name: data.name }); - } - - if (data.existingProviderPriceId) { - return { providerPriceId: data.existingProviderPriceId, providerProductId }; - } - - const priceParams: StripeSdk.PriceCreateParams = { - currency, - product: providerProductId, - unit_amount: data.priceAmount, - }; - if (data.priceInterval) { - priceParams.recurring = { - interval: data.priceInterval as "month" | "year", - }; - } - const stripePrice = await client.prices.create(priceParams); - - return { providerPriceId: stripePrice.id, providerProductId }; - }, - - async createInvoice(data) { - const stripeInvoice = await client.invoices.create({ - auto_advance: data.autoAdvance ?? true, - collection_method: "charge_automatically", - customer: data.providerCustomerId, - currency, - }); - - if (data.lines.length > 0) { - await client.invoices.addLines(stripeInvoice.id, { - lines: data.lines.map((line) => ({ - amount: line.amount, - description: line.description, - })), - }); - } - - const finalizedInvoice = await client.invoices.finalizeInvoice(stripeInvoice.id); - - return normalizeStripeInvoice(finalizedInvoice); - }, - - async handleWebhook(data) { - const signature = data.headers["stripe-signature"]; - if (!signature) { - throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING); - } - - const event = client.webhooks.constructEvent(data.body, signature, options.webhookSecret); - return [ - ...(await createCheckoutCompletedEvents(client, event)), - ...(await createSubscriptionEvents(event)), - ...createInvoiceEvents(event), - ...createDetachedPaymentMethodEvents(event), - ]; - }, - - async createPortalSession(data) { - const session = await client.billingPortal.sessions.create({ - customer: data.providerCustomerId, - return_url: data.returnUrl, - }); - return { url: session.url }; - }, - }; -} - -export function createStripeRuntime(options: StripeProviderConfig): StripeRuntime { - return createStripeProvider(new StripeSdk(options.secretKey), options); -} diff --git a/packages/paykit/src/subscription/subscription.service.ts b/packages/paykit/src/subscription/subscription.service.ts index a69d778..fa959db 100644 --- a/packages/paykit/src/subscription/subscription.service.ts +++ b/packages/paykit/src/subscription/subscription.service.ts @@ -222,7 +222,7 @@ async function cancelExistingProviderSubscriptionForCheckout( ); } - await ctx.stripe.cancelSubscription({ + await ctx.provider.cancelSubscription({ currentPeriodEndAt: completion.subCtx.activeSubscription.currentPeriodEndAt, providerSubscriptionId: activeSubscriptionRef.subscriptionId, providerSubscriptionScheduleId: activeSubscriptionRef.subscriptionScheduleId, @@ -718,7 +718,7 @@ async function handleSamePlanSubscribe( } const activeSubscriptionRef = getProviderSubscriptionRef(activeSubscription); - const stripeResult = await ctx.stripe.resumeSubscription({ + const providerResult = await ctx.provider.resumeSubscription({ providerSubscriptionId: activeSubscriptionRef.subscriptionId!, providerSubscriptionScheduleId: activeSubscriptionRef.subscriptionScheduleId, }); @@ -727,7 +727,7 @@ async function handleSamePlanSubscribe( await deleteScheduledSubscriptionsInGroupIfNeeded(tx, subCtx); await syncSubscriptionFromProvider(tx, { subscriptionId: activeSubscription.id, - providerSubscription: stripeResult.subscription ?? { + providerSubscription: providerResult.subscription ?? { cancelAtPeriodEnd: false, providerSubscriptionId: activeSubscriptionRef.subscriptionId!, providerSubscriptionScheduleId: null, @@ -738,24 +738,25 @@ async function handleSamePlanSubscribe( subscriptionId: activeSubscription.id, scheduledProductId: null, }); - if (stripeResult.subscription) { + if (providerResult.subscription) { await syncSubscriptionBillingState(tx, { - currentPeriodEndAt: stripeResult.subscription.currentPeriodEndAt, - currentPeriodStartAt: stripeResult.subscription.currentPeriodStartAt, + currentPeriodEndAt: providerResult.subscription.currentPeriodEndAt, + currentPeriodStartAt: providerResult.subscription.currentPeriodStartAt, providerData: { - subscriptionId: stripeResult.subscription.providerSubscriptionId, - subscriptionScheduleId: stripeResult.subscription.providerSubscriptionScheduleId ?? null, + subscriptionId: providerResult.subscription.providerSubscriptionId, + subscriptionScheduleId: + providerResult.subscription.providerSubscriptionScheduleId ?? null, }, - status: stripeResult.subscription.status, + status: providerResult.subscription.status, subscriptionId: activeSubscription.id, }); } }); return buildSubscribeResult({ - invoice: stripeResult.invoice, - paymentUrl: stripeResult.paymentUrl, - requiredAction: stripeResult.requiredAction, + invoice: providerResult.invoice, + paymentUrl: providerResult.paymentUrl, + requiredAction: providerResult.requiredAction, }); } @@ -779,13 +780,13 @@ async function handleInitialSubscribe( return createCheckoutSubscribe(ctx, subCtx); } - const stripeResult = await ctx.stripe.createSubscription({ + const providerResult = await ctx.provider.createSubscription({ providerCustomerId: subCtx.providerCustomerId, providerPriceId: subCtx.storedPlan.providerPriceId!, }); await ctx.database.transaction(async (tx) => { - if (!stripeResult.subscription) { + if (!providerResult.subscription) { throw PayKitError.from( "INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED, @@ -793,15 +794,15 @@ async function handleInitialSubscribe( } await upsertProviderBackedTargetSubscription(tx, subCtx, { - invoice: stripeResult.invoice ?? null, - subscription: stripeResult.subscription, + invoice: providerResult.invoice ?? null, + subscription: providerResult.subscription, }); }); return buildSubscribeResult({ - invoice: stripeResult.invoice, - paymentUrl: stripeResult.paymentUrl, - requiredAction: stripeResult.requiredAction, + invoice: providerResult.invoice, + paymentUrl: providerResult.paymentUrl, + requiredAction: providerResult.requiredAction, }); } @@ -836,13 +837,13 @@ async function handleLocalPlanSwitch( return buildSubscribeResult({ paymentUrl: null }); } - const stripeResult = await ctx.stripe.createSubscription({ + const providerResult = await ctx.provider.createSubscription({ providerCustomerId: subCtx.providerCustomerId, providerPriceId: subCtx.storedPlan.providerPriceId!, }); await ctx.database.transaction(async (tx) => { - if (!stripeResult.subscription) { + if (!providerResult.subscription) { throw PayKitError.from( "INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED, @@ -855,15 +856,15 @@ async function handleLocalPlanSwitch( status: "ended", }); await upsertProviderBackedTargetSubscription(tx, subCtx, { - invoice: stripeResult.invoice ?? null, - subscription: stripeResult.subscription, + invoice: providerResult.invoice ?? null, + subscription: providerResult.subscription, }); }); return buildSubscribeResult({ - invoice: stripeResult.invoice, - paymentUrl: stripeResult.paymentUrl, - requiredAction: stripeResult.requiredAction, + invoice: providerResult.invoice, + paymentUrl: providerResult.paymentUrl, + requiredAction: providerResult.requiredAction, }); } @@ -878,7 +879,7 @@ async function handleCancelToFree( throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED); } - const stripeResult = await ctx.stripe.cancelSubscription({ + const providerResult = await ctx.provider.cancelSubscription({ currentPeriodEndAt: activeSubscription.currentPeriodEndAt, providerSubscriptionId: activeSubscriptionRef.subscriptionId, providerSubscriptionScheduleId: activeSubscriptionRef.subscriptionScheduleId, @@ -899,24 +900,25 @@ async function handleCancelToFree( scheduledProductId: subCtx.storedPlan.internalId, subscriptionId: activeSubscription.id, }); - if (stripeResult.subscription) { + if (providerResult.subscription) { await syncSubscriptionBillingState(tx, { - currentPeriodEndAt: stripeResult.subscription.currentPeriodEndAt, - currentPeriodStartAt: stripeResult.subscription.currentPeriodStartAt, + currentPeriodEndAt: providerResult.subscription.currentPeriodEndAt, + currentPeriodStartAt: providerResult.subscription.currentPeriodStartAt, providerData: { - subscriptionId: stripeResult.subscription.providerSubscriptionId, - subscriptionScheduleId: stripeResult.subscription.providerSubscriptionScheduleId ?? null, + subscriptionId: providerResult.subscription.providerSubscriptionId, + subscriptionScheduleId: + providerResult.subscription.providerSubscriptionScheduleId ?? null, }, - status: stripeResult.subscription.status, + status: providerResult.subscription.status, subscriptionId: activeSubscription.id, }); } }); return buildSubscribeResult({ - invoice: stripeResult.invoice, - paymentUrl: stripeResult.paymentUrl, - requiredAction: stripeResult.requiredAction, + invoice: providerResult.invoice, + paymentUrl: providerResult.paymentUrl, + requiredAction: providerResult.requiredAction, }); } @@ -931,7 +933,7 @@ async function handleScheduledDowngrade( throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED); } - const stripeResult = await ctx.stripe.scheduleSubscriptionChange({ + const providerResult = await ctx.provider.scheduleSubscriptionChange({ providerPriceId: subCtx.storedPlan.providerPriceId!, providerSubscriptionId: activeSubscriptionRef.subscriptionId, providerSubscriptionScheduleId: activeSubscriptionRef.subscriptionScheduleId, @@ -952,24 +954,25 @@ async function handleScheduledDowngrade( scheduledProductId: subCtx.storedPlan.internalId, subscriptionId: activeSubscription.id, }); - if (stripeResult.subscription) { + if (providerResult.subscription) { await syncSubscriptionBillingState(tx, { - currentPeriodEndAt: stripeResult.subscription.currentPeriodEndAt, - currentPeriodStartAt: stripeResult.subscription.currentPeriodStartAt, + currentPeriodEndAt: providerResult.subscription.currentPeriodEndAt, + currentPeriodStartAt: providerResult.subscription.currentPeriodStartAt, providerData: { - subscriptionId: stripeResult.subscription.providerSubscriptionId, - subscriptionScheduleId: stripeResult.subscription.providerSubscriptionScheduleId ?? null, + subscriptionId: providerResult.subscription.providerSubscriptionId, + subscriptionScheduleId: + providerResult.subscription.providerSubscriptionScheduleId ?? null, }, - status: stripeResult.subscription.status, + status: providerResult.subscription.status, subscriptionId: activeSubscription.id, }); } }); return buildSubscribeResult({ - invoice: stripeResult.invoice, - paymentUrl: stripeResult.paymentUrl, - requiredAction: stripeResult.requiredAction, + invoice: providerResult.invoice, + paymentUrl: providerResult.paymentUrl, + requiredAction: providerResult.requiredAction, }); } @@ -988,13 +991,13 @@ async function handleUpgrade( return createCheckoutSubscribe(ctx, subCtx); } - const stripeResult = await ctx.stripe.updateSubscription({ + const providerResult = await ctx.provider.updateSubscription({ providerPriceId: subCtx.storedPlan.providerPriceId!, providerSubscriptionId: activeSubscriptionRef.subscriptionId, }); await ctx.database.transaction(async (tx) => { - if (!stripeResult.subscription) { + if (!providerResult.subscription) { throw PayKitError.from( "INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED, @@ -1008,15 +1011,15 @@ async function handleUpgrade( status: "ended", }); await upsertProviderBackedTargetSubscription(tx, subCtx, { - invoice: stripeResult.invoice ?? null, - subscription: stripeResult.subscription, + invoice: providerResult.invoice ?? null, + subscription: providerResult.subscription, }); }); return buildSubscribeResult({ - invoice: stripeResult.invoice, - paymentUrl: stripeResult.paymentUrl, - requiredAction: stripeResult.requiredAction, + invoice: providerResult.invoice, + paymentUrl: providerResult.paymentUrl, + requiredAction: providerResult.requiredAction, }); } @@ -1025,7 +1028,7 @@ async function createCheckoutSubscribe( ctx: PayKitContext, subCtx: SubscribeContext, ): Promise { - const checkoutResult = await ctx.stripe.createSubscriptionCheckout({ + const checkoutResult = await ctx.provider.createSubscriptionCheckout({ cancelUrl: subCtx.cancelUrl, metadata: { paykit_customer_id: subCtx.customerId, diff --git a/packages/paykit/src/testing/testing.service.ts b/packages/paykit/src/testing/testing.service.ts index 859b3c2..66e505d 100644 --- a/packages/paykit/src/testing/testing.service.ts +++ b/packages/paykit/src/testing/testing.service.ts @@ -25,7 +25,7 @@ export async function getCustomerTestClock(ctx: PayKitContext, customerId: strin throw PayKitError.from("NOT_FOUND", PAYKIT_ERROR_CODES.TEST_CLOCK_NOT_FOUND); } - const testClock = await ctx.stripe.getTestClock({ testClockId: providerCustomer.testClockId }); + const testClock = await ctx.provider.getTestClock({ testClockId: providerCustomer.testClockId }); await setProviderCustomer(ctx.database, { customerId, providerCustomer: { @@ -65,7 +65,7 @@ export async function advanceCustomerTestClock( throw PayKitError.from("NOT_FOUND", PAYKIT_ERROR_CODES.TEST_CLOCK_NOT_FOUND); } - const testClock = await ctx.stripe.advanceTestClock({ + const testClock = await ctx.provider.advanceTestClock({ frozenTime: input.frozenTime, testClockId: providerCustomer.testClockId, }); diff --git a/packages/paykit/src/types/events.ts b/packages/paykit/src/types/events.ts index 9bf6313..14903a7 100644 --- a/packages/paykit/src/types/events.ts +++ b/packages/paykit/src/types/events.ts @@ -43,23 +43,6 @@ export interface NormalizedInvoice { export interface CheckoutCompletedSubscription extends NormalizedSubscription {} export interface CheckoutCompletedInvoice extends NormalizedInvoice {} -export interface UpsertCustomerAction { - type: "customer.upsert"; - data: { - id: string; - email?: string; - name?: string; - metadata?: Record; - }; -} - -export interface DeleteCustomerAction { - type: "customer.delete"; - data: { - id: string; - }; -} - export interface UpsertPaymentMethodAction { type: "payment_method.upsert"; data: { @@ -109,8 +92,6 @@ export interface UpsertInvoiceAction { } export type WebhookApplyAction = - | UpsertCustomerAction - | DeleteCustomerAction | UpsertPaymentMethodAction | DeletePaymentMethodAction | UpsertPaymentAction diff --git a/packages/paykit/src/types/options.ts b/packages/paykit/src/types/options.ts index cd83f1a..a46ccc3 100644 --- a/packages/paykit/src/types/options.ts +++ b/packages/paykit/src/types/options.ts @@ -1,7 +1,7 @@ import type { Pool } from "pg"; import type { LevelWithSilent, Logger } from "pino"; -import type { StripeProviderConfig } from "../providers/provider"; +import type { PayKitProviderConfig } from "../providers/provider"; import type { PayKitEventHandlers } from "./events"; import type { PayKitPlugin } from "./plugin"; import type { PayKitPlansModule } from "./schema"; @@ -17,7 +17,7 @@ export interface PayKitTestingOptions { export interface PayKitOptions { database: Pool | string; - provider: StripeProviderConfig; + provider: PayKitProviderConfig; plans?: PayKitPlansModule; basePath?: string; identify?: (request: Request) => Promise<{ diff --git a/packages/paykit/src/webhook/webhook.service.ts b/packages/paykit/src/webhook/webhook.service.ts index 14edd19..0f25e2b 100644 --- a/packages/paykit/src/webhook/webhook.service.ts +++ b/packages/paykit/src/webhook/webhook.service.ts @@ -3,7 +3,7 @@ import { and, eq, sql } from "drizzle-orm"; import type { PayKitContext } from "../core/context"; import { getTraceId } from "../core/logger"; import { generateId } from "../core/utils"; -import { applyCustomerWebhookAction, emitCustomerUpdated } from "../customer/customer.service"; +import { emitCustomerUpdated } from "../customer/customer.service"; import { webhookEvent } from "../database/schema"; import { applyInvoiceWebhookAction } from "../invoice/invoice.service"; import { applyPaymentMethodWebhookAction } from "../payment-method/payment-method.service"; @@ -118,9 +118,6 @@ function getParentProviderEventId(events: readonly AnyNormalizedWebhookEvent[]): async function applyAction(ctx: PayKitContext, action: WebhookApplyAction): Promise { switch (action.type) { - case "customer.upsert": - case "customer.delete": - return applyCustomerWebhookAction(ctx.database, action); case "payment_method.upsert": case "payment_method.delete": return applyPaymentMethodWebhookAction(ctx, action); @@ -211,7 +208,7 @@ export async function handleWebhook( ): Promise<{ received: true }> { return ctx.logger.trace.run("wh", async () => { const startTime = Date.now(); - const events = await ctx.stripe.handleWebhook({ + const events = await ctx.provider.handleWebhook({ body: input.body, headers: input.headers, }); diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 3ccdc75..2d551d0 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -29,7 +29,8 @@ "typecheck": "tsc --build" }, "dependencies": { - "paykitjs": "workspace:*" + "paykitjs": "workspace:*", + "stripe": "^19.1.0" }, "devDependencies": { "tsdown": "^0.21.1", diff --git a/packages/stripe/src/__tests__/stripe-provider.test.ts b/packages/stripe/src/__tests__/stripe-provider.test.ts index 4e90593..0ae26b8 100644 --- a/packages/stripe/src/__tests__/stripe-provider.test.ts +++ b/packages/stripe/src/__tests__/stripe-provider.test.ts @@ -3,19 +3,28 @@ import { describe, expect, it } from "vitest"; import { stripe } from "../stripe-provider"; describe("@paykitjs/stripe", () => { - it("should return a Stripe provider config for core to consume", () => { + it("should return a provider config with createAdapter", () => { const config = stripe({ - currency: "usd", secretKey: "sk_test_123", webhookSecret: "whsec_test_123", }); - expect(config).toEqual({ - currency: "usd", - id: "stripe", - kind: "stripe", + expect(config.id).toBe("stripe"); + expect(config.name).toBe("Stripe"); + expect(typeof config.createAdapter).toBe("function"); + }); + + it("should create a PaymentProvider adapter", () => { + const config = stripe({ secretKey: "sk_test_123", webhookSecret: "whsec_test_123", }); + + const adapter = config.createAdapter(); + expect(adapter.id).toBe("stripe"); + expect(adapter.name).toBe("Stripe"); + expect(typeof adapter.createCustomer).toBe("function"); + expect(typeof adapter.updateCustomer).toBe("function"); + expect(typeof adapter.handleWebhook).toBe("function"); }); }); diff --git a/packages/paykit/src/providers/__tests__/stripe.test.ts b/packages/stripe/src/__tests__/stripe.test.ts similarity index 90% rename from packages/paykit/src/providers/__tests__/stripe.test.ts rename to packages/stripe/src/__tests__/stripe.test.ts index 3681dbd..e40b3d8 100644 --- a/packages/paykit/src/providers/__tests__/stripe.test.ts +++ b/packages/stripe/src/__tests__/stripe.test.ts @@ -1,7 +1,7 @@ +import { PAYKIT_ERROR_CODES } from "paykitjs"; import { describe, expect, it, vi } from "vitest"; -import { PAYKIT_ERROR_CODES } from "../../core/errors"; -import { createStripeProvider } from "../stripe"; +import { createStripeProvider } from "../stripe-provider"; describe("providers/stripe", () => { it("creates a test clock and stores its id on the provider customer", async () => { @@ -24,14 +24,12 @@ describe("providers/stripe", () => { }, } as never, { - id: "stripe", - kind: "stripe", secretKey: "sk_test_123", webhookSecret: "whsec_123", }, ); - const result = await runtime.upsertCustomer({ + const result = await runtime.createCustomer({ createTestClock: true, email: "test@example.com", id: "customer_123", @@ -74,15 +72,13 @@ describe("providers/stripe", () => { }, } as never, { - id: "stripe", - kind: "stripe", secretKey: "sk_live_123", webhookSecret: "whsec_123", }, ); await expect( - runtime.upsertCustomer({ + runtime.createCustomer({ createTestClock: true, id: "customer_123", }), @@ -111,8 +107,6 @@ describe("providers/stripe", () => { }, } as never, { - id: "stripe", - kind: "stripe", secretKey: "sk_test_123", webhookSecret: "whsec_123", }, diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index 9a319bd..a1e5105 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -1,3 +1,3 @@ export { stripe } from "./stripe-provider"; -export type { StripeProviderOptions } from "./stripe-provider"; +export type { StripeOptions } from "./stripe-provider"; diff --git a/packages/stripe/src/stripe-provider.ts b/packages/stripe/src/stripe-provider.ts index 57266f7..be624ad 100644 --- a/packages/stripe/src/stripe-provider.ts +++ b/packages/stripe/src/stripe-provider.ts @@ -1,11 +1,940 @@ -import type { StripeProviderConfig, StripeProviderOptions } from "paykitjs"; +import { PayKitError, PAYKIT_ERROR_CODES } from "paykitjs"; +import type { + NormalizedWebhookEvent, + PayKitProviderConfig, + PaymentProvider, + ProviderTestClock, +} from "paykitjs"; +import StripeSdk from "stripe"; -export type { StripeProviderConfig, StripeProviderOptions } from "paykitjs"; +export interface StripeOptions { + secretKey: string; + webhookSecret: string; +} + +type StripeInvoiceWithExtras = StripeSdk.Invoice & { + payment_intent?: StripeSdk.PaymentIntent | string | null; + subscription?: StripeSdk.Subscription | string | null; +}; + +type StripeSubscriptionWithExtras = StripeSdk.Subscription & { + latest_invoice?: StripeInvoiceWithExtras | string | null; +}; + +function toDate(value?: number | null): Date | null { + return typeof value === "number" ? new Date(value * 1000) : null; +} + +function getLatestPeriodEnd(subscription: StripeSubscriptionWithExtras): number | null { + const firstItem = subscription.items.data[0]; + if (!firstItem) { + const subscriptionWithPeriod = subscription as { current_period_end?: number | null }; + return subscriptionWithPeriod.current_period_end ?? null; + } + + return subscription.items.data.reduce((latest, item) => { + return Math.max(latest, item.current_period_end); + }, firstItem.current_period_end); +} + +function getEarliestPeriodStart(subscription: StripeSubscriptionWithExtras): number | null { + const firstItem = subscription.items.data[0]; + if (!firstItem) { + const subscriptionWithPeriod = subscription as { current_period_start?: number | null }; + return subscriptionWithPeriod.current_period_start ?? null; + } + + return subscription.items.data.reduce((earliest, item) => { + return Math.min(earliest, item.current_period_start); + }, firstItem.current_period_start); +} + +function getStripeCustomerId( + customer: string | StripeSdk.Customer | StripeSdk.DeletedCustomer | null, +): string | null { + if (!customer) { + return null; + } + + return typeof customer === "string" ? customer : customer.id; +} + +function normalizeStripePaymentMethod(paymentMethod: StripeSdk.PaymentMethod): { + expiryMonth?: number; + expiryYear?: number; + last4?: string; + providerMethodId: string; + type: string; +} { + return { + expiryMonth: paymentMethod.card?.exp_month ?? undefined, + expiryYear: paymentMethod.card?.exp_year ?? undefined, + last4: paymentMethod.card?.last4 ?? undefined, + providerMethodId: paymentMethod.id, + type: paymentMethod.type, + }; +} + +function normalizeStripePaymentIntent(paymentIntent: StripeSdk.PaymentIntent) { + const providerMethodId = + typeof paymentIntent.payment_method === "string" + ? paymentIntent.payment_method + : paymentIntent.payment_method?.id; + + return { + amount: paymentIntent.amount_received || paymentIntent.amount, + createdAt: new Date(paymentIntent.created * 1000), + currency: paymentIntent.currency, + description: paymentIntent.description, + metadata: Object.keys(paymentIntent.metadata).length > 0 ? paymentIntent.metadata : undefined, + providerMethodId, + providerPaymentId: paymentIntent.id, + status: paymentIntent.status, + }; +} + +function normalizeStripeInvoice(invoice: StripeInvoiceWithExtras) { + return { + currency: invoice.currency, + hostedUrl: invoice.hosted_invoice_url, + periodEndAt: toDate(invoice.period_end), + periodStartAt: toDate(invoice.period_start), + providerInvoiceId: invoice.id, + status: invoice.status, + totalAmount: invoice.total ?? 0, + }; +} + +function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras) { + const firstItem = subscription.items.data[0]; + const providerPriceId = + typeof firstItem?.price === "string" ? firstItem.price : firstItem?.price.id; + const periodStart = getEarliestPeriodStart(subscription); + const periodEnd = getLatestPeriodEnd(subscription); + + const cancelAt = (subscription as { cancel_at?: number | null }).cancel_at; + return { + cancelAtPeriodEnd: subscription.cancel_at_period_end || (cancelAt != null && cancelAt > 0), + canceledAt: toDate(subscription.canceled_at), + currentPeriodEndAt: toDate(periodEnd), + currentPeriodStartAt: toDate(periodStart), + endedAt: toDate(subscription.ended_at), + providerPriceId: providerPriceId ?? null, + providerSubscriptionId: subscription.id, + providerSubscriptionScheduleId: + (typeof subscription.schedule === "string" + ? subscription.schedule + : subscription.schedule?.id) ?? null, + status: subscription.status, + }; +} + +function normalizeStripeTestClock(clock: StripeSdk.TestHelpers.TestClock): ProviderTestClock { + return { + frozenTime: new Date(clock.frozen_time * 1000), + id: clock.id, + name: clock.name ?? null, + status: clock.status, + }; +} + +function assertStripeTestKey(options: StripeOptions): void { + if (!options.secretKey.startsWith("sk_test_")) { + throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_TEST_KEY_REQUIRED); + } +} + +async function retrieveExpandedSubscription( + client: StripeSdk, + providerSubscriptionId: string, +): Promise { + return (await client.subscriptions.retrieve(providerSubscriptionId, { + expand: ["items.data.price", "latest_invoice.payment_intent", "schedule"], + })) as StripeSubscriptionWithExtras; +} + +function normalizeRequiredAction(paymentIntent?: StripeSdk.PaymentIntent | null) { + const nextActionType = paymentIntent?.next_action?.type; + if (!nextActionType) { + return null; + } + + return { + clientSecret: paymentIntent.client_secret ?? undefined, + paymentIntentId: paymentIntent.id, + type: nextActionType, + }; +} + +function isPaymentMethodAttachedToCustomer( + paymentMethod: StripeSdk.PaymentMethod, + stripeCustomerId: string | null, +): boolean { + if (!stripeCustomerId) { + return false; + } + + return getStripeCustomerId(paymentMethod.customer) === stripeCustomerId; +} + +async function getCheckoutPaymentDetails(client: StripeSdk, session: StripeSdk.Checkout.Session) { + const stripeCustomerId = getStripeCustomerId(session.customer); + if (!stripeCustomerId) { + return { + paymentIntent: null, + paymentMethod: null, + }; + } + + if (session.mode === "payment" || session.mode === "subscription") { + const paymentIntentId = + typeof session.payment_intent === "string" + ? session.payment_intent + : session.payment_intent?.id; + + if (paymentIntentId) { + const paymentIntent = await client.paymentIntents.retrieve(paymentIntentId, { + expand: ["payment_method"], + }); + const paymentMethod = paymentIntent.payment_method; + if (paymentMethod && typeof paymentMethod !== "string") { + return { + paymentIntent, + paymentMethod: isPaymentMethodAttachedToCustomer(paymentMethod, stripeCustomerId) + ? paymentMethod + : null, + }; + } + } + + // Subscription-mode checkouts don't have a top-level payment_intent. + // Retrieve the payment method from the subscription's default_payment_method. + if (session.mode === "subscription") { + const subscriptionId = + typeof session.subscription === "string" ? session.subscription : session.subscription?.id; + if (subscriptionId) { + const sub = await client.subscriptions.retrieve(subscriptionId, { + expand: ["default_payment_method"], + }); + const paymentMethod = sub.default_payment_method; + if (paymentMethod && typeof paymentMethod !== "string") { + return { + paymentIntent: null, + paymentMethod: isPaymentMethodAttachedToCustomer(paymentMethod, stripeCustomerId) + ? paymentMethod + : null, + }; + } + } + } + + return { + paymentIntent: null, + paymentMethod: null, + }; + } + + if (session.mode === "setup") { + const setupIntentId = + typeof session.setup_intent === "string" ? session.setup_intent : session.setup_intent?.id; + if (!setupIntentId) { + return { + paymentIntent: null, + paymentMethod: null, + }; + } + + const setupIntent = await client.setupIntents.retrieve(setupIntentId, { + expand: ["payment_method"], + }); + const paymentMethod = setupIntent.payment_method; + if (!paymentMethod || typeof paymentMethod === "string") { + return { + paymentIntent: null, + paymentMethod: null, + }; + } + + return { + paymentIntent: null, + paymentMethod: isPaymentMethodAttachedToCustomer(paymentMethod, stripeCustomerId) + ? paymentMethod + : null, + }; + } + + return { + paymentIntent: null, + paymentMethod: null, + }; +} + +async function createCheckoutCompletedEvents( + client: StripeSdk, + event: StripeSdk.Event, +): Promise { + if (event.type !== "checkout.session.completed") { + return []; + } + + const session = event.data.object; + const stripeCustomerId = getStripeCustomerId(session.customer); + const providerCustomerId = session.client_reference_id ?? stripeCustomerId; + if (!providerCustomerId) { + return []; + } + + const events: NormalizedWebhookEvent[] = []; + const { paymentIntent, paymentMethod } = await getCheckoutPaymentDetails(client, session); + const providerSubscriptionId = + typeof session.subscription === "string" + ? session.subscription + : (session.subscription?.id ?? null); + const providerInvoiceId = + typeof session.invoice === "string" ? session.invoice : (session.invoice?.id ?? null); + const expandedSubscription = + session.mode === "subscription" && providerSubscriptionId + ? await retrieveExpandedSubscription(client, providerSubscriptionId) + : null; + const expandedInvoice = + providerInvoiceId != null + ? ((await client.invoices.retrieve(providerInvoiceId, { + expand: ["payment_intent"], + })) as StripeInvoiceWithExtras) + : null; + + if (paymentMethod) { + const normalizedPaymentMethod = { + ...normalizeStripePaymentMethod(paymentMethod), + isDefault: session.mode === "subscription", + }; + events.push({ + actions: [ + { + data: { + paymentMethod: normalizedPaymentMethod, + providerCustomerId, + }, + type: "payment_method.upsert", + }, + ], + name: "payment_method.attached", + payload: { + paymentMethod: normalizedPaymentMethod, + providerCustomerId, + }, + }); + } + + if (session.mode === "payment" && paymentIntent?.status === "succeeded") { + const normalizedPayment = normalizeStripePaymentIntent(paymentIntent); + events.push({ + actions: [ + { + data: { + payment: normalizedPayment, + providerCustomerId, + }, + type: "payment.upsert", + }, + ], + name: "payment.succeeded", + payload: { + payment: normalizedPayment, + providerCustomerId, + }, + }); + } + + const sessionMetadata = session.metadata ?? {}; + + events.push({ + name: "checkout.completed", + payload: { + checkoutSessionId: session.id, + invoice: expandedInvoice ? normalizeStripeInvoice(expandedInvoice) : undefined, + metadata: Object.keys(sessionMetadata).length > 0 ? sessionMetadata : undefined, + mode: session.mode ?? undefined, + paymentStatus: session.payment_status, + providerCustomerId, + providerEventId: event.id, + providerInvoiceId: providerInvoiceId ?? undefined, + providerSubscriptionId: providerSubscriptionId ?? undefined, + status: session.status, + subscription: expandedSubscription + ? normalizeStripeSubscription(expandedSubscription) + : undefined, + }, + }); + + return events; +} + +async function createSubscriptionEvents(event: StripeSdk.Event): Promise { + if ( + event.type !== "customer.subscription.created" && + event.type !== "customer.subscription.updated" && + event.type !== "customer.subscription.deleted" + ) { + return []; + } + + const sourceSubscription = event.data.object as StripeSubscriptionWithExtras; + + // Use the webhook event's subscription data directly. Re-fetching from + // Stripe can return stale data during renewals (period dates not yet + // propagated). The webhook event is the authoritative source. + const subscription = sourceSubscription; + const providerCustomerId = getStripeCustomerId(subscription.customer); + if (!providerCustomerId) { + return []; + } + + if (event.type === "customer.subscription.deleted") { + return [ + { + actions: [ + { + data: { + providerCustomerId, + providerSubscriptionId: subscription.id, + }, + type: "subscription.delete", + }, + ], + name: "subscription.deleted", + payload: { + providerCustomerId, + providerEventId: event.id, + providerSubscriptionId: subscription.id, + }, + }, + ]; + } + + const normalizedSubscription = normalizeStripeSubscription(subscription); + const normalizedEvent: NormalizedWebhookEvent<"subscription.updated"> = { + actions: [ + { + data: { + providerCustomerId, + subscription: normalizedSubscription, + }, + type: "subscription.upsert", + }, + ], + name: "subscription.updated", + payload: { + providerCustomerId, + providerEventId: event.id, + subscription: normalizedSubscription, + }, + }; + return [normalizedEvent]; +} + +function createInvoiceEvents(event: StripeSdk.Event): NormalizedWebhookEvent[] { + if ( + event.type !== "invoice.created" && + event.type !== "invoice.finalized" && + event.type !== "invoice.paid" && + event.type !== "invoice.payment_failed" && + event.type !== "invoice.updated" + ) { + return []; + } + + const invoice = event.data.object as StripeInvoiceWithExtras; + const providerCustomerId = getStripeCustomerId(invoice.customer); + if (!providerCustomerId) { + return []; + } + + const providerSubscriptionId = + typeof invoice.subscription === "string" + ? invoice.subscription + : (invoice.subscription?.id ?? null); + + const normalizedInvoice = normalizeStripeInvoice(invoice); + const normalizedEvent: NormalizedWebhookEvent<"invoice.updated"> = { + actions: [ + { + data: { + invoice: normalizedInvoice, + providerCustomerId, + providerSubscriptionId, + }, + type: "invoice.upsert", + }, + ], + name: "invoice.updated", + payload: { + invoice: normalizedInvoice, + providerCustomerId, + providerEventId: event.id, + providerSubscriptionId, + }, + }; + return [normalizedEvent]; +} + +function createDetachedPaymentMethodEvents(event: StripeSdk.Event): NormalizedWebhookEvent[] { + if (event.type !== "payment_method.detached") { + return []; + } + + const paymentMethod = event.data.object; + + return [ + { + actions: [ + { + data: { + providerMethodId: paymentMethod.id, + }, + type: "payment_method.delete", + }, + ], + name: "payment_method.detached", + payload: { + providerEventId: event.id, + providerMethodId: paymentMethod.id, + }, + }, + ]; +} + +export function createStripeProvider(client: StripeSdk, options: StripeOptions): PaymentProvider { + const currency = "usd"; + + return { + id: "stripe", + name: "Stripe", + + async createCustomer(data) { + let testClock: ProviderTestClock | undefined; + if (data.createTestClock) { + assertStripeTestKey(options); + const clock = await client.testHelpers.testClocks.create({ + frozen_time: Math.floor(Date.now() / 1000), + name: data.id, + }); + testClock = normalizeStripeTestClock(clock); + } + + const customer = await client.customers.create({ + email: data.email, + metadata: { + customerId: data.id, + ...data.metadata, + }, + name: data.name, + test_clock: testClock?.id, + }); + + return { + providerCustomer: { + id: customer.id, + frozenTime: testClock?.frozenTime.toISOString(), + testClockId: testClock?.id, + }, + }; + }, + + async updateCustomer(data) { + await client.customers.update(data.providerCustomerId, { + email: data.email, + metadata: data.metadata, + name: data.name, + }); + }, + + async deleteCustomer(data) { + await client.customers.del(data.providerCustomerId); + }, + + async getTestClock(data) { + const clock = await client.testHelpers.testClocks.retrieve(data.testClockId); + return normalizeStripeTestClock(clock); + }, + + async advanceTestClock(data) { + assertStripeTestKey(options); + + await client.testHelpers.testClocks.advance(data.testClockId, { + frozen_time: Math.floor(data.frozenTime.getTime() / 1000), + }); + + for (let i = 0; i < 60; i++) { + const clock = await client.testHelpers.testClocks.retrieve(data.testClockId); + if (clock.status === "ready") { + return normalizeStripeTestClock(clock); + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + throw new Error(`Test clock ${data.testClockId} did not reach 'ready' status`); + }, + + async attachPaymentMethod(data) { + const session = await client.checkout.sessions.create({ + cancel_url: data.returnURL, + client_reference_id: data.providerCustomerId, + customer: data.providerCustomerId, + mode: "setup", + success_url: data.returnURL, + }); + + if (!session.url) { + throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SESSION_INVALID); + } + + return { url: session.url }; + }, + + async createSubscriptionCheckout(data) { + const sessionParams: StripeSdk.Checkout.SessionCreateParams = { + cancel_url: data.cancelUrl ?? data.successUrl, + client_reference_id: data.providerCustomerId, + customer: data.providerCustomerId, + line_items: [{ price: data.providerPriceId, quantity: 1 }], + metadata: data.metadata, + mode: "subscription", + success_url: data.successUrl, + }; + const session = await client.checkout.sessions.create(sessionParams); + + if (!session.url) { + throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SESSION_INVALID); + } + + return { + paymentUrl: session.url, + providerCheckoutSessionId: session.id, + }; + }, + + async createSubscription(data) { + const createParams: StripeSdk.SubscriptionCreateParams = { + customer: data.providerCustomerId, + items: [{ price: data.providerPriceId }], + payment_behavior: "default_incomplete", + expand: ["latest_invoice.payment_intent"], + }; + const createdSubscription = (await client.subscriptions.create( + createParams, + )) as StripeSubscriptionWithExtras; + + const latestInvoice = createdSubscription.latest_invoice; + const invoice = + latestInvoice && typeof latestInvoice !== "string" + ? normalizeStripeInvoice(latestInvoice) + : null; + const paymentIntent = + latestInvoice && typeof latestInvoice !== "string" + ? (latestInvoice.payment_intent as StripeSdk.PaymentIntent | null | undefined) + : null; + + return { + invoice, + paymentUrl: null, + requiredAction: normalizeRequiredAction(paymentIntent ?? null), + subscription: normalizeStripeSubscription(createdSubscription), + }; + }, + + async updateSubscription(data) { + const currentSubscription = await retrieveExpandedSubscription( + client, + data.providerSubscriptionId, + ); + const currentItem = currentSubscription.items.data[0]; + if (!currentItem) { + throw PayKitError.from( + "BAD_REQUEST", + PAYKIT_ERROR_CODES.PROVIDER_SUBSCRIPTION_MISSING_ITEMS, + ); + } + + const updatedSubscription = (await client.subscriptions.update(data.providerSubscriptionId, { + items: [ + { + id: currentItem.id, + price: data.providerPriceId, + }, + ], + payment_behavior: "pending_if_incomplete", + proration_behavior: "always_invoice", + expand: ["latest_invoice.payment_intent"], + })) as StripeSubscriptionWithExtras; + + const latestInvoice = updatedSubscription.latest_invoice; + const invoice = + latestInvoice && typeof latestInvoice !== "string" + ? normalizeStripeInvoice(latestInvoice) + : null; + const paymentIntent = + latestInvoice && typeof latestInvoice !== "string" + ? (latestInvoice.payment_intent as StripeSdk.PaymentIntent | null | undefined) + : null; + + return { + invoice, + paymentUrl: null, + requiredAction: normalizeRequiredAction(paymentIntent ?? null), + subscription: normalizeStripeSubscription(updatedSubscription), + }; + }, + + async scheduleSubscriptionChange(data) { + if (!data.providerPriceId) { + throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_PRICE_REQUIRED); + } + + const currentSub = (await client.subscriptions.retrieve(data.providerSubscriptionId, { + expand: ["items"], + })) as StripeSubscriptionWithExtras; + const periodEndSeconds = getLatestPeriodEnd(currentSub); + if (typeof periodEndSeconds !== "number") { + throw PayKitError.from( + "BAD_REQUEST", + PAYKIT_ERROR_CODES.PROVIDER_SUBSCRIPTION_MISSING_PERIOD, + ); + } + + const currentItems = currentSub.items.data.map((item: { price: { id: string } }) => ({ + price: item.price.id, + quantity: 1, + })); + + let schedule: StripeSdk.SubscriptionSchedule; + if (data.providerSubscriptionScheduleId) { + schedule = await client.subscriptionSchedules.retrieve(data.providerSubscriptionScheduleId); + } else { + const existingScheduleId = + typeof currentSub.schedule === "string" + ? currentSub.schedule + : (currentSub.schedule?.id ?? null); + schedule = existingScheduleId + ? await client.subscriptionSchedules.retrieve(existingScheduleId) + : await client.subscriptionSchedules.create({ + from_subscription: data.providerSubscriptionId, + }); + } + const scheduleId = schedule.id; + + const currentPhase = schedule.phases[0]; + const currentPhaseStart = currentPhase?.start_date ?? Math.floor(Date.now() / 1000); + + await client.subscriptionSchedules.update(scheduleId, { + end_behavior: "release", + phases: [ + { + items: currentItems, + start_date: currentPhaseStart, + end_date: periodEndSeconds, + }, + { + items: [{ price: data.providerPriceId, quantity: 1 }], + start_date: periodEndSeconds, + }, + ], + }); + + const updatedSubscription = await retrieveExpandedSubscription( + client, + data.providerSubscriptionId, + ); + + return { + paymentUrl: null, + requiredAction: null, + subscription: normalizeStripeSubscription(updatedSubscription), + }; + }, + + async cancelSubscription(data) { + const currentSubscription = (await client.subscriptions.retrieve( + data.providerSubscriptionId, + )) as StripeSubscriptionWithExtras; + + let scheduleId = data.providerSubscriptionScheduleId ?? null; + if (!scheduleId) { + scheduleId = + typeof currentSubscription.schedule === "string" + ? currentSubscription.schedule + : (currentSubscription.schedule?.id ?? null); + } + if (scheduleId) { + const schedule = await client.subscriptionSchedules.retrieve(scheduleId); + if (schedule.status !== "released" && schedule.status !== "canceled") { + await client.subscriptionSchedules.release(scheduleId); + } + } + + const updatedSubscription = (await client.subscriptions.update(data.providerSubscriptionId, { + cancel_at_period_end: true, + })) as StripeSubscriptionWithExtras; + + return { + paymentUrl: null, + requiredAction: null, + subscription: normalizeStripeSubscription(updatedSubscription), + }; + }, + + async listActiveSubscriptions(data) { + const subscriptions = await client.subscriptions.list({ + customer: data.providerCustomerId, + status: "active", + }); + return subscriptions.data.map((sub) => ({ + providerSubscriptionId: sub.id, + })); + }, + + async resumeSubscription(data) { + let scheduleId = data.providerSubscriptionScheduleId ?? null; + if (!scheduleId) { + const sub = await client.subscriptions.retrieve(data.providerSubscriptionId); + scheduleId = typeof sub.schedule === "string" ? sub.schedule : (sub.schedule?.id ?? null); + } + if (scheduleId) { + const schedule = await client.subscriptionSchedules.retrieve(scheduleId); + if (schedule.status !== "released" && schedule.status !== "canceled") { + await client.subscriptionSchedules.release(scheduleId); + } + } + + const updatedSubscription = (await client.subscriptions.update(data.providerSubscriptionId, { + cancel_at_period_end: false, + })) as StripeSubscriptionWithExtras; + + return { + paymentUrl: null, + requiredAction: null, + subscription: normalizeStripeSubscription(updatedSubscription), + }; + }, + + async detachPaymentMethod(data) { + await client.paymentMethods.detach(data.providerMethodId); + }, + + async syncProduct(data) { + let providerProductId = data.existingProviderProductId; + if (!providerProductId) { + const stripeProduct = await client.products.create({ + metadata: { paykit_product_id: data.id }, + name: data.name, + }); + providerProductId = stripeProduct.id; + } else { + await client.products.update(providerProductId, { name: data.name }); + } + + if (data.existingProviderPriceId) { + return { providerPriceId: data.existingProviderPriceId, providerProductId }; + } + + const priceParams: StripeSdk.PriceCreateParams = { + currency, + product: providerProductId, + unit_amount: data.priceAmount, + }; + if (data.priceInterval) { + priceParams.recurring = { + interval: data.priceInterval as "month" | "year", + }; + } + const stripePrice = await client.prices.create(priceParams); + + return { providerPriceId: stripePrice.id, providerProductId }; + }, + + async createInvoice(data) { + const stripeInvoice = await client.invoices.create({ + auto_advance: data.autoAdvance ?? true, + collection_method: "charge_automatically", + customer: data.providerCustomerId, + currency, + }); + + if (data.lines.length > 0) { + await client.invoices.addLines(stripeInvoice.id, { + lines: data.lines.map((line) => ({ + amount: line.amount, + description: line.description, + })), + }); + } + + const finalizedInvoice = await client.invoices.finalizeInvoice(stripeInvoice.id); + + return normalizeStripeInvoice(finalizedInvoice); + }, + + async handleWebhook(data) { + const headerKey = Object.keys(data.headers).find( + (k) => k.toLowerCase() === "stripe-signature", + ); + const signature = headerKey ? data.headers[headerKey] : undefined; + if (!signature) { + throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING); + } + + const event = client.webhooks.constructEvent(data.body, signature, options.webhookSecret); + return [ + ...(await createCheckoutCompletedEvents(client, event)), + ...(await createSubscriptionEvents(event)), + ...createInvoiceEvents(event), + ...createDetachedPaymentMethodEvents(event), + ]; + }, + + async createPortalSession(data) { + const session = await client.billingPortal.sessions.create({ + customer: data.providerCustomerId, + return_url: data.returnUrl, + }); + return { url: session.url }; + }, + + async check() { + const mode = + options.secretKey.startsWith("sk_test_") || options.secretKey.startsWith("rk_test_") + ? "test mode" + : "live mode"; + try { + const account = await client.accounts.retrieve(); + const displayName = + account.settings?.dashboard?.display_name || account.business_profile?.name || account.id; + + let webhookEndpoints: Array<{ url: string; status: string }> = []; + try { + const endpoints = await client.webhookEndpoints.list({ limit: 100 }); + webhookEndpoints = endpoints.data + .filter((ep) => ep.status === "enabled") + .map((ep) => ({ url: ep.url, status: ep.status })); + } catch { + // webhook listing may fail with restricted keys + } + + return { ok: true, displayName, mode, webhookEndpoints }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, displayName: "unknown", mode, error: message }; + } + }, + }; +} -export function stripe(options: StripeProviderOptions): StripeProviderConfig { +export function stripe(options: StripeOptions): PayKitProviderConfig { return { - ...options, id: "stripe", - kind: "stripe", + name: "Stripe", + createAdapter(): PaymentProvider { + return createStripeProvider(new StripeSdk(options.secretKey), options); + }, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5182d2..c054820 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,10 +79,10 @@ importers: version: 11.12.0(typescript@5.9.3) autumn-js: specifier: ^1.2.2 - version: 1.2.2(better-auth@1.6.2(8fff6a967c03a30b47656e909f3a8e44))(better-call@1.3.5(zod@4.3.6))(express@5.2.1)(hono@4.12.3)(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 1.2.2(better-auth@1.6.2(140411336a30ff0790dc8607571a86e3))(better-call@1.3.5(zod@4.3.6))(express@5.2.1)(hono@4.12.3)(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) better-auth: specifier: ^1.6.2 - version: 1.6.2(8fff6a967c03a30b47656e909f3a8e44) + version: 1.6.2(140411336a30ff0790dc8607571a86e3) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -245,7 +245,7 @@ importers: version: 2.0.13 '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 1.6.1(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -266,16 +266,16 @@ importers: version: 12.34.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) fumadocs-core: specifier: ^16.7.11 - version: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76) + version: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76) fumadocs-mdx: specifier: ^14.2.11 - version: 14.2.11(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76))(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(vite@8.0.3(@types/node@20.19.34)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 14.2.11(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(vite@8.0.3(@types/node@20.19.34)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) fumadocs-ui: specifier: ^16.7.11 - version: 16.7.11(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76))(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(shiki@4.0.0)(tailwindcss@4.2.1) + version: 16.7.11(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(shiki@4.0.0)(tailwindcss@4.2.1) geist: specifier: ^1.3.1 - version: 1.7.0(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + version: 1.7.0(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -455,9 +455,6 @@ importers: posthog-node: specifier: ^5.28.8 version: 5.28.8(rxjs@7.8.2) - stripe: - specifier: ^19.1.0 - version: 19.3.1(@types/node@25.3.0) typescript: specifier: ^5.9.2 version: 5.9.2 @@ -483,6 +480,9 @@ importers: paykitjs: specifier: workspace:* version: link:../paykit + stripe: + specifier: ^19.1.0 + version: 19.3.1(@types/node@25.3.0) devDependencies: tsdown: specifier: ^0.21.1 @@ -11050,7 +11050,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vercel/analytics@1.6.1(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': + '@vercel/analytics@1.6.1(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': optionalDependencies: next: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 @@ -11244,13 +11244,13 @@ snapshots: auto-bind@5.0.1: {} - autumn-js@1.2.2(better-auth@1.6.2(8fff6a967c03a30b47656e909f3a8e44))(better-call@1.3.5(zod@4.3.6))(express@5.2.1)(hono@4.12.3)(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5): + autumn-js@1.2.2(better-auth@1.6.2(140411336a30ff0790dc8607571a86e3))(better-call@1.3.5(zod@4.3.6))(express@5.2.1)(hono@4.12.3)(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5): dependencies: query-string: 9.3.1 rou3: 0.6.3 zod: 4.3.6 optionalDependencies: - better-auth: 1.6.2(8fff6a967c03a30b47656e909f3a8e44) + better-auth: 1.6.2(140411336a30ff0790dc8607571a86e3) better-call: 1.3.5(zod@4.3.6) express: 5.2.1 hono: 4.12.3 @@ -11274,7 +11274,7 @@ snapshots: baseline-browser-mapping@2.10.0: {} - better-auth@1.6.2(8fff6a967c03a30b47656e909f3a8e44): + better-auth@1.6.2(140411336a30ff0790dc8607571a86e3): dependencies: '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.2.0) '@better-auth/drizzle-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.18.0)(gel@2.2.0)(kysely@0.28.14)(mysql2@3.15.3)(pg@8.20.0)(postgres@3.4.8)(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3))) @@ -12370,7 +12370,7 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76): + fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76): dependencies: '@formatjs/intl-localematcher': 0.8.2 '@orama/orama': 3.1.18 @@ -12410,14 +12410,14 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-mdx@14.2.11(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76))(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(vite@8.0.3(@types/node@20.19.34)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)): + fumadocs-mdx@14.2.11(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(vite@8.0.3(@types/node@20.19.34)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.27.3 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76) + fumadocs-core: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76) js-yaml: 4.1.1 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 @@ -12440,7 +12440,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.7.11(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76))(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(shiki@4.0.0)(tailwindcss@4.2.1): + fumadocs-ui@16.7.11(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(shiki@4.0.0)(tailwindcss@4.2.1): dependencies: '@fumadocs/tailwind': 0.0.3(tailwindcss@4.2.1) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -12455,7 +12455,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) class-variance-authority: 0.7.1 fuma-cli: 0.0.3(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1) - fumadocs-core: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76) + fumadocs-core: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.166.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.5))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@3.25.76) lucide-react: 1.8.0(react@19.2.5) motion: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next-themes: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -12483,7 +12483,7 @@ snapshots: fuzzysort@3.1.0: {} - geist@1.7.0(next@16.2.3(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)): + geist@1.7.0(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)): dependencies: next: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)