Modules

Payments

One-time Stripe payments - Checkout sessions with inline prices, a payments table, refunds and lifecycle hooks driven by verified Stripe events.

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 registerStripeEventHandler for checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, checkout.session.expired and charge.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.
Requires@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

OptionTypeDefaultPurpose
settings.checkoutExpiresInMinutesnumber30Minutes a Checkout session stays open. Clamped to Stripe's range of 30 - 1440. Also in runtimeConfig.payments.
settings.mcpbooleanfalseExpose the my-payments MCP tool.

Server

Auto-imported in Nitro:

UtilDescription
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:

  • successUrl and cancelUrl are validated (relative path or same origin) when event is passed; without event they must be absolute http(s) URLs. Invalid URLs throw 400.
  • resumePayment throws 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 stored items (same names, descriptions, unit amounts and quantities), runs payments:checkout:before and the same return URL validation as createPayment, and reuses the expired session's success and cancel URLs. expiresInMinutes is not stored; the configured checkoutExpiresInMinutes applies. Payments created before items was stored fall back to a single line item for the total, named after the description or Payment; the hook still runs.
  • refundPayment throws 409 unless the payment is paid or partially_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.

HookWhenPayload
payments:checkout:beforeBefore the session is created by createPayment or rebuilt by resumePayment; adjust ctx.params or throw to deny.CheckoutBeforeContext
payments:paidOnce, when the payment became paid.PaymentHookContext
payments:failedAn asynchronous payment method failed.PaymentHookContext
payments:expiredThe Checkout session expired unpaid.PaymentHookContext
payments:refundedA 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

MethodPathAuthPurpose
GET/api/paymentsrequireUser, scope payments:readThe signed-in user's payments.
POST/api/payments/resumerequireUser, scope payments:writeBody { paymentId }. Returns { url } for an unpaid payment.
GET/api/admin/users/:id/paymentsusers: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

ExportReturnsDescription
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.

ColumnTypeNotes
idtext, primary keypay_ + random id.
userIdintegerIndexed. No foreign key to users.
statustextPaymentStatus, default pending.
amount, currencyinteger, textMinor units.
descriptiontext, nullable
referenceType, referenceIdtext, nullableIndexed together.
checkoutSessionIdtext, unique, nullableFollows the new session after a resume. Null for invoice payments.
invoiceIdtext, unique, nullableStripe invoice id for membership charges (see below).
paymentIntentIdtext, nullableIndexed.
refundedAmountintegerDefault 0.
metadataJSON, nullableRecord<string, string>.
itemsJSON, nullablePaymentItem[] the session was created from; null for older rows.
paidAt, createdAt, updatedAttimestamps
The module changes the generated schema, not the database: generate and commit a migration in every host app and fixture that uses it. See Module Kit.

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" } } }
Copyright © 2026