Skip to content
Closed
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 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
135 changes: 135 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,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" }),
Expand All @@ -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: [] }),
},
};
}

Expand Down Expand Up @@ -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);
});
});
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?.quantity ?? 1,
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";

import type { PayKitDatabase } from "../../database";
import {
syncSubscriptionBillingState,
syncSubscriptionFromProvider,
} from "../subscription.service";

function createUpdateChain() {
const where = vi.fn().mockResolvedValue(undefined);
const set = vi.fn().mockReturnValue({ where });
return { set, where };
}

describe("subscription/service", () => {
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 }));
});
});
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
1 change: 1 addition & 0 deletions packages/paykit/src/subscription/subscription.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
Loading