From 29d3cea5fbe308c0c382b487ec90c5da5a9a5a1c Mon Sep 17 00:00:00 2001 From: almeida Date: Mon, 18 May 2026 09:37:50 -0300 Subject: [PATCH 1/4] feat(paykit): pass customer details into checkout providers --- packages/paykit/src/providers/provider.ts | 6 ++++++ packages/paykit/src/subscription/subscription.service.ts | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/packages/paykit/src/providers/provider.ts b/packages/paykit/src/providers/provider.ts index 73a5a0c9..7b34c2ab 100644 --- a/packages/paykit/src/providers/provider.ts +++ b/packages/paykit/src/providers/provider.ts @@ -60,6 +60,11 @@ export interface ProviderRequiredAction { type: string; } +export interface ProviderCheckoutCustomer { + email?: string; + name?: string; +} + export interface ProviderSubscription { cancelAtPeriodEnd: boolean; canceledAt?: Date | null; @@ -111,6 +116,7 @@ export interface PaymentProvider { }): Promise<{ url: string }>; createSubscriptionCheckout(data: { + customer?: ProviderCheckoutCustomer; providerCustomerId: string; providerProduct: Record; successUrl: string; diff --git a/packages/paykit/src/subscription/subscription.service.ts b/packages/paykit/src/subscription/subscription.service.ts index ba4c28e3..16b11b1f 100644 --- a/packages/paykit/src/subscription/subscription.service.ts +++ b/packages/paykit/src/subscription/subscription.service.ts @@ -4,6 +4,7 @@ import type { PayKitContext } from "../core/context"; import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors"; import { generateId } from "../core/utils"; import { + getCustomerByIdOrThrow, findCustomerByProviderCustomerId, upsertProviderCustomer, } from "../customer/customer.service"; @@ -124,8 +125,10 @@ export async function loadSubscribeContext(ctx: PayKitContext, input: SubscribeI await warnOnDuplicateActiveSubscriptionGroups(ctx, input.customerId); + const customer = await getCustomerByIdOrThrow(ctx.database, input.customerId); const { providerCustomerId } = await upsertProviderCustomer(ctx, { customerId: input.customerId, + customerRow: customer, }); const hasDefaultPaymentMethod = (await getDefaultPaymentMethod(ctx.database, { @@ -160,6 +163,10 @@ export async function loadSubscribeContext(ctx: PayKitContext, input: SubscribeI return { activeSubscription, cancelUrl: input.cancelUrl, + customer: { + email: customer.email ?? undefined, + name: customer.name ?? undefined, + }, customerId: input.customerId, isFreeTarget, isPaidTarget, @@ -1068,6 +1075,7 @@ async function createCheckoutSubscribe( ): Promise { const checkoutResult = await ctx.provider.createSubscriptionCheckout({ cancelUrl: subCtx.cancelUrl, + customer: subCtx.customer, metadata: { paykit_customer_id: subCtx.customerId, paykit_intent: "subscribe", From 364bff26f28ae1c6666a76461b973f9d2ff7d11a Mon Sep 17 00:00:00 2001 From: almeida Date: Mon, 18 May 2026 09:37:55 -0300 Subject: [PATCH 2/4] test(paykit): cover checkout customer propagation --- .../__tests__/subscription.service.test.ts | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 packages/paykit/src/subscription/__tests__/subscription.service.test.ts 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..332bd78e --- /dev/null +++ b/packages/paykit/src/subscription/__tests__/subscription.service.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PayKitContext } from "../../core/context"; +import type { Customer } from "../../types/models"; +import type { NormalizedSchema } from "../../types/schema"; + +const { + getCustomerByIdOrThrow, + upsertProviderCustomer, + getDefaultPaymentMethod, + getProductByHash, + getProductByInternalId, + getProductFeatures, + getDefaultProductInGroup, + getProductByProviderData, + withProviderInfo, +} = vi.hoisted(() => ({ + getCustomerByIdOrThrow: vi.fn(), + upsertProviderCustomer: vi.fn(), + getDefaultPaymentMethod: vi.fn(), + getProductByHash: vi.fn(), + getProductByInternalId: vi.fn(), + getProductFeatures: vi.fn(), + getDefaultProductInGroup: vi.fn(), + getProductByProviderData: vi.fn(), + withProviderInfo: vi.fn(), +})); + +vi.mock("../../customer/customer.service", () => ({ + findCustomerByProviderCustomerId: vi.fn(), + getCustomerByIdOrThrow, + upsertProviderCustomer, +})); + +vi.mock("../../payment-method/payment-method.service", () => ({ + getDefaultPaymentMethod, +})); + +vi.mock("../../product/product.service", () => ({ + getDefaultProductInGroup, + getProductByHash, + getProductByInternalId, + getProductByProviderData, + getProductFeatures, + withProviderInfo, +})); + +import { subscribeToPlan } from "../subscription.service"; + +const emptyProducts: NormalizedSchema = { + features: [], + plans: [], + planMap: new Map(), +}; + +function createCustomerRow(overrides: Partial = {}): Customer { + const now = new Date("2024-01-01T00:00:00.000Z"); + + return { + createdAt: now, + deletedAt: null, + email: null, + id: "customer_123", + metadata: null, + name: null, + provider: {}, + updatedAt: now, + ...overrides, + }; +} + +function createSelectChain(result: unknown, terminalMethod: "where" | "orderBy" | "limit") { + const chain: Record = { + from: vi.fn(), + innerJoin: vi.fn(), + where: vi.fn(), + orderBy: vi.fn(), + limit: 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; +} + +describe("subscription/service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes customer details into subscription checkout", async () => { + const customer = createCustomerRow({ + email: "billing@example.com", + name: "Billing User", + }); + const storedPlan = { + group: "default", + id: "pro", + internalId: "product_internal_123", + priceAmount: 1900, + priceInterval: "month", + providerProduct: { priceId: "price_123" }, + }; + const createSubscriptionCheckout = vi.fn().mockResolvedValue({ + paymentUrl: "https://checkout.example.com/session", + providerCheckoutSessionId: "cs_123", + }); + const warningSelect = createSelectChain([], "where"); + const activeSubscriptionSelect = createSelectChain([], "limit"); + const scheduledSubscriptionSelect = createSelectChain([], "orderBy"); + + getCustomerByIdOrThrow.mockResolvedValue(customer); + upsertProviderCustomer.mockResolvedValue({ + customerId: "customer_123", + providerCustomer: { id: "cus_123" }, + providerCustomerId: "cus_123", + }); + getDefaultPaymentMethod.mockResolvedValue(null); + getProductByHash.mockResolvedValue({ id: "pro" }); + withProviderInfo.mockReturnValue(storedPlan); + + const ctx = { + database: { + select: vi + .fn() + .mockReturnValueOnce(warningSelect) + .mockReturnValueOnce(activeSubscriptionSelect) + .mockReturnValueOnce(scheduledSubscriptionSelect), + }, + logger: { + info: vi.fn(), + trace: { + run: vi.fn().mockImplementation(async (_label, fn) => fn()), + }, + warn: vi.fn(), + }, + options: { + provider: { + createAdapter: vi.fn(), + id: "stripe", + name: "Stripe", + }, + }, + products: { + ...emptyProducts, + planMap: new Map([ + [ + "pro", + { + hash: "plan_hash_123", + id: "pro", + includes: [], + }, + ], + ]), + }, + provider: { + createSubscription: vi.fn(), + createSubscriptionCheckout, + id: "stripe", + name: "Stripe", + }, + } as unknown as PayKitContext; + + const result = await subscribeToPlan(ctx, { + customerId: "customer_123", + forceCheckout: true, + planId: "pro", + successUrl: "https://example.com/success", + }); + + expect(result).toEqual({ + paymentUrl: "https://checkout.example.com/session", + requiredAction: null, + }); + expect(createSubscriptionCheckout).toHaveBeenCalledWith({ + cancelUrl: undefined, + customer: { + email: "billing@example.com", + name: "Billing User", + }, + metadata: { + paykit_customer_id: "customer_123", + paykit_intent: "subscribe", + paykit_plan_id: "pro", + paykit_product_internal_id: "product_internal_123", + }, + providerCustomerId: "cus_123", + providerProduct: { priceId: "price_123" }, + successUrl: "https://example.com/success", + }); + }); +}); From 9992043208c8c42ecaea43d4b7269374926d9025 Mon Sep 17 00:00:00 2001 From: almeida Date: Mon, 18 May 2026 09:38:01 -0300 Subject: [PATCH 3/4] feat(stripe): forward customer email to checkout --- packages/stripe/src/__tests__/stripe.test.ts | 23 ++++++++++++++++++++ packages/stripe/src/stripe-provider.ts | 1 + 2 files changed, 24 insertions(+) diff --git a/packages/stripe/src/__tests__/stripe.test.ts b/packages/stripe/src/__tests__/stripe.test.ts index 3592659e..525319cb 100644 --- a/packages/stripe/src/__tests__/stripe.test.ts +++ b/packages/stripe/src/__tests__/stripe.test.ts @@ -184,6 +184,29 @@ describe("providers/stripe", () => { expect(params.managed_payments).toBeUndefined(); }); + it("passes customer email to subscription checkout when available", async () => { + const createSession = vi + .fn() + .mockResolvedValue({ id: "cs_123", url: "https://checkout.stripe.com/x" }); + const runtime = createCheckoutRuntime(createSession, false); + + await runtime.createSubscriptionCheckout({ + cancelUrl: "https://example.com/cancel", + customer: { + email: "billing@example.com", + name: "Billing User", + }, + metadata: {}, + providerCustomerId: "cus_123", + providerProduct: { priceId: "price_123" }, + successUrl: "https://example.com/success", + }); + + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ customer_email: "billing@example.com" }), + ); + }); + it("throws when managedPayments is enabled without the preview apiVersion", () => { expect(() => stripe({ diff --git a/packages/stripe/src/stripe-provider.ts b/packages/stripe/src/stripe-provider.ts index 51c324a3..2237782a 100644 --- a/packages/stripe/src/stripe-provider.ts +++ b/packages/stripe/src/stripe-provider.ts @@ -661,6 +661,7 @@ export function createStripeProvider(client: StripeSdk, options: StripeOptions): cancel_url: data.cancelUrl ?? data.successUrl, client_reference_id: data.providerCustomerId, customer: data.providerCustomerId, + customer_email: data.customer?.email, line_items: [{ price: data.providerProduct.priceId, quantity: 1 }], metadata: data.metadata, mode: "subscription", From 1f71d2019ea8e6055ac6abfad9aaedab911ebabb Mon Sep 17 00:00:00 2001 From: almeida Date: Mon, 18 May 2026 09:38:10 -0300 Subject: [PATCH 4/4] feat(polar): forward customer details to checkout --- packages/polar/src/polar-provider.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/polar/src/polar-provider.ts b/packages/polar/src/polar-provider.ts index ccb668fc..4e5b0df5 100644 --- a/packages/polar/src/polar-provider.ts +++ b/packages/polar/src/polar-provider.ts @@ -214,8 +214,10 @@ export function createPolarProvider(client: Polar, options: PolarOptions): Payme async createSubscriptionCheckout(data) { const checkout = await client.checkouts.create({ + customerEmail: data.customer?.email, products: [data.providerProduct.productId!], customerId: data.providerCustomerId, + customerName: data.customer?.name, metadata: data.metadata, successUrl: data.successUrl, });