Commerce

Payments

Charge a signed-in user once through Stripe Checkout, and hear through a hook when the money arrives.

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:manage permission (the admin role holds it).
  • Composables and helpers (on by default): createPayment, refundPayment and readers on the server, and usePayments() 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.
ServerClientMobile
Installed for you:@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

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxthub/core', '@frigear-nu/payments'], // installs Users and Stripe for you
  payments: {
    settings: { checkoutExpiresInMinutes: 30 },
  },
})
Use test keys (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.

Without a migration the 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:

nuxt.config.ts
export default defineNuxtConfig({
  authorization: {
    roles: {
      bookkeeper: { label: 'Bookkeeper', grant: ['payments:manage'] },
    },
  },
})
Without a role that holds payments:manage, nobody sees revenue and recent transactions on the admin dashboard.
This step charges money. Test it with test keys first.

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:

server/api/shop/checkout.post.ts
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:

server/plugins/shop.ts
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('payments:paid', async ({ payment }) => {
    if (payment.referenceType !== 'workshop_seat') return
    // confirm the seat for payment.userId
  })
})
What you should see: after paying with Stripe's test card 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.

OptionTypeDefaultPurpose
settings.checkoutExpiresInMinutesnumber30Minutes a Checkout session stays open.
settings.mcpbooleanfalseAdds 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.

HelperWhat 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', and referenceId is the Stripe subscription id.
  • It fires the payments:* hooks and shows in the admin tab, the card and my-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.

ComposableReturnsWhat 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).

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

MethodPathAuthStatusPurpose
GET/api/payments/mepayments:read200The signed-in user's payments (PaymentDto[]).
POST/api/payments/me/:id/resumepayments:write200{ url } for one of the caller's unpaid payments.
GET/api/admin/payments/recentpayments:manage20030-day revenue per currency and the latest settled payments.
GET/api/admin/users/:id/paymentsusers:read, and the user must not outrank the caller200A user's payments, newest first. ?limit= is 1 to 500, default 100.
  • The resume route answers 404 not_found for someone else's payment and 409 conflict when it is already paid.
  • The /me routes also accept an API token or an access token whose scope covers the permission, such as the payments scope.
  • Path builders are paymentsApi from @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.

PermissionFlagsHeld by defaultWhat it allows
payments:readimplicitevery signed-in userRead your own payments.
payments:writeimplicitevery signed-in userResume paying for your own unpaid payments.
payments:manageadminSee 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 to payments:read and payments: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 admin role, or from any role of yours that grants payments:manage.
  • Reading another user's payments (GET /api/admin/users/:id/payments) needs users:read from 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.

ColumnTypeNotes
idtext, primary keypay_ and a 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 invoices.
invoiceIdtext, unique, nullableThe Stripe invoice id of a subscription invoice.
paymentIntentIdtext, nullableIndexed.
refundedAmountintegerDefault 0.
metadataJSON, nullableRecord<string, string>.
itemsJSON, nullableThe PaymentItem[] of the session. Null for older rows.
paidAt, createdAt, updatedAttimestamps

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

Copyright © 2026