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/2] 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 f741c6960825fba947ade4128c9e7a269900fba8 Mon Sep 17 00:00:00 2001 From: Pascoooo <162371632+Pascoooo@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:30:40 +0200 Subject: [PATCH 2/2] fix(stripe): preserve missing subscription quantity --- .../stripe/__tests__/stripe-provider.test.ts | 26 +++++++++++++++++++ packages/paykit/src/stripe/stripe-provider.ts | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts b/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts index 969373b1..2105b185 100644 --- a/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts +++ b/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts @@ -26,6 +26,15 @@ function createStripeSubscription(quantity = 1) { }; } +function createStripeSubscriptionWithoutItems() { + return { + ...createStripeSubscription(), + items: { + data: [], + }, + }; +} + function createStripeClientMock() { return { checkout: { @@ -175,6 +184,23 @@ describe("stripe-provider", () => { expect(result.subscription?.quantity).toBe(4); }); + it("does not synthesize quantity when a Stripe subscription has no items", async () => { + const client = createStripeClientMock(); + client.subscriptions.update.mockResolvedValue(createStripeSubscriptionWithoutItems()); + 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(result.subscription?.quantity).toBeUndefined(); + }); + it("preserves current quantity and applies target quantity for scheduled changes", async () => { const client = createStripeClientMock(); client.subscriptions.retrieve diff --git a/packages/paykit/src/stripe/stripe-provider.ts b/packages/paykit/src/stripe/stripe-provider.ts index dbc61739..c54549ef 100644 --- a/packages/paykit/src/stripe/stripe-provider.ts +++ b/packages/paykit/src/stripe/stripe-provider.ts @@ -189,7 +189,7 @@ function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras) (typeof subscription.schedule === "string" ? subscription.schedule : subscription.schedule?.id) ?? null, - quantity: firstItem?.quantity ?? 1, + quantity: firstItem ? (firstItem.quantity ?? 1) : undefined, status: subscription.status, }; }