| name | stacks-payments |
| description | 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).
Key Paths
- Core package:
storage/framework/core/payments/src/
- Payment facade:
storage/framework/core/payments/src/payment.ts
- Stripe driver:
storage/framework/core/payments/src/drivers/stripe.ts
- Billable modules:
storage/framework/core/payments/src/billable/
- Configuration:
config/payment.ts
- SaaS config:
config/saas.ts
- Default billing functions:
storage/framework/defaults/functions/billing/payments.ts
Package Exports
Payment (default) -- facade object with all payment methods
- All individual functions (
charge, subscribe, checkout, etc.)
- All billable modules (
manageCharge, manageCustomer, manageSubscription, etc.)
stripe -- raw Stripe SDK instance
Stripe -- re-exported Stripe types namespace
Payment Facade
The Payment object aggregates all payment operations. Every method is also available as a standalone export.
Charges
import { Payment } from '@stacksjs/payments'
const intent = await Payment.charge(user, 2999, 'pm_xxx', { currency: 'usd' })
const intent = await Payment.createPayment(user, 2999, { currency: 'usd' })
const refund = await Payment.refund('pi_xxx', 1000)
const refund = await Payment.refund('pi_xxx')
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.
Checkout Sessions
const session = await Payment.checkout(user, [
{ price: 'price_xxx', quantity: 1 }
], { success_url: '/success', cancel_url: '/cancel' })
const subSession = await Payment.subscriptionCheckout(user, 'price_xxx', {
success_url: '/success', cancel_url: '/cancel'
})
Both methods require the user to have a stripe_id (throws if missing). checkout() sets mode: 'payment', subscriptionCheckout() sets mode: 'subscription'.
Subscriptions
const sub = await Payment.subscribe(user, 'premium-monthly')
const cancelled = await Payment.cancelSubscription('sub_xxx')
const cancelled = await Payment.cancelSubscription('sub_xxx', true)
const hasActive = await Payment.hasActiveSubscription(user, 'default')
const changed = await Payment.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
const customer = await Payment.getOrCreateCustomer(user, { name: 'John' })
const updated = await Payment.updateCustomer(user, { name: 'Jane' })
const deleted = await Payment.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
syncStripeCustomerDetails(user, options) -- updates Stripe with user's name, email, address, locales, metadata
Payment Methods
const pm = await Payment.addPaymentMethod(user, 'pm_xxx')
const customer = await Payment.setDefaultPaymentMethod(user, 'pm_xxx')
const removed = await Payment.removePaymentMethod(user, paymentMethodDbId)
const intent = await Payment.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
Invoices
const invoices = await Payment.getInvoices(user)
const invoice = await Payment.createInvoice('cus_xxx', { description: 'Custom invoice' })
const paid = await Payment.payInvoice('inv_xxx')
getInvoices() requires the user to have a stripe_id and expands data.payment_intent.payment_method.
Products & Prices
const { product, price } = await Payment.createProduct('Pro Plan', 2999, {
currency: 'usd', interval: 'month', description: 'Pro features'
})
const price = await Payment.getPrice('premium-monthly')
const products = await Payment.listProducts({ limit: 10 })
createProduct() creates the Stripe product, then creates a price linked to it. If interval is provided, the price gets recurring: { interval }.
Additional methods on manageProduct:
create(params), retrieve(productId), update(productId, params)
archive(productId) -- sets active: false
search(query, params) -- Stripe product search
list(params) -- defaults to active: true
Additional methods on managePriceExtended:
create(params), retrieve(priceId), update(priceId, params)
list(params) -- defaults to active: true
listByProduct(productId, params) -- prices for a specific product
search(query, params) -- Stripe price search
archive(priceId) -- sets active: false
Coupons & Promo Codes
const coupon = await Payment.createCoupon({
percentOff: 20, duration: 'once', name: 'Holiday Sale', maxRedemptions: 100
})
const promo = await Payment.createPromoCode('coupon_xxx', 'SAVE20')
const valid = await Payment.validatePromoCode('SAVE20')
createCoupon() supports percentOff, amountOff, currency, duration ('forever' | 'once' | 'repeating'), durationInMonths, name, maxRedemptions.
Additional methods on manageCoupon:
retrieve(couponId), update(couponId, params), delete(couponId), list(params)
createPromotionCode(params) -- wraps stripe.promotionCodes.create()
retrievePromotionCode(code) -- lists active promotion codes matching the code string
Transactions
import { manageTransaction } from '@stacksjs/payments'
const tx = await manageTransaction.store(user, productId, {
brand: 'visa', provider_id: 'pi_xxx', description: 'Purchase', type: 'one-time'
})
const txList = await manageTransaction.list(user)
Transactions are stored in the payment_transactions table with name (from product), amount (from product unit_price), brand, type, provider_id, user_id.
Webhooks
import { Payment } from '@stacksjs/payments'
Payment.onPaymentIntent({
succeeded: async (event) => { },
failed: async (event) => { },
created: async (event) => { },
canceled: async (event) => { },
})
Payment.onSubscription({
created: async (event) => { },
updated: async (event) => { },
deleted: async (event) => { },
trialWillEnd: async (event) => { },
})
Payment.onInvoice({
paid: async (event) => { },
paymentFailed: async (event) => { },
created: async (event) => { },
finalized: async (event) => { },
})
Payment.onCheckout({
: (event) => { },
: (event) => { },
})
.({
: (event) => { },
: (event) => { },
: (event) => { },
: (event) => { },
})
result = .(rawPayload, signatureHeader, {
: , :
})
..(, handler)
..({ : handler })
{ getPaymentIntent, getSubscription, getInvoice, getCheckoutSession, getCharge, getCustomer }
Supported webhook event types: payment_intent.succeeded, payment_intent.payment_failed, payment_intent.created, payment_intent.canceled, customer.subscription.created/updated/deleted, customer.subscription.trial_will_end, customer.created/updated/deleted, invoice.paid/payment_failed/finalized/created, checkout.session.completed/expired, charge.succeeded/failed/refunded, charge.dispute.created/closed, payment_method.attached/detached, setup_intent.succeeded/setup_failed, plus any string.
Setup Products from SaaS Config
import { createStripeProduct } from '@stacksjs/payments'
const result = await createStripeProduct()
This iterates saas.plans, creates a Stripe product per plan, then creates prices for each pricing option using lookup_key from the key field.
Utility Functions
Payment.formatAmount(2999, 'usd')
Payment.toCents(29.99)
Payment.toDollars(2999)
Client-Side Billing (useBillable)
The storage/framework/defaults/functions/billing/payments.ts file provides a useBillable() composable for frontend billing UI:
const { loadCardForm, loadPaymentForm, handleAddPaymentMethod, handlePayment } = useBillable()
await loadCardForm(clientSecret)
await loadPaymentForm(clientSecret)
await handleAddPaymentMethod(clientSecret, elements)
await handlePayment(elements)
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.
config/payment.ts
{
driver: 'stripe',
stripe: {
publishableKey: env.STRIPE_PUBLISHABLE_KEY || '',
secretKey: env.STRIPE_SECRET_KEY || '',
},
} satisfies PaymentConfig
config/saas.ts
Plans use productName, description, metadata, and a pricing array where each entry has key (lookup_key), price (in cents), interval (optional: 'month' | 'year'), currency:
{
plans: [
{
productName: 'Stacks Hobby',
description: 'All the Stacks features.',
pricing: [
{ key: 'stacks_hobby_early_monthly', price: 1900, interval: 'month', currency: 'usd' },
{ key: 'stacks_hobby_yearly', price: 37900, interval: 'year', currency: 'usd' },
],
metadata: { createdBy: 'admin', version: '1.0.0' },
},
],
webhook: { endpoint: 'your-webhook-endpoint', secret: 'your-webhook-secret' },
currencies: ['usd'],
coupons: [],
products: [
{ name: 'Stacks Hobby', description: '...', images: ['image-url'] },
],
} satisfies SaasConfig
Database Tables Used
subscriptions -- columns: user_id, type, unit_price, provider_id, provider_status, provider_price_id, quantity, trial_ends_at, ends_at, provider_type, last_used_at
payment_methods -- columns: id, type, last_four, brand, exp_year, exp_month, user_id, provider_id, is_default
payment_products -- columns: id, name, unit_price
payment_transactions -- columns: id, name, description, amount, brand, type, provider_id, user_id
User Model Requirements
The UserModel must have:
id, name, email, stripe_id fields
hasStripeId() method -- returns boolean
update(data) method -- for persisting stripe_id
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