From 541a9e57551ba19ac7059152a96ec1667e7dfbb1 Mon Sep 17 00:00:00 2001 From: Pascoooo <162371632+Pascoooo@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:30:09 +0200 Subject: [PATCH 1/3] feat(paykit): add subscription quantity support --- .changeset/subscription-quantity.md | 5 + packages/paykit/src/providers/provider.ts | 5 + .../stripe/__tests__/stripe-provider.test.ts | 135 +++++++++++ packages/paykit/src/stripe/stripe-provider.ts | 18 +- .../__tests__/subscription.service.test.ts | 224 ++++++++++++++++++ .../__tests__/subscription.types.test.ts | 32 +++ .../src/subscription/subscription.api.ts | 1 + .../src/subscription/subscription.service.ts | 111 ++++++++- .../src/subscription/subscription.types.ts | 1 + packages/paykit/src/types/events.ts | 1 + 10 files changed, 523 insertions(+), 10 deletions(-) create mode 100644 .changeset/subscription-quantity.md create mode 100644 packages/paykit/src/subscription/__tests__/subscription.service.test.ts create mode 100644 packages/paykit/src/subscription/__tests__/subscription.types.test.ts diff --git a/.changeset/subscription-quantity.md b/.changeset/subscription-quantity.md new file mode 100644 index 00000000..ec4732fa --- /dev/null +++ b/.changeset/subscription-quantity.md @@ -0,0 +1,5 @@ +--- +"paykitjs": minor +--- + +Add subscription quantity support to subscribe flows and Stripe provider sync. diff --git a/packages/paykit/src/providers/provider.ts b/packages/paykit/src/providers/provider.ts index a2a1d69d..e88138b5 100644 --- a/packages/paykit/src/providers/provider.ts +++ b/packages/paykit/src/providers/provider.ts @@ -64,6 +64,7 @@ export interface ProviderSubscription { endedAt?: Date | null; providerSubscriptionId: string; providerSubscriptionScheduleId?: string | null; + quantity?: number; status: string; } @@ -111,16 +112,19 @@ export interface PaymentProvider { successUrl: string; cancelUrl?: string; metadata?: Record; + quantity: number; }): Promise<{ paymentUrl: string; providerCheckoutSessionId: string }>; createSubscription(data: { providerCustomerId: string; providerProduct: Record; + quantity: number; }): Promise; updateSubscription(data: { providerProduct: Record; providerSubscriptionId: string; + quantity: number; }): Promise; createInvoice(data: { @@ -133,6 +137,7 @@ export interface PaymentProvider { providerProduct?: Record | null; providerSubscriptionScheduleId?: string | null; providerSubscriptionId: string; + quantity: number; }): Promise; cancelSubscription(data: { diff --git a/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts b/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts index f3d0d5ea..969373b1 100644 --- a/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts +++ b/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts @@ -2,8 +2,37 @@ import { describe, expect, it, vi } from "vitest"; import { createStripeProvider } from "../stripe-provider"; +function createStripeSubscription(quantity = 1) { + return { + cancel_at: null, + cancel_at_period_end: false, + canceled_at: null, + ended_at: null, + id: "sub_123", + items: { + data: [ + { + current_period_end: 1_750_000_000, + current_period_start: 1_700_000_000, + id: "si_123", + price: { id: "price_123", product: "prod_123" }, + quantity, + }, + ], + }, + latest_invoice: null, + schedule: null, + status: "active", + }; +} + function createStripeClientMock() { return { + checkout: { + sessions: { + create: vi.fn().mockResolvedValue({ id: "cs_123", url: "https://checkout.test/session" }), + }, + }, invoices: { addLines: vi.fn().mockResolvedValue({}), create: vi.fn().mockResolvedValue({ id: "in_123" }), @@ -20,6 +49,16 @@ function createStripeClientMock() { products: { create: vi.fn().mockResolvedValue({ id: "prod_123" }), }, + subscriptions: { + create: vi.fn().mockResolvedValue(createStripeSubscription(3)), + retrieve: vi.fn().mockResolvedValue(createStripeSubscription(1)), + update: vi.fn().mockResolvedValue(createStripeSubscription(4)), + }, + subscriptionSchedules: { + create: vi.fn().mockResolvedValue({ id: "sched_123", phases: [] }), + retrieve: vi.fn().mockResolvedValue({ id: "sched_123", phases: [] }), + update: vi.fn().mockResolvedValue({ id: "sched_123", phases: [] }), + }, }; } @@ -71,4 +110,100 @@ describe("stripe-provider", () => { customer: "cus_123", }); }); + + it("passes quantity to Stripe Checkout subscription line items", async () => { + const client = createStripeClientMock(); + const provider = createStripeProvider(client as never, { + currency: "eur", + secretKey: "sk_test_123", + }); + + await provider.createSubscriptionCheckout({ + providerCustomerId: "cus_123", + providerProduct: { priceId: "price_123", productId: "prod_123" }, + quantity: 3, + successUrl: "https://app.test/success", + }); + + expect(client.checkout.sessions.create).toHaveBeenCalledWith( + expect.objectContaining({ + line_items: [{ price: "price_123", quantity: 3 }], + }), + ); + }); + + it("passes quantity to direct Stripe subscription creation", async () => { + const client = createStripeClientMock(); + const provider = createStripeProvider(client as never, { + currency: "eur", + secretKey: "sk_test_123", + }); + + const result = await provider.createSubscription({ + providerCustomerId: "cus_123", + providerProduct: { priceId: "price_123", productId: "prod_123" }, + quantity: 3, + }); + + expect(client.subscriptions.create).toHaveBeenCalledWith( + expect.objectContaining({ + items: [{ price: "price_123", quantity: 3 }], + }), + ); + expect(result.subscription?.quantity).toBe(3); + }); + + it("passes quantity when updating a Stripe subscription", async () => { + const client = createStripeClientMock(); + const provider = createStripeProvider(client as never, { + currency: "eur", + secretKey: "sk_test_123", + }); + + const result = await provider.updateSubscription({ + providerProduct: { priceId: "price_456", productId: "prod_456" }, + providerSubscriptionId: "sub_123", + quantity: 4, + }); + + expect(client.subscriptions.update).toHaveBeenCalledWith( + "sub_123", + expect.objectContaining({ + items: [{ id: "si_123", price: "price_456", quantity: 4 }], + }), + ); + expect(result.subscription?.quantity).toBe(4); + }); + + it("preserves current quantity and applies target quantity for scheduled changes", async () => { + const client = createStripeClientMock(); + client.subscriptions.retrieve + .mockResolvedValueOnce(createStripeSubscription(5)) + .mockResolvedValueOnce(createStripeSubscription(2)); + const provider = createStripeProvider(client as never, { + currency: "eur", + secretKey: "sk_test_123", + }); + + const result = await provider.scheduleSubscriptionChange({ + providerProduct: { priceId: "price_456", productId: "prod_456" }, + providerSubscriptionId: "sub_123", + quantity: 2, + }); + + expect(client.subscriptionSchedules.update).toHaveBeenCalledWith( + "sched_123", + expect.objectContaining({ + phases: [ + expect.objectContaining({ + items: [{ price: "price_123", quantity: 5 }], + }), + expect.objectContaining({ + items: [{ price: "price_456", quantity: 2 }], + }), + ], + }), + ); + expect(result.subscription?.quantity).toBe(2); + }); }); diff --git a/packages/paykit/src/stripe/stripe-provider.ts b/packages/paykit/src/stripe/stripe-provider.ts index ae57e000..dbc61739 100644 --- a/packages/paykit/src/stripe/stripe-provider.ts +++ b/packages/paykit/src/stripe/stripe-provider.ts @@ -189,6 +189,7 @@ function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras) (typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule?.id) ?? null, + quantity: firstItem?.quantity ?? 1, status: subscription.status, }; } @@ -686,7 +687,7 @@ export function createStripeProvider( cancel_url: data.cancelUrl ?? data.successUrl, client_reference_id: data.providerCustomerId, customer: data.providerCustomerId, - line_items: [{ price: data.providerProduct.priceId, quantity: 1 }], + line_items: [{ price: data.providerProduct.priceId, quantity: data.quantity }], metadata: data.metadata, mode: "subscription", success_url: data.successUrl, @@ -709,7 +710,7 @@ export function createStripeProvider( async createSubscription(data) { const createParams: StripeSdk.SubscriptionCreateParams = { customer: data.providerCustomerId, - items: [{ price: data.providerProduct.priceId }], + items: [{ price: data.providerProduct.priceId, quantity: data.quantity }], payment_behavior: "default_incomplete", expand: ["latest_invoice.payment_intent"], }; @@ -753,6 +754,7 @@ export function createStripeProvider( { id: currentItem.id, price: data.providerProduct.priceId, + quantity: data.quantity, }, ], payment_behavior: "pending_if_incomplete", @@ -794,10 +796,12 @@ export function createStripeProvider( ); } - const currentItems = currentSub.items.data.map((item: { price: { id: string } }) => ({ - price: item.price.id, - quantity: 1, - })); + const currentItems = currentSub.items.data.map( + (item: { price: { id: string }; quantity?: number | null }) => ({ + price: item.price.id, + quantity: item.quantity ?? 1, + }), + ); let schedule: StripeSdk.SubscriptionSchedule; if (data.providerSubscriptionScheduleId) { @@ -827,7 +831,7 @@ export function createStripeProvider( end_date: periodEndSeconds, }, { - items: [{ price: data.providerProduct.priceId, quantity: 1 }], + items: [{ price: data.providerProduct.priceId, quantity: data.quantity }], start_date: periodEndSeconds, }, ], diff --git a/packages/paykit/src/subscription/__tests__/subscription.service.test.ts b/packages/paykit/src/subscription/__tests__/subscription.service.test.ts new file mode 100644 index 00000000..26b12fdf --- /dev/null +++ b/packages/paykit/src/subscription/__tests__/subscription.service.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + customer: { + findCustomerByProviderCustomerId: vi.fn(), + upsertProviderCustomer: vi.fn(), + }, + paymentMethod: { + getDefaultPaymentMethod: vi.fn(), + }, + product: { + getDefaultProductInGroup: vi.fn(), + getProductByInternalId: vi.fn(), + getProductByPlan: vi.fn(), + getProductByProviderData: vi.fn(), + getProductFeatures: vi.fn(), + withProviderInfo: vi.fn(), + }, +})); + +vi.mock("../../customer/customer.service", () => mocks.customer); +vi.mock("../../payment-method/payment-method.service", () => mocks.paymentMethod); +vi.mock("../../product/product.service", () => mocks.product); + +import type { PayKitContext } from "../../core/context"; +import type { PayKitDatabase } from "../../database"; +import { + subscribeToPlan, + syncSubscriptionBillingState, + syncSubscriptionFromProvider, +} from "../subscription.service"; + +const now = new Date("2024-01-01T00:00:00.000Z"); + +function createUpdateChain() { + const where = vi.fn().mockResolvedValue(undefined); + const set = vi.fn().mockReturnValue({ where }); + return { set, where }; +} + +function createSelectChain(result: unknown, terminalMethod: "limit" | "orderBy" | "where") { + const chain: Record = { + from: vi.fn(), + innerJoin: vi.fn(), + limit: vi.fn(), + orderBy: vi.fn(), + where: vi.fn(), + }; + chain.from = vi.fn().mockReturnValue(chain); + chain.innerJoin = vi.fn().mockReturnValue(chain); + chain.where = + terminalMethod === "where" ? vi.fn().mockResolvedValue(result) : vi.fn().mockReturnValue(chain); + chain.orderBy = + terminalMethod === "orderBy" + ? vi.fn().mockResolvedValue(result) + : vi.fn().mockReturnValue(chain); + chain.limit = terminalMethod === "limit" ? vi.fn().mockResolvedValue(result) : vi.fn(); + return chain; +} + +function createProductRow() { + return { + createdAt: now, + group: "default", + hash: "hash_123", + id: "restaurant-live", + internalId: "prod_internal_123", + isDefault: false, + name: "Restaurant Live", + priceAmount: 900, + priceCurrency: "eur", + priceInterval: "month", + stripePriceId: "price_123", + stripeProductId: "prod_123", + updatedAt: now, + version: 1, + }; +} + +function createSubscriptionRow(quantity = 3) { + return { + cancelAtPeriodEnd: false, + canceled: false, + canceledAt: null, + createdAt: now, + currentPeriodEndAt: null, + currentPeriodStartAt: null, + customerId: "cust_123", + endedAt: null, + id: "sub_local_123", + productInternalId: "prod_internal_123", + quantity, + scheduledProductId: null, + startedAt: now, + status: "active", + stripeSubscriptionId: "sub_123", + stripeSubscriptionScheduleId: null, + trialEndsAt: null, + updatedAt: now, + }; +} + +function createSamePlanSubscribeContext() { + const product = createProductRow(); + const activeSubscription = createSubscriptionRow(3); + const selectResults = [ + createSelectChain([], "where"), + createSelectChain([{ product, subscription: activeSubscription }], "limit"), + createSelectChain([], "orderBy"), + ]; + const database = { + select: vi.fn(() => selectResults.shift()), + } as unknown as PayKitDatabase; + const provider = { + id: "stripe", + name: "Stripe", + resumeSubscription: vi.fn(), + updateSubscription: vi.fn(), + }; + const trace = vi.fn() as unknown as PayKitContext["logger"]["trace"]; + trace.run = (_prefix, callback) => callback(); + const ctx = { + database, + logger: { info: vi.fn(), trace, warn: vi.fn() }, + options: {}, + products: { + planMap: new Map([ + [ + "restaurant-live", + { + group: "default", + hash: "hash_123", + id: "restaurant-live", + includes: [], + isDefault: false, + name: "Restaurant Live", + price: { amount: 900, currency: "eur", interval: "month" }, + }, + ], + ]), + }, + provider, + } as unknown as PayKitContext; + + mocks.customer.upsertProviderCustomer.mockResolvedValue({ + customerId: "cust_123", + providerCustomer: { id: "cus_123" }, + providerCustomerId: "cus_123", + }); + mocks.paymentMethod.getDefaultPaymentMethod.mockResolvedValue({ id: "pm_123" }); + mocks.product.getProductByPlan.mockResolvedValue(product); + mocks.product.withProviderInfo.mockReturnValue({ + ...product, + providerProduct: { priceId: "price_123", productId: "prod_123" }, + }); + + return { ctx, provider }; +} + +describe("subscription/service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not reset same-plan quantity when quantity is omitted", async () => { + const { ctx, provider } = createSamePlanSubscribeContext(); + + await subscribeToPlan(ctx, { + customerId: "cust_123", + planId: "restaurant-live", + successUrl: "https://app.example.com/success", + }); + + expect(provider.updateSubscription).not.toHaveBeenCalled(); + expect(provider.resumeSubscription).not.toHaveBeenCalled(); + }); + + it("syncs provider subscription quantity into the local subscription", async () => { + const update = createUpdateChain(); + const database = { + update: vi.fn().mockReturnValue({ set: update.set }), + } as unknown as PayKitDatabase; + + await syncSubscriptionFromProvider(database, { + providerSubscription: { + cancelAtPeriodEnd: false, + providerSubscriptionId: "sub_123", + quantity: 3, + status: "active", + }, + subscriptionId: "sub_local_123", + }); + + expect(update.set).toHaveBeenCalledWith(expect.objectContaining({ quantity: 3 })); + }); + + it("syncs explicit billing state quantity without resetting existing quantity", async () => { + const update = createUpdateChain(); + const database = { + query: { + subscription: { + findFirst: vi.fn().mockResolvedValue({ + currentPeriodEndAt: null, + currentPeriodStartAt: null, + id: "sub_local_123", + quantity: 1, + startedAt: null, + status: "active", + stripeSubscriptionId: "sub_123", + stripeSubscriptionScheduleId: null, + }), + }, + }, + update: vi.fn().mockReturnValue({ set: update.set }), + } as unknown as PayKitDatabase; + + await syncSubscriptionBillingState(database, { + quantity: 4, + subscriptionId: "sub_local_123", + }); + + expect(update.set).toHaveBeenCalledWith(expect.objectContaining({ quantity: 4 })); + }); +}); diff --git a/packages/paykit/src/subscription/__tests__/subscription.types.test.ts b/packages/paykit/src/subscription/__tests__/subscription.types.test.ts new file mode 100644 index 00000000..30f071d7 --- /dev/null +++ b/packages/paykit/src/subscription/__tests__/subscription.types.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { subscribeBodySchema } from "../subscription.types"; + +const validSubscribeBody = { + planId: "restaurant-live", + successUrl: "https://app.example.com/billing/success", +}; + +describe("subscribeBodySchema", () => { + it("accepts missing quantity for the default quantity path", () => { + expect(subscribeBodySchema.safeParse(validSubscribeBody).success).toBe(true); + }); + + it("accepts a positive integer quantity", () => { + expect(subscribeBodySchema.safeParse({ ...validSubscribeBody, quantity: 3 }).success).toBe( + true, + ); + }); + + it("rejects zero, negative, and decimal quantities", () => { + expect(subscribeBodySchema.safeParse({ ...validSubscribeBody, quantity: 0 }).success).toBe( + false, + ); + expect(subscribeBodySchema.safeParse({ ...validSubscribeBody, quantity: -1 }).success).toBe( + false, + ); + expect(subscribeBodySchema.safeParse({ ...validSubscribeBody, quantity: 1.5 }).success).toBe( + false, + ); + }); +}); diff --git a/packages/paykit/src/subscription/subscription.api.ts b/packages/paykit/src/subscription/subscription.api.ts index 8eeb8514..83ae1207 100644 --- a/packages/paykit/src/subscription/subscription.api.ts +++ b/packages/paykit/src/subscription/subscription.api.ts @@ -18,6 +18,7 @@ export const subscribe = definePayKitMethod( customerId: ctx.customer.id, forceCheckout: ctx.input.forceCheckout, planId: ctx.input.planId, + quantity: ctx.input.quantity, successUrl: ctx.input.successUrl, cancelUrl: ctx.input.cancelUrl, }); diff --git a/packages/paykit/src/subscription/subscription.service.ts b/packages/paykit/src/subscription/subscription.service.ts index ccbda6de..27a12d7c 100644 --- a/packages/paykit/src/subscription/subscription.service.ts +++ b/packages/paykit/src/subscription/subscription.service.ts @@ -43,10 +43,17 @@ export async function subscribeToPlan( const startTime = Date.now(); ctx.logger.info({ planId: input.planId, customerId: input.customerId }, "subscribe started"); - const subCtx = await loadSubscribeContext(ctx, input); + let subCtx = await loadSubscribeContext(ctx, input); let result: SubscribeResult; - if (isSamePlan(subCtx)) { + if (input.quantity !== undefined && isSamePlan(subCtx) && !isSameQuantity(subCtx)) { + if (hasPendingSamePlanChange(subCtx)) { + await handleSamePlanSubscribe(ctx, subCtx); + subCtx = await loadSubscribeContext(ctx, input); + } + // Same plan with a different quantity updates the current provider subscription in place. + result = await handleQuantityChange(ctx, subCtx); + } else if (isSamePlan(subCtx)) { // Same plan means either a noop or resuming a pending cancellation. result = await handleSamePlanSubscribe(ctx, subCtx); } else if (!subCtx.activeSubscription) { @@ -99,6 +106,7 @@ async function resolveStoredPlanFeatures( export async function loadSubscribeContext(ctx: PayKitContext, input: SubscribeInput) { const providerId = ctx.provider.id; + const quantity = input.quantity ?? 1; const normalizedPlan = ctx.products.planMap.get(input.planId); const matchingProduct = input.productInternalId ? await getProductByInternalId(ctx.database, input.productInternalId) @@ -178,6 +186,7 @@ export async function loadSubscribeContext(ctx: PayKitContext, input: SubscribeI planFeatures, providerCustomerId, providerId, + quantity, scheduledSubscriptions, shouldUseCheckout: isPaidTarget && (input.forceCheckout === true || !hasDefaultPaymentMethod), storedPlan, @@ -385,6 +394,19 @@ function isSamePlan(subCtx: SubscribeContext): boolean { return subCtx.activeSubscription?.planId === subCtx.storedPlan.id; } +function isSameQuantity(subCtx: SubscribeContext): boolean { + return (subCtx.activeSubscription?.quantity ?? 1) === subCtx.quantity; +} + +function hasPendingSamePlanChange(subCtx: SubscribeContext): boolean { + const activeSubscription = subCtx.activeSubscription; + return ( + activeSubscription != null && + hasProviderSubscription(activeSubscription) && + (subCtx.scheduledSubscriptions.length > 0 || activeSubscription.canceled) + ); +} + function getSubscriptionEffectiveDate(input: { currentPeriodEndAt?: Date | null; currentPeriodStartAt?: Date | null; @@ -436,6 +458,7 @@ async function activateScheduledSubscriptionForGroup( subscriptionStatus: string; subscriptionCurrentPeriodEndAt?: Date | null; subscriptionCurrentPeriodStartAt?: Date | null; + subscriptionQuantity?: number | null; stripeSubscriptionId?: string | null; stripeSubscriptionScheduleId?: string | null; }, @@ -484,6 +507,7 @@ async function activateScheduledSubscriptionForGroup( subscriptionId: targetSub.id, startedAt: targetSub.startedAt ?? activationDate, status: input.subscriptionStatus, + quantity: input.subscriptionQuantity ?? undefined, stripeSubscriptionId: input.stripeSubscriptionId, stripeSubscriptionScheduleId: input.stripeSubscriptionScheduleId, }); @@ -581,6 +605,7 @@ export async function applySubscriptionWebhookAction( stripeSubscriptionId: action.data.subscription.providerSubscriptionId, stripeSubscriptionScheduleId: action.data.subscription.providerSubscriptionScheduleId ?? null, + quantity: action.data.subscription.quantity, status: action.data.subscription.status, }) : null); @@ -597,6 +622,7 @@ export async function applySubscriptionWebhookAction( await syncSubscriptionBillingState(ctx.database, { stripeSubscriptionId: action.data.subscription.providerSubscriptionId, stripeSubscriptionScheduleId: action.data.subscription.providerSubscriptionScheduleId ?? null, + quantity: action.data.subscription.quantity, subscriptionId: targetSub.id, }); @@ -702,6 +728,7 @@ export async function applySubscriptionWebhookAction( action.data.subscription.providerSubscriptionScheduleId ?? null, subscriptionCurrentPeriodEndAt: action.data.subscription.currentPeriodEndAt, subscriptionCurrentPeriodStartAt: action.data.subscription.currentPeriodStartAt, + subscriptionQuantity: action.data.subscription.quantity, subscriptionStatus: action.data.subscription.status, }); @@ -735,6 +762,64 @@ function getProviderSubscriptionRef(subscription: ActiveSubscription): { }; } +/** Updates the quantity of the current subscription without changing its plan. */ +async function handleQuantityChange( + ctx: PayKitContext, + subCtx: SubscribeContext, +): Promise { + const activeSubscription = subCtx.activeSubscription; + if (!activeSubscription) { + throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED); + } + + if (!hasProviderSubscription(activeSubscription) || !subCtx.storedPlan.providerProduct) { + await syncSubscriptionBillingState(ctx.database, { + subscriptionId: activeSubscription.id, + quantity: subCtx.quantity, + }); + return buildSubscribeResult({ paymentUrl: null }); + } + + const activeSubscriptionRef = getProviderSubscriptionRef(activeSubscription); + if (!activeSubscriptionRef.subscriptionId) { + throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED); + } + + const providerResult = await ctx.provider.updateSubscription({ + providerProduct: subCtx.storedPlan.providerProduct, + providerSubscriptionId: activeSubscriptionRef.subscriptionId, + quantity: subCtx.quantity, + }); + + if (!providerResult.subscription) { + throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED); + } + + await ctx.database.transaction(async (tx) => { + await deleteScheduledSubscriptionsInGroupIfNeeded(tx, subCtx); + await syncSubscriptionFromProvider(tx, { + providerSubscription: providerResult.subscription!, + subscriptionId: activeSubscription.id, + }); + await syncSubscriptionBillingState(tx, { + currentPeriodEndAt: providerResult.subscription!.currentPeriodEndAt, + currentPeriodStartAt: providerResult.subscription!.currentPeriodStartAt, + quantity: providerResult.subscription!.quantity ?? subCtx.quantity, + stripeSubscriptionId: providerResult.subscription!.providerSubscriptionId, + stripeSubscriptionScheduleId: + providerResult.subscription!.providerSubscriptionScheduleId ?? null, + status: providerResult.subscription!.status, + subscriptionId: activeSubscription.id, + }); + }); + + return buildSubscribeResult({ + invoice: providerResult.invoice, + paymentUrl: providerResult.paymentUrl, + requiredAction: providerResult.requiredAction, + }); +} + /** Returns a noop or resumes the current provider subscription. */ async function handleSamePlanSubscribe( ctx: PayKitContext, @@ -764,6 +849,7 @@ async function handleSamePlanSubscribe( cancelAtPeriodEnd: false, providerSubscriptionId: activeSubscriptionRef.subscriptionId!, providerSubscriptionScheduleId: null, + quantity: activeSubscription.quantity, status: activeSubscription.status, }, }); @@ -778,6 +864,7 @@ async function handleSamePlanSubscribe( stripeSubscriptionId: providerResult.subscription.providerSubscriptionId, stripeSubscriptionScheduleId: providerResult.subscription.providerSubscriptionScheduleId ?? null, + quantity: providerResult.subscription.quantity, status: providerResult.subscription.status, subscriptionId: activeSubscription.id, }); @@ -814,6 +901,7 @@ async function handleInitialSubscribe( const providerResult = await ctx.provider.createSubscription({ providerCustomerId: subCtx.providerCustomerId, providerProduct: subCtx.storedPlan.providerProduct!, + quantity: subCtx.quantity, }); await ctx.database.transaction(async (tx) => { @@ -871,6 +959,7 @@ async function handleLocalPlanSwitch( const providerResult = await ctx.provider.createSubscription({ providerCustomerId: subCtx.providerCustomerId, providerProduct: subCtx.storedPlan.providerProduct!, + quantity: subCtx.quantity, }); await ctx.database.transaction(async (tx) => { @@ -938,6 +1027,7 @@ async function handleCancelToFree( stripeSubscriptionId: providerResult.subscription.providerSubscriptionId, stripeSubscriptionScheduleId: providerResult.subscription.providerSubscriptionScheduleId ?? null, + quantity: providerResult.subscription.quantity, status: providerResult.subscription.status, subscriptionId: activeSubscription.id, }); @@ -966,6 +1056,7 @@ async function handleScheduledDowngrade( providerProduct: subCtx.storedPlan.providerProduct!, providerSubscriptionId: activeSubscriptionRef.subscriptionId, providerSubscriptionScheduleId: activeSubscriptionRef.subscriptionScheduleId, + quantity: subCtx.quantity, }); await ctx.database.transaction(async (tx) => { @@ -990,6 +1081,7 @@ async function handleScheduledDowngrade( stripeSubscriptionId: providerResult.subscription.providerSubscriptionId, stripeSubscriptionScheduleId: providerResult.subscription.providerSubscriptionScheduleId ?? null, + quantity: providerResult.subscription.quantity, status: providerResult.subscription.status, subscriptionId: activeSubscription.id, }); @@ -1017,6 +1109,7 @@ async function handleUpgrade( const providerResult = await ctx.provider.updateSubscription({ providerProduct: subCtx.storedPlan.providerProduct!, providerSubscriptionId: activeSubscriptionRef.subscriptionId, + quantity: subCtx.quantity, }); await ctx.database.transaction(async (tx) => { @@ -1061,6 +1154,7 @@ async function createCheckoutSubscribe( }, providerCustomerId: subCtx.providerCustomerId, providerProduct: subCtx.storedPlan.providerProduct!, + quantity: subCtx.quantity, successUrl: subCtx.successUrl, }); @@ -1079,6 +1173,7 @@ async function insertLocalTargetSubscription( customerId: subCtx.customerId, planFeatures: subCtx.planFeatures, productInternalId: subCtx.storedPlan.internalId, + quantity: subCtx.quantity, startedAt: input.startedAt, status: input.status, }); @@ -1106,6 +1201,7 @@ async function upsertProviderBackedTargetSubscription( currentPeriodStartAt: input.subscription.currentPeriodStartAt ?? null, stripeSubscriptionId: input.subscription.providerSubscriptionId, stripeSubscriptionScheduleId: input.subscription.providerSubscriptionScheduleId ?? null, + quantity: input.subscription.quantity ?? subCtx.quantity, status: input.subscription.status, subscriptionId: existingSub.id, }); @@ -1119,6 +1215,7 @@ async function upsertProviderBackedTargetSubscription( customerId: subCtx.customerId, planFeatures: subCtx.planFeatures, productInternalId: subCtx.storedPlan.internalId, + quantity: input.subscription.quantity ?? subCtx.quantity, startedAt: input.subscription.currentPeriodStartAt ?? new Date(), stripeSubscriptionId: input.subscription.providerSubscriptionId, stripeSubscriptionScheduleId: input.subscription.providerSubscriptionScheduleId ?? null, @@ -1349,6 +1446,7 @@ export async function insertSubscriptionRecord( currentPeriodStartAt?: Date | null; planFeatures: readonly NormalizedPlanFeature[]; productInternalId: string; + quantity?: number; scheduledProductId?: string | null; startedAt?: Date | null; stripeSubscriptionId?: string | null; @@ -1370,7 +1468,7 @@ export async function insertSubscriptionRecord( endedAt: null, id: generateId("sub"), productInternalId: input.productInternalId, - quantity: 1, + quantity: input.quantity ?? 1, scheduledProductId: input.scheduledProductId ?? null, startedAt: input.startedAt ?? now, stripeSubscriptionId: input.stripeSubscriptionId ?? null, @@ -1517,6 +1615,7 @@ export async function activateScheduledSubscription( subscriptionId: string; startedAt?: Date | null; status: string; + quantity?: number; stripeSubscriptionId?: string | null; stripeSubscriptionScheduleId?: string | null; }, @@ -1529,6 +1628,7 @@ export async function activateScheduledSubscription( currentPeriodEndAt: input.currentPeriodEndAt ?? null, currentPeriodStartAt: input.currentPeriodStartAt ?? null, endedAt: null, + ...(input.quantity !== undefined ? { quantity: input.quantity } : {}), startedAt: input.startedAt ?? new Date(), stripeSubscriptionId: input.stripeSubscriptionId ?? null, stripeSubscriptionScheduleId: input.stripeSubscriptionScheduleId ?? null, @@ -1589,6 +1689,9 @@ export async function syncSubscriptionFromProvider( currentPeriodEndAt: input.providerSubscription.currentPeriodEndAt ?? null, currentPeriodStartAt: input.providerSubscription.currentPeriodStartAt ?? null, endedAt, + ...(input.providerSubscription.quantity !== undefined + ? { quantity: input.providerSubscription.quantity } + : {}), status: input.providerSubscription.status, updatedAt: new Date(), }) @@ -1604,6 +1707,7 @@ export async function syncSubscriptionBillingState( startedAt?: Date | null; stripeSubscriptionId?: string | null; stripeSubscriptionScheduleId?: string | null; + quantity?: number | null; status?: string; }, ): Promise { @@ -1634,6 +1738,7 @@ export async function syncSubscriptionBillingState( input.stripeSubscriptionScheduleId !== undefined ? input.stripeSubscriptionScheduleId : existing.stripeSubscriptionScheduleId, + quantity: input.quantity != null ? input.quantity : existing.quantity, status: input.status ?? existing.status, updatedAt: new Date(), }) diff --git a/packages/paykit/src/subscription/subscription.types.ts b/packages/paykit/src/subscription/subscription.types.ts index a069976d..3c175d46 100644 --- a/packages/paykit/src/subscription/subscription.types.ts +++ b/packages/paykit/src/subscription/subscription.types.ts @@ -6,6 +6,7 @@ import type { StoredSubscription } from "../types/models"; export const subscribeBodySchema = z.object({ planId: z.string(), forceCheckout: z.boolean().optional(), + quantity: z.number().int().positive().optional(), successUrl: returnUrl(), cancelUrl: returnUrl().optional(), }); diff --git a/packages/paykit/src/types/events.ts b/packages/paykit/src/types/events.ts index a9289996..1b05b4d1 100644 --- a/packages/paykit/src/types/events.ts +++ b/packages/paykit/src/types/events.ts @@ -27,6 +27,7 @@ export interface NormalizedSubscription { providerProduct?: Record | null; providerSubscriptionId: string; providerSubscriptionScheduleId?: string | null; + quantity?: number; status: string; } From 671371aa5fa1c56bdecbfeefe818e5fb400d5bfb Mon Sep 17 00:00:00 2001 From: Pascoooo <162371632+Pascoooo@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:19:54 +0200 Subject: [PATCH 2/3] feat(paykit): harden subscription checkout sessions --- .changeset/checkout-session-hardening.md | 5 + packages/paykit/src/api/methods.ts | 3 +- packages/paykit/src/core/errors.ts | 4 + packages/paykit/src/index.ts | 8 +- packages/paykit/src/providers/provider.ts | 24 +++ .../stripe/__tests__/stripe-provider.test.ts | 158 ++++++++++++++++++ packages/paykit/src/stripe/stripe-provider.ts | 66 +++++++- .../__tests__/subscription.service.test.ts | 87 ++++++++++ .../__tests__/subscription.types.test.ts | 69 +++++--- .../src/subscription/subscription.api.ts | 28 +++- .../src/subscription/subscription.service.ts | 9 +- .../src/subscription/subscription.types.ts | 43 +++++ packages/paykit/src/types/instance.ts | 10 ++ 13 files changed, 490 insertions(+), 24 deletions(-) create mode 100644 .changeset/checkout-session-hardening.md diff --git a/.changeset/checkout-session-hardening.md b/.changeset/checkout-session-hardening.md new file mode 100644 index 00000000..efe68614 --- /dev/null +++ b/.changeset/checkout-session-hardening.md @@ -0,0 +1,5 @@ +--- +"paykitjs": minor +--- + +Add subscription Checkout hardening options, checkout session IDs in subscribe results, and a server-side method to expire Stripe Checkout Sessions with customer ownership checks. diff --git a/packages/paykit/src/api/methods.ts b/packages/paykit/src/api/methods.ts index 5e1370fc..34fda5ad 100644 --- a/packages/paykit/src/api/methods.ts +++ b/packages/paykit/src/api/methods.ts @@ -9,7 +9,7 @@ import { upsertCustomer, } from "../customer/customer.api"; import { check, report } from "../entitlement/entitlement.api"; -import { subscribe } from "../subscription/subscription.api"; +import { expireCheckoutSession, subscribe } from "../subscription/subscription.api"; import { advanceTestClock, getTestClock } from "../testing/testing.api"; import type { PayKitOptions } from "../types/options"; import { receiveWebhook } from "../webhook/webhook.api"; @@ -17,6 +17,7 @@ import type { PayKitMethod } from "./define-route"; export const baseMethods = { subscribe, + expireCheckoutSession, customerPortal, upsertCustomer, getCustomer, diff --git a/packages/paykit/src/core/errors.ts b/packages/paykit/src/core/errors.ts index 9637afd2..c82e71a7 100644 --- a/packages/paykit/src/core/errors.ts +++ b/packages/paykit/src/core/errors.ts @@ -21,6 +21,10 @@ export const PAYKIT_ERROR_CODES = defineErrorCodes({ PROVIDER_REQUIRED: "A provider is required", PROVIDER_INVALID_CONFIG: "Provider config is invalid", PROVIDER_CUSTOMER_NOT_FOUND: "Customer not found in provider", + PROVIDER_CHECKOUT_SESSION_CUSTOMER_MISMATCH: + "Provider checkout session does not belong to customer", + PROVIDER_CHECKOUT_SESSION_NOT_EXPIREABLE: "Provider checkout session cannot be expired", + PROVIDER_CHECKOUT_SESSION_NOT_FOUND: "Provider checkout session not found", PROVIDER_SESSION_INVALID: "Provider session did not include a URL", PROVIDER_SIGNATURE_MISSING: "Missing provider webhook signature", PROVIDER_SUBSCRIPTION_MISSING_ITEMS: "Provider subscription did not include any items", diff --git a/packages/paykit/src/index.ts b/packages/paykit/src/index.ts index bce8e5c5..b71db9b7 100644 --- a/packages/paykit/src/index.ts +++ b/packages/paykit/src/index.ts @@ -12,6 +12,8 @@ export type { PayKitClientGetTestClockInput, PayKitClientSubscribeInput, PayKitGetTestClockInput, + PayKitExpireCheckoutSessionInput, + PayKitExpireCheckoutSessionResult, PayKitInstance, PayKitCheckInput, PayKitCustomerPortalInput, @@ -19,7 +21,11 @@ export type { PayKitSubscribeInput, PayKitSubscribeResult, } from "./types/instance"; -export type { SubscribeResult } from "./subscription/subscription.types"; +export type { + ExpireCheckoutSessionResult, + SubscribeResult, + SubscriptionCheckoutOptions, +} from "./subscription/subscription.types"; export type { CheckResult, EntitlementBalance, diff --git a/packages/paykit/src/providers/provider.ts b/packages/paykit/src/providers/provider.ts index e88138b5..35f46e42 100644 --- a/packages/paykit/src/providers/provider.ts +++ b/packages/paykit/src/providers/provider.ts @@ -76,6 +76,24 @@ export interface ProviderSubscriptionResult { subscription?: ProviderSubscription | null; } +export interface ProviderSubscriptionCheckoutOptions { + allowPromotionCodes?: boolean; + automaticTax?: { + enabled: boolean; + }; + billingAddressCollection?: "auto" | "required"; + customerUpdate?: { + address?: "auto" | "never"; + name?: "auto" | "never"; + shipping?: "auto" | "never"; + }; + idempotencyKey?: string; + taxIdCollection?: { + enabled: boolean; + required?: "if_supported" | "never"; + }; +} + export interface PaymentProvider { readonly id: string; readonly name: string; @@ -107,6 +125,7 @@ export interface PaymentProvider { }): Promise<{ url: string }>; createSubscriptionCheckout(data: { + checkout?: ProviderSubscriptionCheckoutOptions; providerCustomerId: string; providerProduct: Record; successUrl: string; @@ -121,6 +140,11 @@ export interface PaymentProvider { quantity: number; }): Promise; + expireCheckoutSession(data: { + providerCheckoutSessionId: string; + providerCustomerId: string; + }): Promise<{ providerCheckoutSessionId: string; status: "expired" }>; + updateSubscription(data: { providerProduct: Record; providerSubscriptionId: string; diff --git a/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts b/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts index 969373b1..9423b407 100644 --- a/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts +++ b/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts @@ -31,6 +31,13 @@ function createStripeClientMock() { checkout: { sessions: { create: vi.fn().mockResolvedValue({ id: "cs_123", url: "https://checkout.test/session" }), + expire: vi.fn().mockResolvedValue({ id: "cs_123", status: "expired" }), + retrieve: vi.fn().mockResolvedValue({ + client_reference_id: "cus_123", + customer: "cus_123", + id: "cs_123", + status: "open", + }), }, }, invoices: { @@ -132,6 +139,157 @@ describe("stripe-provider", () => { ); }); + it("passes checkout hardening options to Stripe Checkout", async () => { + const client = createStripeClientMock(); + const provider = createStripeProvider(client as never, { + currency: "eur", + secretKey: "sk_test_123", + }); + + await provider.createSubscriptionCheckout({ + checkout: { + allowPromotionCodes: true, + automaticTax: { enabled: true }, + billingAddressCollection: "required", + customerUpdate: { + address: "auto", + name: "auto", + shipping: "never", + }, + idempotencyKey: "checkout_123", + taxIdCollection: { + enabled: true, + required: "if_supported", + }, + }, + metadata: { + paykit_customer_id: "cust_123", + paykit_intent: "subscribe", + }, + providerCustomerId: "cus_123", + providerProduct: { priceId: "price_123", productId: "prod_123" }, + quantity: 3, + successUrl: "https://app.test/success", + }); + + expect(client.checkout.sessions.create).toHaveBeenCalledWith( + expect.objectContaining({ + allow_promotion_codes: true, + automatic_tax: { enabled: true }, + billing_address_collection: "required", + customer_update: { + address: "auto", + name: "auto", + shipping: "never", + }, + subscription_data: { + metadata: { + paykit_customer_id: "cust_123", + paykit_intent: "subscribe", + }, + }, + tax_id_collection: { + enabled: true, + required: "if_supported", + }, + }), + { idempotencyKey: "checkout_123" }, + ); + }); + + it("expires open checkout sessions that belong to the provider customer", async () => { + const client = createStripeClientMock(); + const provider = createStripeProvider(client as never, { + currency: "eur", + secretKey: "sk_test_123", + }); + + const result = await provider.expireCheckoutSession({ + providerCheckoutSessionId: "cs_123", + providerCustomerId: "cus_123", + }); + + expect(client.checkout.sessions.retrieve).toHaveBeenCalledWith("cs_123"); + expect(client.checkout.sessions.expire).toHaveBeenCalledWith("cs_123"); + expect(result).toEqual({ + providerCheckoutSessionId: "cs_123", + status: "expired", + }); + }); + + it("treats already expired checkout sessions as idempotent success", async () => { + const client = createStripeClientMock(); + client.checkout.sessions.retrieve.mockResolvedValueOnce({ + client_reference_id: "cus_123", + customer: "cus_123", + id: "cs_123", + status: "expired", + }); + const provider = createStripeProvider(client as never, { + currency: "eur", + secretKey: "sk_test_123", + }); + + const result = await provider.expireCheckoutSession({ + providerCheckoutSessionId: "cs_123", + providerCustomerId: "cus_123", + }); + + expect(client.checkout.sessions.expire).not.toHaveBeenCalled(); + expect(result).toEqual({ + providerCheckoutSessionId: "cs_123", + status: "expired", + }); + }); + + it("blocks checkout session expiration when customer ownership does not match", async () => { + const client = createStripeClientMock(); + client.checkout.sessions.retrieve.mockResolvedValueOnce({ + client_reference_id: "cus_other", + customer: "cus_other", + id: "cs_123", + status: "open", + }); + const provider = createStripeProvider(client as never, { + currency: "eur", + secretKey: "sk_test_123", + }); + + await expect( + provider.expireCheckoutSession({ + providerCheckoutSessionId: "cs_123", + providerCustomerId: "cus_123", + }), + ).rejects.toMatchObject({ + code: "PROVIDER_CHECKOUT_SESSION_CUSTOMER_MISMATCH", + }); + expect(client.checkout.sessions.expire).not.toHaveBeenCalled(); + }); + + it("blocks checkout session expiration after completion", async () => { + const client = createStripeClientMock(); + client.checkout.sessions.retrieve.mockResolvedValueOnce({ + client_reference_id: "cus_123", + customer: "cus_123", + id: "cs_123", + status: "complete", + }); + const provider = createStripeProvider(client as never, { + currency: "eur", + secretKey: "sk_test_123", + }); + + await expect( + provider.expireCheckoutSession({ + providerCheckoutSessionId: "cs_123", + providerCustomerId: "cus_123", + }), + ).rejects.toMatchObject({ + code: "PROVIDER_CHECKOUT_SESSION_NOT_EXPIREABLE", + }); + expect(client.checkout.sessions.expire).not.toHaveBeenCalled(); + }); + it("passes quantity to direct Stripe subscription creation", async () => { const client = createStripeClientMock(); const provider = createStripeProvider(client as never, { diff --git a/packages/paykit/src/stripe/stripe-provider.ts b/packages/paykit/src/stripe/stripe-provider.ts index dbc61739..43b71d40 100644 --- a/packages/paykit/src/stripe/stripe-provider.ts +++ b/packages/paykit/src/stripe/stripe-provider.ts @@ -684,18 +684,29 @@ export function createStripeProvider( const sessionParams: StripeSdk.Checkout.SessionCreateParams & { managed_payments?: { enabled: boolean }; } = { + allow_promotion_codes: data.checkout?.allowPromotionCodes, + automatic_tax: data.checkout?.automaticTax, + billing_address_collection: data.checkout?.billingAddressCollection, cancel_url: data.cancelUrl ?? data.successUrl, client_reference_id: data.providerCustomerId, customer: data.providerCustomerId, + customer_update: data.checkout?.customerUpdate, line_items: [{ price: data.providerProduct.priceId, quantity: data.quantity }], metadata: data.metadata, mode: "subscription", + subscription_data: data.metadata ? { metadata: data.metadata } : undefined, success_url: data.successUrl, + tax_id_collection: data.checkout?.taxIdCollection, }; if (options.managedPayments) { sessionParams.managed_payments = { enabled: true }; } - const session = await client.checkout.sessions.create(sessionParams); + const requestOptions = data.checkout?.idempotencyKey + ? { idempotencyKey: data.checkout.idempotencyKey } + : undefined; + const session = requestOptions + ? await client.checkout.sessions.create(sessionParams, requestOptions) + : await client.checkout.sessions.create(sessionParams); if (!session.url) { throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SESSION_INVALID); @@ -707,6 +718,59 @@ export function createStripeProvider( }; }, + async expireCheckoutSession(data) { + let session: StripeSdk.Checkout.Session; + try { + session = await client.checkout.sessions.retrieve(data.providerCheckoutSessionId); + } catch (error) { + if (isStripeResourceMissingError(error)) { + throw PayKitError.from( + "NOT_FOUND", + PAYKIT_ERROR_CODES.PROVIDER_CHECKOUT_SESSION_NOT_FOUND, + ); + } + throw error; + } + + const sessionCustomerId = getStripeCustomerId(session.customer); + const isCustomerSession = + sessionCustomerId === data.providerCustomerId || + session.client_reference_id === data.providerCustomerId; + if (!isCustomerSession) { + throw PayKitError.from( + "FORBIDDEN", + PAYKIT_ERROR_CODES.PROVIDER_CHECKOUT_SESSION_CUSTOMER_MISMATCH, + ); + } + + if (session.status === "expired") { + return { + providerCheckoutSessionId: session.id, + status: "expired", + }; + } + + if (session.status !== "open") { + throw PayKitError.from( + "BAD_REQUEST", + PAYKIT_ERROR_CODES.PROVIDER_CHECKOUT_SESSION_NOT_EXPIREABLE, + ); + } + + const expiredSession = await client.checkout.sessions.expire(data.providerCheckoutSessionId); + if (expiredSession.status !== "expired") { + throw PayKitError.from( + "BAD_REQUEST", + PAYKIT_ERROR_CODES.PROVIDER_CHECKOUT_SESSION_NOT_EXPIREABLE, + ); + } + + return { + providerCheckoutSessionId: expiredSession.id, + status: "expired", + }; + }, + async createSubscription(data) { const createParams: StripeSdk.SubscriptionCreateParams = { customer: data.providerCustomerId, diff --git a/packages/paykit/src/subscription/__tests__/subscription.service.test.ts b/packages/paykit/src/subscription/__tests__/subscription.service.test.ts index 26b12fdf..2bbb7e6a 100644 --- a/packages/paykit/src/subscription/__tests__/subscription.service.test.ts +++ b/packages/paykit/src/subscription/__tests__/subscription.service.test.ts @@ -157,6 +157,64 @@ function createSamePlanSubscribeContext() { return { ctx, provider }; } +function createInitialCheckoutSubscribeContext() { + const product = createProductRow(); + const selectResults = [ + createSelectChain([], "where"), + createSelectChain([], "limit"), + createSelectChain([], "orderBy"), + ]; + const database = { + select: vi.fn(() => selectResults.shift()), + } as unknown as PayKitDatabase; + const provider = { + id: "stripe", + name: "Stripe", + createSubscriptionCheckout: vi.fn().mockResolvedValue({ + paymentUrl: "https://checkout.test/session", + providerCheckoutSessionId: "cs_123", + }), + }; + const trace = vi.fn() as unknown as PayKitContext["logger"]["trace"]; + trace.run = (_prefix, callback) => callback(); + const ctx = { + database, + logger: { info: vi.fn(), trace, warn: vi.fn() }, + options: {}, + products: { + planMap: new Map([ + [ + "restaurant-live", + { + group: "default", + hash: "hash_123", + id: "restaurant-live", + includes: [], + isDefault: false, + name: "Restaurant Live", + price: { amount: 900, currency: "eur", interval: "month" }, + }, + ], + ]), + }, + provider, + } as unknown as PayKitContext; + + mocks.customer.upsertProviderCustomer.mockResolvedValue({ + customerId: "cust_123", + providerCustomer: { id: "cus_123" }, + providerCustomerId: "cus_123", + }); + mocks.paymentMethod.getDefaultPaymentMethod.mockResolvedValue(null); + mocks.product.getProductByPlan.mockResolvedValue(product); + mocks.product.withProviderInfo.mockReturnValue({ + ...product, + providerProduct: { priceId: "price_123", productId: "prod_123" }, + }); + + return { ctx, provider }; +} + describe("subscription/service", () => { beforeEach(() => { vi.clearAllMocks(); @@ -175,6 +233,35 @@ describe("subscription/service", () => { expect(provider.resumeSubscription).not.toHaveBeenCalled(); }); + it("passes checkout options to provider checkout and returns checkout session id", async () => { + const { ctx, provider } = createInitialCheckoutSubscribeContext(); + + const result = await subscribeToPlan(ctx, { + checkout: { + automaticTax: { enabled: true }, + idempotencyKey: "checkout_123", + }, + customerId: "cust_123", + planId: "restaurant-live", + quantity: 2, + successUrl: "https://app.example.com/success", + }); + + expect(provider.createSubscriptionCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + checkout: { + automaticTax: { enabled: true }, + idempotencyKey: "checkout_123", + }, + quantity: 2, + }), + ); + expect(result).toMatchObject({ + checkoutSessionId: "cs_123", + paymentUrl: "https://checkout.test/session", + }); + }); + it("syncs provider subscription quantity into the local subscription", async () => { const update = createUpdateChain(); const database = { diff --git a/packages/paykit/src/subscription/__tests__/subscription.types.test.ts b/packages/paykit/src/subscription/__tests__/subscription.types.test.ts index 30f071d7..fead95a2 100644 --- a/packages/paykit/src/subscription/__tests__/subscription.types.test.ts +++ b/packages/paykit/src/subscription/__tests__/subscription.types.test.ts @@ -2,31 +2,62 @@ import { describe, expect, it } from "vitest"; import { subscribeBodySchema } from "../subscription.types"; -const validSubscribeBody = { +const validSubscribeInput = { planId: "restaurant-live", - successUrl: "https://app.example.com/billing/success", + successUrl: "https://app.example.com/success", }; -describe("subscribeBodySchema", () => { - it("accepts missing quantity for the default quantity path", () => { - expect(subscribeBodySchema.safeParse(validSubscribeBody).success).toBe(true); +describe("subscription/types", () => { + it("accepts checkout hardening options", () => { + const result = subscribeBodySchema.safeParse({ + ...validSubscribeInput, + checkout: { + allowPromotionCodes: true, + automaticTax: { enabled: true }, + billingAddressCollection: "required", + customerUpdate: { + address: "auto", + name: "auto", + shipping: "never", + }, + idempotencyKey: "checkout_123", + taxIdCollection: { + enabled: true, + required: "if_supported", + }, + }, + }); + + expect(result.success).toBe(true); }); - it("accepts a positive integer quantity", () => { - expect(subscribeBodySchema.safeParse({ ...validSubscribeBody, quantity: 3 }).success).toBe( - true, - ); + it("rejects invalid checkout idempotency keys", () => { + expect( + subscribeBodySchema.safeParse({ + ...validSubscribeInput, + checkout: { idempotencyKey: "" }, + }).success, + ).toBe(false); + expect( + subscribeBodySchema.safeParse({ + ...validSubscribeInput, + checkout: { idempotencyKey: "x".repeat(256) }, + }).success, + ).toBe(false); }); - it("rejects zero, negative, and decimal quantities", () => { - expect(subscribeBodySchema.safeParse({ ...validSubscribeBody, quantity: 0 }).success).toBe( - false, - ); - expect(subscribeBodySchema.safeParse({ ...validSubscribeBody, quantity: -1 }).success).toBe( - false, - ); - expect(subscribeBodySchema.safeParse({ ...validSubscribeBody, quantity: 1.5 }).success).toBe( - false, - ); + it("rejects invalid checkout option enums", () => { + expect( + subscribeBodySchema.safeParse({ + ...validSubscribeInput, + checkout: { billingAddressCollection: "always" }, + }).success, + ).toBe(false); + expect( + subscribeBodySchema.safeParse({ + ...validSubscribeInput, + checkout: { taxIdCollection: { enabled: true, required: "always" } }, + }).success, + ).toBe(false); }); }); diff --git a/packages/paykit/src/subscription/subscription.api.ts b/packages/paykit/src/subscription/subscription.api.ts index 83ae1207..f81c44dc 100644 --- a/packages/paykit/src/subscription/subscription.api.ts +++ b/packages/paykit/src/subscription/subscription.api.ts @@ -1,6 +1,7 @@ import { definePayKitMethod } from "../api/define-route"; +import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors"; import { subscribeToPlan } from "./subscription.service"; -import { subscribeBodySchema } from "./subscription.types"; +import { expireCheckoutSessionBodySchema, subscribeBodySchema } from "./subscription.types"; /** Applies a subscription change for the resolved customer. */ export const subscribe = definePayKitMethod( @@ -16,6 +17,7 @@ export const subscribe = definePayKitMethod( async (ctx) => { return subscribeToPlan(ctx.paykit, { customerId: ctx.customer.id, + checkout: ctx.input.checkout, forceCheckout: ctx.input.forceCheckout, planId: ctx.input.planId, quantity: ctx.input.quantity, @@ -24,3 +26,27 @@ export const subscribe = definePayKitMethod( }); }, ); + +/** Expires a provider checkout session after verifying it belongs to the resolved customer. */ +export const expireCheckoutSession = definePayKitMethod( + { + input: expireCheckoutSessionBodySchema, + requireCustomer: true, + }, + async (ctx) => { + const providerCustomerId = ctx.customer.stripeCustomerId; + if (!providerCustomerId) { + throw PayKitError.from("NOT_FOUND", PAYKIT_ERROR_CODES.PROVIDER_CUSTOMER_NOT_FOUND); + } + + const result = await ctx.paykit.provider.expireCheckoutSession({ + providerCheckoutSessionId: ctx.input.checkoutSessionId, + providerCustomerId, + }); + + return { + checkoutSessionId: result.providerCheckoutSessionId, + status: result.status, + }; + }, +); diff --git a/packages/paykit/src/subscription/subscription.service.ts b/packages/paykit/src/subscription/subscription.service.ts index 27a12d7c..e8fd05f5 100644 --- a/packages/paykit/src/subscription/subscription.service.ts +++ b/packages/paykit/src/subscription/subscription.service.ts @@ -177,6 +177,7 @@ export async function loadSubscribeContext(ctx: PayKitContext, input: SubscribeI return { activeSubscription, cancelUrl: input.cancelUrl, + checkout: input.checkout, customerId: input.customerId, isFreeTarget, isPaidTarget, @@ -198,6 +199,7 @@ type SubscribeContext = Awaited>; type ActiveSubscription = Awaited>; function buildSubscribeResult(input: { + checkoutSessionId?: string; invoice?: { currency: string; hostedUrl?: string | null; @@ -211,6 +213,7 @@ function buildSubscribeResult(input: { requiredAction?: ProviderRequiredAction | null; }): SubscribeResult { return { + checkoutSessionId: input.checkoutSessionId, invoice: input.invoice ? { currency: input.invoice.currency, @@ -1146,6 +1149,7 @@ async function createCheckoutSubscribe( ): Promise { const checkoutResult = await ctx.provider.createSubscriptionCheckout({ cancelUrl: subCtx.cancelUrl, + checkout: subCtx.checkout, metadata: { paykit_customer_id: subCtx.customerId, paykit_intent: "subscribe", @@ -1158,7 +1162,10 @@ async function createCheckoutSubscribe( successUrl: subCtx.successUrl, }); - return buildSubscribeResult({ paymentUrl: checkoutResult.paymentUrl }); + return buildSubscribeResult({ + checkoutSessionId: checkoutResult.providerCheckoutSessionId, + paymentUrl: checkoutResult.paymentUrl, + }); } async function insertLocalTargetSubscription( diff --git a/packages/paykit/src/subscription/subscription.types.ts b/packages/paykit/src/subscription/subscription.types.ts index 3c175d46..41517152 100644 --- a/packages/paykit/src/subscription/subscription.types.ts +++ b/packages/paykit/src/subscription/subscription.types.ts @@ -3,22 +3,65 @@ import * as z from "zod"; import { returnUrl } from "../api/define-route"; import type { StoredSubscription } from "../types/models"; +const checkoutIdempotencyKeySchema = z.string().min(1).max(255); +const checkoutCustomerUpdateValueSchema = z.enum(["auto", "never"]); + +export const subscriptionCheckoutOptionsSchema = z.object({ + allowPromotionCodes: z.boolean().optional(), + automaticTax: z + .object({ + enabled: z.boolean(), + }) + .optional(), + billingAddressCollection: z.enum(["auto", "required"]).optional(), + customerUpdate: z + .object({ + address: checkoutCustomerUpdateValueSchema.optional(), + name: checkoutCustomerUpdateValueSchema.optional(), + shipping: checkoutCustomerUpdateValueSchema.optional(), + }) + .optional(), + idempotencyKey: checkoutIdempotencyKeySchema.optional(), + taxIdCollection: z + .object({ + enabled: z.boolean(), + required: z.enum(["never", "if_supported"]).optional(), + }) + .optional(), +}); + export const subscribeBodySchema = z.object({ planId: z.string(), + checkout: subscriptionCheckoutOptionsSchema.optional(), forceCheckout: z.boolean().optional(), quantity: z.number().int().positive().optional(), successUrl: returnUrl(), cancelUrl: returnUrl().optional(), }); +export const expireCheckoutSessionBodySchema = z.object({ + checkoutSessionId: z.string().min(1), +}); + export type SubscribeBody = z.infer; +export type SubscriptionCheckoutOptions = z.infer; export type SubscribeInput = SubscribeBody & { customerId: string; productInternalId?: string; }; +export type ExpireCheckoutSessionInput = z.infer & { + customerId: string; +}; + +export interface ExpireCheckoutSessionResult { + checkoutSessionId: string; + status: "expired"; +} + export interface SubscribeResult { + checkoutSessionId?: string; invoice?: { currency: string; hostedUrl: string | null; diff --git a/packages/paykit/src/types/instance.ts b/packages/paykit/src/types/instance.ts index 4679d26a..cd079da6 100644 --- a/packages/paykit/src/types/instance.ts +++ b/packages/paykit/src/types/instance.ts @@ -95,6 +95,16 @@ export type PayKitSubscribeInput export type PayKitSubscribeResult = InferMethodResult; +export type PayKitExpireCheckoutSessionInput = RefineServerMethodInput< + PayKitOptions, + "expireCheckoutSession", + InferMethodInput +>; + +export type PayKitExpireCheckoutSessionResult = InferMethodResult< + RegisteredMethods["expireCheckoutSession"] +>; + export type PayKitCustomerInput = InferMethodInput; export type PayKitClientCustomerPortalInput = RefineClientMethodInput< From 0dc710c3df441e8e34278c4623f967b0787afe2c Mon Sep 17 00:00:00 2001 From: Pascoooo <162371632+Pascoooo@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:01:13 +0200 Subject: [PATCH 3/3] fix(paykit): avoid top-level eval during runtime init --- packages/paykit/src/core/create-paykit.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/paykit/src/core/create-paykit.ts b/packages/paykit/src/core/create-paykit.ts index 9b2bd7fa..c72ce1e4 100644 --- a/packages/paykit/src/core/create-paykit.ts +++ b/packages/paykit/src/core/create-paykit.ts @@ -20,15 +20,19 @@ export function isPayKitInstance(value: unknown): value is PayKitInstance { } const _global = globalThis as unknown as { __paykitDevChecksRan?: boolean }; -const dynamicImport = new Function("specifier", "return import(specifier)") as ( - specifier: string, -) => Promise; + +function hiddenDynamicImport(specifier: string): Promise { + const dynamicImport = new Function("specifier", "return import(specifier)") as ( + specifier: string, + ) => Promise; + return dynamicImport(specifier); +} async function runDevChecks(ctx: PayKitContext): Promise { if (_global.__paykitDevChecksRan) return; _global.__paykitDevChecksRan = true; if (process.env.PAYKIT_DISABLE_DEPENDENCY_CHECKER !== "1") { - const { checkPayKitDependencies } = (await dynamicImport( + const { checkPayKitDependencies } = (await hiddenDynamicImport( ["..", "utilities", "dependencies", "index.js"].join("/"), )) as { checkPayKitDependencies: () => Promise;