Stripe
The stripe module (@strav/stripe) provides Stripe billing integration — subscriptions, one-time charges, checkout sessions, invoices, payment methods, and webhooks. Attach billing capabilities directly to your user model with the billable() mixin, or use the stripe helper object for a standalone API.
Stripe-only. Uses the official Stripe SDK under the hood.
Installation
bun add @strav/stripe
bun strav install stripe
The install command copies files into your project:
config/stripe.ts— Stripe keys, currency, webhook secret, checkout URLs.database/schemas/customer.ts— thecustomertable schema.database/schemas/subscription.ts— thesubscriptiontable schema.database/schemas/subscription_item.ts— thesubscription_itemtable schema.database/schemas/receipt.ts— thereceipttable schema.
--force to overwrite).
Setup
1. Register StripeManager
Using a service provider (recommended)
import { StripeProvider } from '@strav/stripe'
app.use(new StripeProvider())
The StripeProvider registers StripeManager as a singleton. It depends on the database provider.
Manual setup
import StripeManager from '@strav/stripe'
app.singleton(StripeManager)
app.resolve(StripeManager)
2. Configure Stripe credentials
Editconfig/stripe.ts:
import { env } from '@strav/kernel'
export default {
secret: env('STRIPE_SECRET', ''),
key: env('STRIPE_KEY', ''),
webhookSecret: env('STRIPE_WEBHOOK_SECRET', ''),
currency: 'usd',
userKey: 'id',
urls: {
success: env('APP_URL', 'http://localhost:3000') + '/billing/success',
cancel: env('APP_URL', 'http://localhost:3000') + '/billing/cancel',
},
}
The userKey option controls which field on your user table is used as the foreign key in billing tables. It defaults to 'id', which produces a user_id FK column. If your user table uses a custom primary key (e.g. uuid), set userKey: 'uuid' and the FK column becomes user_uuid.
3. Run the migration
bun strav generate:migration -m "add billing tables"
bun strav migrate
4. Add environment variables
STRIPE_SECRET=sk_test_...
STRIPE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Billable mixin
Thebillable() mixin adds billing methods directly to your user model. This is the recommended API for most applications.
import { BaseModel } from '@strav/database'
import { billable } from '@strav/stripe'
class User extends billable(BaseModel) {
declare id: number
declare email: string
}
Works with compose() for combining multiple mixins:
import { compose } from '@strav/kernel'
import { billable } from '@strav/stripe'
class User extends compose(BaseModel, softDeletes, billable) {
declare id: number
declare email: string
}
Once applied, the user instance gains all the methods documented below.
Customers
Every billable user is linked to a Stripe customer through the localcustomer table.
// Get or create the Stripe customer
const customer = await user.createOrGetStripeCustomer()
// Check if the user has a Stripe customer record
await user.hasStripeId() // true
// Get the Stripe customer ID
await user.stripeId() // 'cus_xxx'
// Get the local customer record
const customer = await user.customer()
// customer.stripeId, customer.pmType, customer.pmLastFour, customer.trialEndsAt
The createOrGetStripeCustomer() method is idempotent — if a customer already exists, it returns the existing record. Otherwise it creates one on Stripe and stores it locally.
Subscriptions
Creating subscriptions
// Simple subscription
await user.subscribe('default', 'price_xxx')
// With a trial period
await user.newSubscription('pro', 'price_xxx')
.trialDays(14)
.create()
// With a coupon
await user.newSubscription('pro', 'price_xxx')
.coupon('LAUNCH20')
.create()
// Multi-plan subscription
await user.newSubscription('enterprise', 'price_base')
.plan('price_addon', 3)
.create()
// Full builder API
await user.newSubscription('pro', 'price_xxx')
.quantity(5)
.trialDays(14)
.coupon('LAUNCH20')
.promotionCode('promo_abc')
.metadata({ team: 'alpha' })
.paymentBehavior('allow_incomplete')
.anchorBillingCycleOn(timestamp)
.create()
The subscribe() method is a shorthand for newSubscription(name, price).create(). Use newSubscription() when you need to configure the subscription before creating it.
Checking subscription status
// Is the user subscribed? (active, trialing, or on grace period)
await user.subscribed('pro') // true
await user.subscribed() // checks 'default'
// Is the user on a trial?
await user.onTrial('pro') // true if trial_ends_at is in the future
// Is the subscription on a grace period? (canceled but not yet expired)
await user.onGracePeriod('pro') // true
// Is the user subscribed to a specific price?
await user.subscribedToPrice('price_xxx') // true
// Get subscription details
const sub = await user.subscription('pro')
sub.name // 'pro'
sub.stripeId // 'sub_xxx'
sub.stripeStatus // 'active'
sub.stripePriceId // 'price_xxx'
sub.quantity // 1
sub.trialEndsAt // Date | null
sub.endsAt // Date | null
// Get all subscriptions
const subs = await user.subscriptions()
Status checks on SubscriptionData
TheSubscription class also provides pure status-check functions that operate on SubscriptionData objects directly:
import Subscription from '@strav/stripe/subscription'
const sub = await user.subscription('pro')
Subscription.active(sub) // active, trialing, or past_due
Subscription.onTrial(sub) // trial_ends_at in the future
Subscription.onGracePeriod(sub) // ends_at in the future
Subscription.canceled(sub) // ends_at is set
Subscription.ended(sub) // canceled and past grace period
Subscription.pastDue(sub) // stripe_status === 'past_due'
Subscription.recurring(sub) // not on trial, not canceled
Subscription.valid(sub) // active OR onTrial OR onGracePeriod
Canceling subscriptions
import Subscription from '@strav/stripe/subscription'
const sub = await user.subscription('pro')
// Cancel at period end (grace period)
await Subscription.cancel(sub)
// Cancel immediately (no grace period)
await Subscription.cancelNow(sub)
After canceling at period end, onGracePeriod() returns true until the current billing period expires. The user retains access during this time.
Resuming subscriptions
Resume a subscription that was canceled but is still within its grace period:
await Subscription.resume(sub)
Throws if the subscription is not on a grace period.
Swapping plans
Switch a subscription to a different price (prorates by default):
await Subscription.swap(sub, 'price_new')
Updating quantity
await Subscription.updateQuantity(sub, 10)
One-time charges
// Charge a payment method
const paymentIntent = await user.charge(2500, 'pm_xxx')
// amount is in the smallest currency unit (e.g. cents)
// With options
const paymentIntent = await user.charge(2500, 'pm_xxx', {
currency: 'eur',
description: 'Add-on purchase',
metadata: { product: 'widget' },
})
// Refund a charge (full)
const refund = await user.refund('pi_xxx')
// Partial refund
const refund = await user.refund('pi_xxx', 1000)
Payment methods
// List all payment methods
const methods = await user.paymentMethods()
// Set a payment method as default
await user.setDefaultPaymentMethod('pm_xxx')
// Create a SetupIntent (for collecting card details without charging)
const intent = await user.createSetupIntent()
// Pass intent.client_secret to Stripe.js on the frontend
Checkout sessions
Create Stripe Checkout sessions for one-time payments or subscriptions.
Quick checkout
// One-time payment
const session = await user.checkout([
{ price: 'price_xxx', quantity: 1 },
{ price: 'price_yyy', quantity: 2 },
])
// Redirect to session.url
Checkout builder
const session = await user.newCheckout()
.item('price_xxx', 2)
.item('price_yyy')
.mode('subscription')
.subscriptionName('pro')
.trialDays(14)
.successUrl('/billing/success')
.cancelUrl('/billing/cancel')
.allowPromotionCodes()
.metadata({ campaign: 'launch' })
.create()
The subscriptionName() method automatically sets mode to 'subscription' and stores the name in metadata so the webhook handler can create the local record with the correct name.
Guest checkout
For users without a Stripe customer (not logged in):
const session = await new CheckoutBuilder()
.item('price_xxx')
.email('guest@example.com')
.create()
When no user is passed to .create(), the session is created without attaching a Stripe customer. Use .email() to pre-fill the customer email.
Invoices
// List recent invoices
const invoices = await user.invoices()
// Preview the next invoice (prorations, upcoming charges)
const upcoming = await user.upcomingInvoice()
For direct access to invoice operations:
import Invoice from '@strav/stripe/invoice'
const invoice = await Invoice.find('in_xxx')
const pdfUrl = await Invoice.pdfUrl('in_xxx')
const hostedUrl = await Invoice.hostedUrl('in_xxx')
await Invoice.void_('in_xxx')
Billing portal
Create a Stripe Customer Portal session URL so users can manage their subscriptions, payment methods, and invoices:
const url = await user.billingPortalUrl()
// Redirect to url
// With a custom return URL
const url = await user.billingPortalUrl('/account')
Webhooks
Register a route handler to receive Stripe webhook events. The handler verifies signatures, keeps local database records in sync, and dispatches custom event handlers.
Route setup
import { router } from '@strav/http'
import { stripeWebhook } from '@strav/stripe/webhook'
router.post('/stripe/webhook', stripeWebhook())
> Note: Webhook routes should not use the session() or csrf() middleware. Stripe sends raw POST requests that won't have a session cookie or CSRF token.
Built-in event handling
The webhook handler automatically processes these events to keep local records in sync:
| Event | Action |
|---|---|
customer.updated |
Syncs default payment method to local customer record |
customer.deleted |
Deletes local customer and all subscription records |
customer.subscription.created |
Creates local subscription + items (for externally created subs) |
customer.subscription.updated |
Syncs status, ends_at, price, quantity, trial, and items |
customer.subscription.deleted |
Marks local subscription as canceled |
Custom event handlers
Register handlers for any Stripe event type:
import { onWebhookEvent } from '@strav/stripe/webhook'
onWebhookEvent('invoice.payment_failed', async (event) => {
const invoice = event.data.object as Stripe.Invoice
// Send a notification to the user...
})
onWebhookEvent('checkout.session.completed', async (event) => {
const session = event.data.object as Stripe.Checkout.Session
// Fulfill the order...
})
Custom handlers run after the built-in handlers.
Stripe CLI for local testing
Forward webhook events to your local server during development:
stripe listen --forward-to localhost:3000/stripe/webhook
Copy the webhook signing secret from the CLI output into your .env:
STRIPE_WEBHOOK_SECRET=whsec_...
stripe helper
Thestripe helper provides the same functionality as the billable mixin but without requiring a model instance. Useful for standalone operations or when you don't want to use the mixin.
import { stripe } from '@strav/stripe'
// Customer
const customer = await stripe.createOrGetCustomer(user)
const customer = await stripe.findCustomer(user)
// Subscriptions
const sub = await stripe.newSubscription('pro', 'price_xxx')
.trialDays(14)
.create(user)
const sub = await stripe.subscription(user, 'pro')
const isSubscribed = await stripe.subscribed(user, 'pro')
// Checkout
const session = await stripe.newCheckout()
.item('price_xxx')
.mode('subscription')
.subscriptionName('pro')
.create(user)
// Invoices & payment methods
const invoices = await stripe.invoices(user)
const upcoming = await stripe.upcomingInvoice(user)
const methods = await stripe.paymentMethods(user)
await stripe.setDefaultPaymentMethod(user, 'pm_xxx')
// Receipts
const receipts = await stripe.receipts(user)
// Direct Stripe SDK access
stripe.stripe.customers.list({ limit: 10 })
stripe.key // publishable key for frontend
stripe.currency // configured default currency
Error handling
The module throws these error types:
StripeError— general billing errors (extendsStravError)CustomerNotFoundError— no local customer record found for a userSubscriptionNotFoundError— no subscription found with the given namePaymentMethodError— a payment method operation failed on Stripe (attach, detach, etc.)SubscriptionCreationError— subscription creation failed on StripeWebhookSignatureError— Stripe webhook signature verification failed
import { StripeError, PaymentMethodError, SubscriptionCreationError } from '@strav/stripe'
try {
await user.subscribe('pro', 'price_xxx')
} catch (error) {
if (error instanceof SubscriptionCreationError) {
// Stripe rejected the subscription creation
} else if (error instanceof StripeError) {
// Other billing error
}
}
Database tables
The module uses four tables, defined by the schema stubs:
customer
| Column | Type | Description |
|---|---|---|
id |
serial |
Primary key |
user_id |
integer |
FK to user table |
stripe_id |
varchar |
Stripe customer ID (cus_xxx) |
pm_type |
varchar |
Default payment method type |
pm_last_four |
varchar(4) |
Last 4 digits of default card |
trial_ends_at |
timestamp |
Customer-level trial expiry |
created_at |
timestamp |
Row creation time |
updated_at |
timestamp |
Last update time |
subscription
| Column | Type | Description |
|---|---|---|
id |
serial |
Primary key |
user_id |
integer |
FK to user table |
name |
varchar |
Subscription name ('default', 'pro', etc.) |
stripe_id |
varchar |
Stripe subscription ID (sub_xxx) |
stripe_status |
varchar |
Stripe status (active, trialing, canceled, etc.) |
stripe_price_id |
varchar |
Primary price ID |
quantity |
integer |
Seat count or unit quantity |
trial_ends_at |
timestamp |
Trial expiry |
ends_at |
timestamp |
Set when canceled (grace period end) |
created_at |
timestamp |
Row creation time |
updated_at |
timestamp |
Last update time |
subscription_item
| Column | Type | Description |
|---|---|---|
id |
serial |
Primary key |
subscription_id |
integer |
FK to subscription table |
stripe_id |
varchar |
Stripe subscription item ID (si_xxx) |
stripe_product_id |
varchar |
Stripe product ID |
stripe_price_id |
varchar |
Stripe price ID |
quantity |
integer |
Item quantity |
created_at |
timestamp |
Row creation time |
updated_at |
timestamp |
Last update time |
receipt
| Column | Type | Description |
|---|---|---|
id |
serial |
Primary key |
user_id |
integer |
FK to user table |
stripe_id |
varchar |
Stripe payment intent ID (pi_xxx) |
amount |
integer |
Amount in smallest currency unit |
currency |
varchar |
Currency code |
description |
text |
Charge description |
receipt_url |
text |
Stripe receipt URL |
created_at |
timestamp |
Row creation time |
SubscriptionData
All subscription methods return or accept aSubscriptionData object:
| Field | Type | Description |
|---|---|---|
id |
number |
Local primary key |
userId |
string | number |
Foreign key to user table |
name |
string |
Subscription name |
stripeId |
string |
Stripe subscription ID |
stripeStatus |
string |
Stripe status |
stripePriceId |
string | null |
Primary price ID |
quantity |
number | null |
Quantity |
trialEndsAt |
Date | null |
Trial expiry |
endsAt |
Date | null |
Grace period end |
createdAt |
Date |
Row creation time |
updatedAt |
Date |
Last update time |
CustomerData
| Field | Type | Description |
|---|---|---|
id |
number |
Local primary key |
userId |
string | number |
Foreign key to user table |
stripeId |
string |
Stripe customer ID |
pmType |
string | null |
Default payment method type |
pmLastFour |
string | null |
Last 4 digits |
trialEndsAt |
Date | null |
Customer-level trial expiry |
createdAt |
Date |
Row creation time |
updatedAt |
Date |
Last update time |