Skip to content

Leaky abstraction: createCheckoutSession hides Stripe Checkout params (allow_promotion_codes, discounts, automatic_tax, ...) #50

Description

@ethan-huo

Problem

StripeSubscriptions.createCheckoutSession (src/client/index.ts:166) accepts only a narrow subset of Stripe's Checkout.SessionCreateParams. Several commonly-used fields are silently unavailable:

  • allow_promotion_codes — enable user-facing promotion code input at Checkout
  • discounts — pre-apply a coupon or promotion code programmatically
  • automatic_tax — Stripe Tax integration
  • shipping_address_collection / billing_address_collection — for physical goods or compliance
  • ui_mode (embedded / hosted) — embedded Checkout
  • customer_creation, customer_update, payment_method_types, locale, expires_at, ...

This is the classic leaky-abstraction trap: the wrapper covers 20% of the underlying surface area, and any real product eventually needs something outside that 20%. Today it's promo codes; tomorrow it's tax or embedded mode.

What consumers are forced to do

To enable allow_promotion_codes for internal-testing promo codes, we had to:

  1. Add stripe as a direct dependency (it was only transitive via @convex-dev/stripe).
  2. Construct a fresh Stripe SDK instance in application code and call stripe.checkout.sessions.create() directly — duplicating the param-shape the component already builds internally.
  3. Audit the component to confirm that bypassing the session-creation wrapper doesn't corrupt component state.

The audit outcome is actually encouraging and worth documenting: the component's tables (customers, checkout_sessions, payments, subscriptions, invoices) are populated entirely by webhook events routed through registerStripeRoutes — not by the code path that created the session. So bypassing createCheckoutSession is safe for state integrity, as long as webhooks are still registered. That's a real strength of the design, but it makes the narrow wrapper feel even less justified: the component already does the right thing regardless of who creates the session.

Proposals (in order of minimal change)

Option A — pass-through escape hatch

createCheckoutSession(ctx, {
  priceId, mode, successUrl, cancelUrl,
  extraParams?: Partial<Stripe.Checkout.SessionCreateParams>,
})

Merge extraParams into sessionParams before calling the SDK. Keeps the convenience for the 80% case, unblocks the 20% case. Lowest-risk change.

Option B — accept full SDK shape

Accept Stripe.Checkout.SessionCreateParams directly, with the component's convenience fields (priceId → line_items, mode, metadata wiring) layered on top as optional builder sugar.

Option C — expose the SDK instance

Add a getStripe() accessor on StripeSubscriptions so consumers can run arbitrary Stripe SDK calls without re-instantiating (and without being forced to add stripe as a direct dep).

Options A and C are additive and non-breaking; B is a larger refactor. A + C together cover most real-world escape hatches with minimal surface.

Same critique applies to createCustomerPortalSession, cancelSubscription, etc. — any wrapper that hides the Stripe params will hit this eventually. A consistent extraParams + SDK accessor convention across the client would future-proof the component.

Version

@convex-dev/stripe@0.1.4

Happy to send a PR for Option A + C if there's agreement on direction.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions