Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/subscription-quantity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"paykitjs": minor
---

Add subscription quantity support to subscribe flows and Stripe provider sync.
5 changes: 5 additions & 0 deletions packages/paykit/src/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface ProviderSubscription {
endedAt?: Date | null;
providerSubscriptionId: string;
providerSubscriptionScheduleId?: string | null;
quantity?: number;
status: string;
}

Expand Down Expand Up @@ -111,16 +112,19 @@ export interface PaymentProvider {
successUrl: string;
cancelUrl?: string;
metadata?: Record<string, string>;
quantity: number;
}): Promise<{ paymentUrl: string; providerCheckoutSessionId: string }>;

createSubscription(data: {
providerCustomerId: string;
providerProduct: Record<string, string>;
quantity: number;
}): Promise<ProviderSubscriptionResult>;

updateSubscription(data: {
providerProduct: Record<string, string>;
providerSubscriptionId: string;
quantity: number;
}): Promise<ProviderSubscriptionResult>;

createInvoice(data: {
Expand All @@ -133,6 +137,7 @@ export interface PaymentProvider {
providerProduct?: Record<string, string> | null;
providerSubscriptionScheduleId?: string | null;
providerSubscriptionId: string;
quantity: number;
}): Promise<ProviderSubscriptionResult>;

cancelSubscription(data: {
Expand Down
161 changes: 161 additions & 0 deletions packages/paykit/src/stripe/__tests__/stripe-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,46 @@ 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 createStripeSubscriptionWithoutItems() {
return {
...createStripeSubscription(),
items: {
data: [],
},
};
}

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" }),
Expand All @@ -20,6 +58,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: [] }),
},
};
}

Expand Down Expand Up @@ -71,4 +119,117 @@ 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("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
.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);
});
});
18 changes: 11 additions & 7 deletions packages/paykit/src/stripe/stripe-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras)
(typeof subscription.schedule === "string"
? subscription.schedule
: subscription.schedule?.id) ?? null,
quantity: firstItem ? (firstItem.quantity ?? 1) : undefined,
status: subscription.status,
};
}
Expand Down Expand Up @@ -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,
Expand All @@ -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"],
};
Expand Down Expand Up @@ -753,6 +754,7 @@ export function createStripeProvider(
{
id: currentItem.id,
price: data.providerProduct.priceId,
quantity: data.quantity,
},
],
payment_behavior: "pending_if_incomplete",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
},
],
Expand Down
Loading