Payments
Payments lets your app charge a signed-in user once, for a ticket, an order or a fee. Your code creates a payment with its line items and sends the user to Stripe Checkout. A hook tells your code when the money has arrived. Payments does not know what is sold, so Events and your own routes can share it. For recurring charges, use Subscriptions.
The package is @frigear-nu/payments. Only a verified Stripe event marks a payment as paid, never the redirect back to
your app. Prices are inline, so you create no products or prices in Stripe.
What you get
- Server routes (on by default): the signed-in user's payments, and resuming an unpaid one. A
staff route for revenue, guarded by the
payments:managepermission (theadminrole holds it). - Composables and helpers (on by default):
createPayment,refundPaymentand readers on the server, andusePayments()in the app. - Admin area (with Admin and Widget): a Payments tab and a Last payment card on the admin user page, and revenue and recent transactions on the dashboard.
@frigear-nu/module-kit, Auth, Users,
Stripe, MCP and UI.You add:@nuxthub/core.Optional: Admin and Widget, for the admin tab, card and dashboard widgets. They need both.Quick setup
Install Payments
pnpm add @frigear-nu/payments @nuxthub/core
Add it to nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxthub/core', '@frigear-nu/payments'], // installs Users and Stripe for you
payments: {
settings: { checkoutExpiresInMinutes: 30 },
},
})
sk_test_...) until the whole flow works: live keys charge real cards.Connect your Stripe account
Set the keys and the webhook endpoint as Stripe setup describes, with the events it lists for Payments.
payments table does not exist, and every checkout fails.Generate the migration
pnpm db:generate
Decide who sees revenue
Revenue and recent transactions on the admin dashboard need the payments:manage permission. The admin role holds
it by default. To give it to another role, grant it in Authorization:
export default defineNuxtConfig({
authorization: {
roles: {
bookkeeper: { label: 'Bookkeeper', grant: ['payments:manage'] },
},
},
})
payments:manage, nobody sees revenue and recent transactions on the admin dashboard.Take a payment
Create the payment in a route and send the user to the returned url. Charging money needs a signed-in session, so the
route passes sessionOnly: true. See Access control for how a session differs from
a token:
export default defineEventHandler(async (event) => {
const user = await requireUser(event, { sessionOnly: true })
const { url } = await createPayment({
userId: user.id,
items: [{ name: 'Workshop seat', amount: 25000, currency: 'dkk' }], // 250.00 DKK
reference: { type: 'workshop_seat', id: 'spring' },
successUrl: '/shop/thanks',
cancelUrl: '/shop',
}, event)
return { url }
})
Then act when Stripe confirms it, filtered on your reference type:
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('payments:paid', async ({ payment }) => {
if (payment.referenceType !== 'workshop_seat') return
// confirm the seat for payment.userId
})
})
4242 4242 4242 4242, GET /api/payments/me lists the
payment as paid, and your payments:paid listener has run.Options
Options go under the payments key.
| Option | Type | Default | Purpose |
|---|---|---|---|
settings.checkoutExpiresInMinutes | number | 30 | Minutes a Checkout session stays open. |
settings.mcp | boolean | false | Adds the my-payments MCP tool. |
settings.checkoutExpiresInMinutes is clamped to Stripe's range of 30 to 1440.
Server
These helpers are auto-imported in Nitro.
| Helper | What it does |
|---|---|
createPayment(input, event?) | Creates a Checkout session and a pending payment. Returns CreatePaymentResult. |
resumePayment(paymentId, userId, event?) | Returns { url } of the open session, or of a new one once it expired. |
refundPayment(paymentId, { amount? }) | Asks Stripe for a full or partial refund. |
getPayment(id), getPaymentByReference(type, id) | 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
}
createPayment validates successUrl and cancelUrl as a relative path or the same origin when you pass event.
Without event they must be absolute http(s) URLs. An invalid URL throws 400.
resumePayment throws 404 for an unknown payment, and 409 when it is already paid or refunded. For an expired
session it builds a new one from the stored items and the old success and cancel URLs. It runs
payments:checkout:before and the URL check again. The configured checkoutExpiresInMinutes applies, because
expiresInMinutes is not stored.
refundPayment throws 409 unless the payment is paid or partially_refunded. An amount that is not positive,
or more than is left, throws 400. The status changes when the charge.refunded event arrives.
Subscription invoices
With Subscriptions installed, every subscription invoice is stored as a payment too.
These are the invoice.paid, invoice.payment_failed, invoice.voided and invoice.marked_uncollectible events.
- The payment has
referenceType: 'subscription', andreferenceIdis the Stripe subscription id. - It fires the
payments:*hooks and shows in the admin tab, the card andmy-payments. - Invoices of zero, such as trials and full discounts, are skipped.
- Only invoices received after upgrading are stored.
recordInvoicePayment(input: InvoicePaymentInput) is the auto-imported helper behind this. It is idempotent per
invoice id. Without Payments, Subscriptions records no invoices.
MCP tool
With settings.mcp: true, the my-payments tool (scope payments:read) lists the signed-in user's own payments,
newest first, with an optional limit. Its amount and refundedAmount are in whole currency units (52.2 is 52.20),
not minor units, and currency is upper-case.
App
usePayments() loads the signed-in user's payments. It is auto-imported, and pages can await it.
| Composable | Returns | What it does |
|---|---|---|
usePayments() | { payments, refresh, status, loading, error, resume(paymentId) } | resume sends the user back 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. The tab is read-only: it has no refund action yet. The admin dashboard gets a 30-day revenue stat and the recent transactions.
Hooks
Your app answers these Nitro hooks from a server plugin (server/plugins/*.ts).
| Hook | When | Payload |
|---|---|---|
payments:checkout:before | Before createPayment or resumePayment creates a session. Change ctx.params, or throw to deny. | CheckoutBeforeContext |
payments:paid | Once, when the payment is 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 to 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
}
A status change happens once. A redelivered Stripe event fires no hook twice, and a late expired event never
downgrades a paid payment.
Routes
Routes the module registers, and who may call them.
| Method | Path | Auth | Status | Purpose |
|---|---|---|---|---|
GET | /api/payments/me | payments:read | 200 | The signed-in user's payments (PaymentDto[]). |
POST | /api/payments/me/:id/resume | payments:write | 200 | { url } for one of the caller's unpaid payments. |
GET | /api/admin/payments/recent | payments:manage | 200 | 30-day revenue per currency and the latest settled payments. |
GET | /api/admin/users/:id/payments | users:read, and the user must not outrank the caller | 200 | A user's payments, newest first. ?limit= is 1 to 500, default 100. |
- The resume route answers
404 not_foundfor someone else's payment and409 conflictwhen it is already paid. - The
/meroutes also accept an API token or an access token whose scope covers the permission, such as thepaymentsscope. - Path builders are
paymentsApifrom@frigear-nu/payments/api.
Permissions
The module registers these permissions with Authorization. A route calls
authorize(event, 'payments:read'), which passes only when the caller's roles, the authorization:before hook and any
policy allow it.
| Permission | Flags | Held by default | What it allows |
|---|---|---|---|
payments:read | implicit | every signed-in user | Read your own payments. |
payments:write | implicit | every signed-in user | Resume paying for your own unpaid payments. |
payments:manage | admin | See recent payments and revenue on the dashboard. |
- Implicit permissions work even when Authorization is not installed, so self-service keeps working.
- Scope:
payments(label "Your payments") expands topayments:readandpayments:write. A token or OAuth client with this scope can read and resume the user's own payments, and nothing else. - Roles and policies: the module ships no role, requirement or policy. Staff access comes from the
adminrole, or from any role of yours that grantspayments:manage. - Reading another user's payments (
GET /api/admin/users/:id/payments) needsusers:readfrom Users, not a payments permission. The caller must also hold every permission the target user holds, so a coordinator cannot read an admin's payments.
For the full model, see Authorization, including roles and requirements and policies.
Database
Tables the module adds. Generate and commit a migration after installing.
The module adds one payments table.
| Column | Type | Notes |
|---|---|---|
id | text, primary key | pay_ and a 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 invoices. |
invoiceId | text, unique, nullable | The Stripe invoice id of a subscription invoice. |
paymentIntentId | text, nullable | Indexed. |
refundedAmount | integer | Default 0. |
metadata | JSON, nullable | Record<string, string>. |
items | JSON, nullable | The PaymentItem[] of the session. Null for older rows. |
paidAt, createdAt, updatedAt | timestamps |
Translations
Override any string in your app's i18n/locales/<code>.json.
The module registers da and en files with Module Kit under payments.*. The
keys are payments.status.<status>, payments.defaultDescription and payments.admin.*. API error messages stay in
English.
{ "payments": { "status": { "partially_refunded": "Partly refunded" } } }
Next steps
- Stripe setup – connect Stripe so payments are marked as paid.
- Sell event tickets – see Events take payments through this module.