Use when implementing payment processing in Stacks — Stripe charges, subscriptions, checkout sessions, customer management, payment methods, invoices, coupons, promo codes, products, prices, webhooks, or the Payment facade. Covers @stacksjs/payments and config/payment.ts.
license
MIT
compatibility
Bun >= 1.3.0, TypeScript
allowed-tools
Read Edit Write Bash Grep Glob
Stacks Payments
Full Stripe integration via the Payment facade. Uses Stripe API version 2026-01-28.clover. The Stripe SDK is initialized from services.stripe.secretKey (sourced from config/payment.ts).
charge() sets confirmation_method: 'automatic', confirm: true, attaches the payment method, and delegates to createPayment(). If the user has a stripe_id, it is set as the customer on the PaymentIntent. Default currency is 'usd'.
The manageCharge module also exposes findPayment(id) which retrieves a PaymentIntent by ID, returning null on failure.
Both methods require the user to have a stripe_id (throws if missing). checkout() sets mode: 'payment', subscriptionCheckout() sets mode: 'subscription'.
Subscriptions
// Create -- uses lookup_key to resolve the Stripe Priceconst sub = awaitPayment.subscribe(user, 'premium-monthly')
// Cancel at period end (prorate: true)const cancelled = awaitPayment.cancelSubscription('sub_xxx')
// Cancel immediately (invoice_now: true, prorate: false)const cancelled = awaitPayment.cancelSubscription('sub_xxx', true)
// Check active subscriptionconst hasActive = awaitPayment.hasActiveSubscription(user, 'default')
// Change plan -- swaps the subscription item's priceconst changed = awaitPayment.changeSubscription(user, 'enterprise-monthly')
subscribe() calls managePrice.retrieveByLookupKey(lookupKey) to find the price, then creates the subscription with payment_behavior: 'allow_incomplete' and expand: ['latest_invoice.payment_intent']. It stores the subscription in the subscriptions database table.
isValid() returns true if the subscription status is 'active' or 'trialing'. isIncomplete() checks for 'incomplete' status.
cancel() calls stripe.subscriptions.cancel() and updates provider_status to 'canceled' in the database.
update() retrieves the active subscription via user.activeSubscription(), finds the new price by lookup key, updates the subscription item, and stores the updated price in the database.
Customers
// Get existing or create new Stripe customerconst customer = awaitPayment.getOrCreateCustomer(user, { name: 'John' })
// Update customer details in Stripeconst updated = awaitPayment.updateCustomer(user, { name: 'Jane' })
// Delete from Stripe and clear stripe_id on user modelconst deleted = awaitPayment.deleteCustomer(user)
createOrGetStripeUser() checks user.stripe_id first. If the user has one, it retrieves the customer from Stripe. If the customer was deleted (404 or deleted: true), it creates a new one. On creation, it auto-fills name and email from the user model and calls user.update({ stripe_id: customer.id }).
Additional methods on manageCustomer:
stripeId(user) -- returns user.stripe_id
hasStripeId(user) -- boolean check
createStripeCustomer(user, options) -- throws if user already has a stripe_id
createOrUpdateStripeUser(user, options) -- creates or updates
retrieveStripeUser(user) -- returns customer or undefined
// Add a payment method to the customerconst pm = awaitPayment.addPaymentMethod(user, 'pm_xxx')
// Set as default (by Stripe payment method ID string)const customer = awaitPayment.setDefaultPaymentMethod(user, 'pm_xxx')
// Remove a payment method (by database record ID number)const removed = awaitPayment.removePaymentMethod(user, paymentMethodDbId)
// Create a setup intent for collecting payment methodsconst intent = awaitPayment.createSetupIntent(user, { payment_method_types: ['card'] })
addPaymentMethod() accepts a string (Stripe PM ID) or Stripe.PaymentMethod object. It attaches the PM to the customer if not already attached, then stores it in the payment_methods table with type, last_four, brand, exp_year, exp_month, user_id, provider_id.
setUserDefaultPayment() accepts a Stripe PM ID string, clears existing is_default flags, sets the new default in the database, and updates invoice_settings.default_payment_method on the Stripe customer.
setDefaultPaymentMethod() accepts a database record ID number.
Additional methods on managePaymentMethod:
updatePaymentMethod(user, pmId, params) -- updates PM in Stripe
listPaymentMethods(user) -- queries payment_methods table by user_id
retrievePaymentMethod(user, pmId) -- by database ID
retrieveDefaultPaymentMethod(user) -- finds where is_default: true
Transactions are stored in the payment_transactions table with name (from product), amount (from product unit_price), brand, type, provider_id, user_id.
useBillable() explicitly imports usePaymentStore from the default payment
store. Keep that dependency explicit in imported billing modules. Browser
auto-imports are injected into STX script entries and do not become lexical
globals inside the TypeScript modules those entries bundle.
usePaymentStore() is a callable wrapper around one STX defineStore()
singleton. Its requests resolve the configured API origin, include the current
bearer token, add the CSRF header for writes, and derive mutation route IDs from
the authenticated user. Never restore a fixed localhost port or a hard-coded
user ID.
Dashboard Billing
/settings/billing is a thin route that renders BillingSettings. The
component calls GET /api/dashboard/billing, an authenticated aggregate Action
registered in routes/dashboard-api.ts. Do not call the root /payments/*
group from the dashboard: buddy dev --dashboard delegates /api/* to the
Stacks router and intentionally leaves root GET paths to STX page rendering.
The aggregate always returns persisted PaymentTransaction records for the
authenticated user. Subscription and payment-method reads are provider-backed
and may be unavailable when the application User override is not billable.
Render that as an explicit unavailable state, not sample plans or fake cards.
Plans use productName, description, metadata, and a pricing array where each entry has key (lookup_key), price (in cents), interval (optional: 'month' | 'year'), currency:
activeSubscription() method -- for subscription updates
The framework default storage/framework/defaults/app/Models/User.ts sets
billable: false intentionally because not every application uses payments.
Run buddy publish:model User, keep the override at app/Models/User.ts, and
enable its billable trait before calling instance helpers such as
activeSubscription(), paymentMethods(), or createSetupIntent(). A payment
Action must report the missing trait clearly instead of calling an undefined
method.
Gotchas
Stripe API keys MUST be in .env as STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY -- never hardcode them in config files
The Stripe SDK is initialized eagerly -- if STRIPE_SECRET_KEY is missing, the module throws on import
All amounts are in cents -- use toCents() and toDollars() for conversion
charge() creates AND confirms the PaymentIntent in one step
subscribe() resolves the price via lookup_key, not a direct Stripe price ID
removePaymentMethod() takes a database record ID (number), not a Stripe PM ID (string)
setDefaultPaymentMethod has two variants: one takes a Stripe PM ID string (setUserDefaultPayment), the other takes a database ID number
getOrCreateCustomer() handles deleted Stripe customers by recreating them
Subscription status checks query the local database, not Stripe directly
list() on products defaults to active: true only
Webhook handlers are stored in an in-memory Map -- register them on application startup
processWebhook() uses stripe.webhooks.constructEvent() for signature verification