Payments
One-time payments on top of Stripe. The module is event-agnostic: other modules (for example Events) ask it to charge something and are told through hooks when the money arrives.
- Stripe events are the only inbound signal. The module has no webhook route of its own. It registers a handler with
registerStripeEventHandlerforcheckout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed,checkout.session.expiredandcharge.refunded. The success redirect never marks anything as paid. - Prices are inline. Checkout sessions use
price_data, so no Stripe products or prices need syncing between environments. - Hooks, not coupling. State transitions are announced through Nitro hooks; consumers filter on
payment.referenceType.
@nuxthub/core (KV on), @frigear-nu/module-kit, @frigear-nu/auth,
@frigear-nu/users and @frigear-nu/stripe (configure it as described there).
Optional@frigear-nu/widget and @frigear-nu/admin: the admin user tab and
card are only added when both are present.Quick setup
export default defineNuxtConfig({
modules: ['@frigear-nu/users', '@frigear-nu/stripe', '@frigear-nu/payments'],
payments: {
settings: { checkoutExpiresInMinutes: 30 },
},
})
Options
| Option | Type | Default | Purpose |
|---|---|---|---|
settings.checkoutExpiresInMinutes | number | 30 | Minutes a Checkout session stays open. Clamped to Stripe's range of 30 - 1440. Also in runtimeConfig.payments. |
settings.mcp | boolean | false | Expose the my-payments MCP tool. |
Server
Auto-imported in Nitro:
| Util | Description |
|---|---|
createPayment(input, event?) | Creates the Checkout session (mode: 'payment') and a pending row. Returns CreatePaymentResult. |
resumePayment(paymentId, userId, event?) | Returns the open session's URL, or creates a fresh session once it expired. Returns { url }. |
refundPayment(paymentId, { amount? }) | Asks Stripe for a (partial) refund. The status changes when charge.refunded arrives. |
getPayment(id), getPaymentByReference(type, id) | Read one payment (the latest for a reference), or null. |
listUserPayments(userId, { limit? }) | A user's payments, newest first. |
@frigear-nu/payments/utils exports the types below and sumItems(items), which totals items in minor units.
type PaymentStatus = 'pending' | 'paid' | 'failed' | 'expired' | 'refunded' | 'partially_refunded'
type PaymentItem = {
name: string
description?: string
amount: number // unit amount in minor units (cents / øre)
currency: string // ISO 4217
quantity?: number // default 1
}
type CreatePaymentInput = {
userId: number
items: PaymentItem[] // one currency, total above zero
reference?: { type: string, id: string } // e.g. { type: 'event_ticket', id }
successUrl: string // absolute URL or path on the current origin
cancelUrl: string
description?: string
expiresInMinutes?: number // overrides settings.checkoutExpiresInMinutes
metadata?: Record<string, string>
}
type CreatePaymentResult = { payment: PaymentDto, url: string }
type PaymentDto = {
id: string // pay_...
userId: number
status: PaymentStatus
amount: number
currency: string
description: string | null
referenceType: string | null
referenceId: string | null
refundedAmount: number
paidAt: string | null // ISO
createdAt: string // ISO
}
Behaviour worth knowing:
successUrlandcancelUrlare validated (relative path or same origin) wheneventis passed; withouteventthey must be absolutehttp(s)URLs. Invalid URLs throw 400.resumePaymentthrows 404 for an unknown payment, and 409 when it is already paid or refunded. For an expired session it creates a new one from the storeditems(same names, descriptions, unit amounts and quantities), runspayments:checkout:beforeand the same return URL validation ascreatePayment, and reuses the expired session's success and cancel URLs.expiresInMinutesis not stored; the configuredcheckoutExpiresInMinutesapplies. Payments created beforeitemswas stored fall back to a single line item for the total, named after the description orPayment; the hook still runs.refundPaymentthrows 409 unless the payment ispaidorpartially_refunded, and 400 for an amount that is not positive or exceeds what is left to refund.
Hooks
Register with nitro.hooks.hook(...) in a Nitro plugin.
| Hook | When | Payload |
|---|---|---|
payments:checkout:before | Before the session is created by createPayment or rebuilt by resumePayment; adjust ctx.params or throw to deny. | CheckoutBeforeContext |
payments:paid | Once, when the payment became paid. | PaymentHookContext |
payments:failed | An asynchronous payment method failed. | PaymentHookContext |
payments:expired | The Checkout session expired unpaid. | PaymentHookContext |
payments:refunded | A refund changed the payment (partially_refunded or refunded). | PaymentHookContext |
type PaymentHookContext = { payment: PaymentDto }
type CheckoutBeforeContext = {
event?: H3Event
userId: number
reference?: { type: string, id: string }
params: Stripe.Checkout.SessionCreateParams // mutable
}
Transitions are conditional updates: a redelivered Stripe event emits nothing twice, and a paid payment is never
downgraded by a late expired event.
Routes
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /api/payments | requireUser, scope payments:read | The signed-in user's payments. |
| POST | /api/payments/resume | requireUser, scope payments:write | Body { paymentId }. Returns { url } for an unpaid payment. |
| GET | /api/admin/users/:id/payments | users:admin:authorize (action view) | Staff view of a user's payments, newest first. ?limit= defaults to 100, max 500. |
Both user routes also accept bearer tokens and OAuth access tokens that carry the scope. The scopes are registered with
Auth as payments:read ("Read your payments") and payments:write ("Resume your payments").
App
| Export | Returns | Description |
|---|---|---|
usePayments() | { payments, refresh, status, resume(paymentId) } | The signed-in user's payments; resume redirects to Stripe. |
With Widget and Admin installed, the module adds a Payments tab (order 20) and a
Last payment card to the admin user page of Users (admin.users tab area,
admin.users.overview widget area).
The Payments tab is a TableCard with a read-only UTable (amounts through useFormat().money, status badges
coloured with statusColor); there is no refund action yet, so rows have no menu. Last payment is a SectionCard with
a DescriptionList. On the admin dashboard, Revenue, 30 days is a StatCard and Recent transactions a ListCard
that links to the users list. All of them are built from the UX patterns of
@frigear-nu/ui.
Database
One payments table.
| Column | Type | Notes |
|---|---|---|
id | text, primary key | pay_ + random id. |
userId | integer | Indexed. No foreign key to users. |
status | text | PaymentStatus, default pending. |
amount, currency | integer, text | Minor units. |
description | text, nullable | |
referenceType, referenceId | text, nullable | Indexed together. |
checkoutSessionId | text, unique, nullable | Follows the new session after a resume. Null for invoice payments. |
invoiceId | text, unique, nullable | Stripe invoice id for membership charges (see below). |
paymentIntentId | text, nullable | Indexed. |
refundedAmount | integer | Default 0. |
metadata | JSON, nullable | Record<string, string>. |
items | JSON, nullable | PaymentItem[] the session was created from; null for older rows. |
paidAt, createdAt, updatedAt | timestamps |
Membership payments
When @frigear-nu/subscriptions is installed next to this module, every membership invoice (invoice.paid,
invoice.payment_failed, invoice.voided, invoice.marked_uncollectible) is stored as a payment with
referenceType: 'subscription' and referenceId set to the Stripe subscription id. They appear in the admin user
Payments tab and Last payment card, fire the payments:* hooks (filter on referenceType) and are returned by the
my-payments MCP tool. Refunds are applied like any other payment through charge.refunded. Without this module,
@frigear-nu/subscriptions behaves as before and records nothing. Zero-amount invoices (trials, full discounts) are skipped, and only invoices
received after upgrading are stored.
recordInvoicePayment(input: InvoicePaymentInput) is the auto-imported server util behind this; it is idempotent per
invoice id.
MCP
Set settings.mcp: true to expose my-payments (scope payments:read): the signed-in user's own payments, newest
first, with an optional limit. amount and refundedAmount are in whole currency units (52.2 = 52.20), not minor units, and currency is upper-case.
Translations
da and en locale files are registered with Module Kit under payments.*:
payments.status.<status> (one key per PaymentStatus), payments.defaultDescription and payments.admin.* for the
admin tab. API error messages stay English. Override keys in your app's i18n/locales/<code>.json (see
overriding module strings):
{ "payments": { "status": { "partially_refunded": "Partly refunded" } } }