Billing
The Cashier-style subscription layer — billable Lucid mixins, the billing tables, an idempotent webhook processor that keeps local rows in sync with the gateway, and durable-backed dispatch.
The billing layer is the Laravel-Cashier experience for AdonisJS: Lucid models + mixins on
your side, and — on the library side — an idempotent webhook processor that keeps the
local billing tables in sync with the gateway, plus a dispatcher that runs webhooks
through @adonis-agora/durable when it's installed.
The core promise: your database is always a truthful mirror of the gateway, no matter how many webhooks arrive, in which order, or how many times a gateway redelivers them.
The billing tables
You do not create these tables. The library does, on first use — the ecosystem convention that
@adonis-agora/durable and @adonis-agora/authz follow, and the reason there is no migration to
publish, run or remember on upgrade. createBillingTables is idempotent (CREATE TABLE IF NOT EXISTS), runs once per process, and carries columns added in later versions to a database that
already has the tables.
To own the DDL yourself, set billing: { autoCreateSchema: false } and run the migration
configure publishes for that case — it calls the same function, so the two paths cannot drift.
Seven tables:
| Table | Purpose |
|---|---|
billing_customers | the polymorphic mapping between your rows and their gateway customers — opt-in (see below) |
billing_subscriptions | subscriptions — status, plan_id, trial_ends_at, ends_at |
billing_payments | every payment, keyed by gateway id, indexed on external_reference — your id for the charge — and carrying refunded_amount |
billing_webhook_events | the idempotency ledger, keyed by gateway event id, storing the raw payload and the normalized event |
billing_disputes | chargebacks and pre-chargeback alerts, keyed by the dispute's gateway id — and evidence_due_by, the deadline to answer |
billing_usage_events | metered consumption, one row per event — the input to usage-based billing |
billing_audit_events | what a human or the endpoint did, which no other table keeps — see The audit trail |
createBillingTables also carries columns added after their table shipped, as guarded
ALTERs: CREATE TABLE IF NOT EXISTS is a no-op on a table that already exists, so an
install upgrading from an earlier version would otherwise keep the old columns and fail on
the first query naming a new one. ensureSchema() is public on LucidBillingStore for an
app that turned autoCreateSchema off and wants it in a seeder.
billing_webhook_events is the heart: it's what makes every redelivery a no-op instead of a
double-grant.
billing_customers is the one you have to opt into. The Billable mixin keeps
the gateway customer id on your model, and that is what a charge reads — so the mapping table is
not on the critical path and the library will not guess which of your rows owns a gateway customer.
Pass a store to ensureCustomer and it is written where the id is
created. Leave it out and the table stays empty, which is fine until you want the two things only it
can answer: enumerating every gateway customer (what payments:sync --all reconciles over), and
mapping one owner to customers at several gateways, which a single column on the user row has
nowhere to put.
Reading the billing tables
Query the store, not the tables. The store is a configured seam — an app can point billing.store
at its own implementation — so code that reaches around it into billing_payments breaks the moment
someone does. Everything the library records is readable through it:
await store.findCustomerByGatewayId(gatewayId) // one customer mapping
await store.findCustomerByOwner('User', userId, 'stripe')
await store.listCustomers({ provider: 'stripe', ownerType: 'User', ownerId: '4102' })
await store.listCustomersByGatewayIds(['cus_1', 'cus_2']) // a page of owners in ONE read
await store.findPaymentByGatewayId(gatewayId) // one payment, by the GATEWAY's id
await store.findPaymentByExternalReference('order-1042') // ...or by YOURS
await store.listPayments({ status: 'failed', size: 20 })
await store.listPayments({ externalReference: 'order-1042' }) // exact, never a prefix
await store.listPayments({ gatewayId: 'pay_1abc' })
await store.listPayments({ customerId: 'cus_123' })
await store.countPayments({ status: 'pending', createdBefore: cutoff })
await store.findDisputeByGatewayId('dp_1Nx4') // one dispute, by the DISPUTE's id
await store.findOpenDisputeByPayment('pi_3Qx7') // ...or the open one against a payment
await store.listDisputes({ status: 'open', size: 20 })
await store.countDisputes({ status: 'open' })
await store.listDisputesDueWithin({ withinHours: 72 }) // the windows closing soonest, first
await store.countDisputesDueWithin({ withinHours: 72 })
await store.listOpenDisputes({ size: 20 }) // unanswered, oldest first — no deadline needed
await store.countOpenDisputes({})
await store.findWebhookEventByGatewayEventId(eventId) // one ledger row, with its error
await store.listWebhookEvents({ status: 'failed' })
await store.listWebhookEvents({ type: 'payment.refunded' })
await store.listWebhookEventsForPayment('pay_1abc') // a payload SUBSTRING scan — read below
await store.webhookEventBreakdown({ status: 'failed' }) // grouped by provider + type
await store.recordAuditEvent({ action: 'payment.refunded', actor: 'ana@acme.com' })
await store.listAuditEvents({ action: 'webhook.rejected', size: 20 })
await store.countAuditEvents({ action: 'webhook.rejected', createdAfter: cutoff })
await store.revenue({ from, to }) // cents, GROSS — subtracts no refund
await store.netRevenue({ from, to }) // cents, NET — minus refunded_amount
await store.countActiveSubscriptions()
await store.usageReport({ subscriptionId, from, to })The three payment filters — externalReference, gatewayId, customerId — are exact, never
prefixes or substrings. They are join keys, and a substring match letting order-4 return
order-42 is a wrong answer to a question about money. listPayments({ externalReference }) differs
from findPaymentByExternalReference in one way that matters: it returns every row carrying the
reference rather than the newest, because an app that reuses a reference across retries is asking
exactly "which one landed?".
listCustomersByGatewayIds is the join a page of payments needs. A payment row carries cus_… and
nothing else; the only thing tying that to an app user is billing_customers.owner_type/owner_id,
written by ensureCustomer. Resolving it one row at a time would be a query per row of every page.
findPaymentByExternalReference is the direction an app actually has at hand: a checkout page
polls for order-1042, not for pi_3Qx.... It reads the external_reference column — the
externalReference you set on the charge, as the gateway echoed it back — through
billing_payments_external_reference_idx, and listPayments carries the same value on every item.
It answers null for a payment whose gateway sent no reference, and for every row written
before the column existed: nothing backfills a reference that was never stored.
Two aggregates sit on top of those: billingOverview(store, { from, to }) for the KPI row a
dashboard renders, and billingHealth(store) for the
six operational checks — both plain functions over the same store.
`listWebhookEventsForPayment` is a payload substring scan, and says so
billing_payments is a single mutable row upserted in place, so it holds a current state and no
history at all. The ledger does have one — every delivery that moved the row is in it — but
nothing links a ledger row to a payment, because the link lives inside the stored payload.
So this read is a CAST(payload AS TEXT) LIKE scan, and all three of its costs are real: it is
unindexed, it can over-match (a gateway id that happens to be a substring of some other
identifier in an unrelated delivery), and it cannot see a delivery that never stored the id.
It is bounded by size, newest first. The dashboard's per-payment view reports
events.matchedBy: 'payload-substring' on the wire and on screen rather than presenting the result
as a history.
It is offered anyway because the alternative on the table today is nothing at all. The honest fix
is a payment_gateway_id column the processor fills on the way in — a write path this does not
touch.
The audit trail
billing_audit_events records the things that happened to an install that no other table keeps.
Each of them is otherwise only a diagnostic — a line in a log that has usually rotated away by the
time somebody asks.
AUDIT_ACTIONS | Written when |
|---|---|
refund → 'payment.refunded' | someone issues a refund from the console. The payment row moves only when the gateway's webhook lands, and that row names no person |
disputeResolved → 'dispute.resolved' | someone records how a dispute ended, on a gateway that will never close it itself |
webhookRejected → 'webhook.rejected' | the webhook endpoint answered 400. The only trace a rejected delivery leaves anywhere, since it is refused before a ledger row exists |
action is a free string in the column, so an app can record its own. The three above are what this
package writes.
await store.recordAuditEvent({
action: 'entitlement.granted',
actor: user.email, // null/absent means "unattributed", never "the system"
subjectType: 'payment',
subjectId: 'pay_1abc',
amount: 4990, // integer minor units. NEVER divide
currency: 'brl',
message: 'manual grant after support ticket #812',
})Two deliberate tolerances. recordAuditEvent returns null — and writes nothing — on an install
whose table is not there yet: the audit row is additional to an action that already happened, and
failing a refund the gateway already accepted because the note could not be filed is the worse
outcome by a distance. And a refund the gateway refused writes nothing at all: an audit of
refunds that never happened is an audit nobody can trust.
billing_audit_events is a new table, not a new column, so it needs nothing from the post-ship
ALTER phase — CREATE TABLE IF NOT EXISTS carries it to an existing install exactly as well as to
a fresh one.
Writing a payment: absent is not null
savePayment upserts by gateway id, and three of its fields — externalReference, paidAt and
refundedAmount — are leave-alone when omitted. Passing null still clears them.
That is not a convenience, it is a money bug that already happened. A refund, a chargeback and a
dispute close all write this row and none of them carries a settlement date. paidAt was written
through unconditionally, so every one of them set paid_at = NULL — and revenue() filters on that
column. Closing a dispute in your favour therefore restored status = 'paid' with no date, and
the recovered money left every windowed revenue figure, permanently and silently.
await store.savePayment({ gatewayId, provider, status: 'paid', amount, currency })
// paid_at, external_reference and refunded_amount: untouched
await store.savePayment({ gatewayId, provider, status: 'paid', amount, currency, paidAt: null })
// paid_at: cleared, deliberatelyRows an earlier version already damaged
An install that ran a won dispute close or a refund on an older version has rows sitting at
status = 'paid' with paid_at = NULL, missing from every monthly revenue figure. The fix is
backfilling those rows from the gateway — payments:sync now takes the gateway's own settlement
date and never overwrites one already recorded, so running it over the affected customers repairs
them.
refunded_amount — what a partial refund needed
A partial refund had nowhere to be recorded. The only two options were overwriting the status with
refunded (writing the whole charge off — a R$10 refund on a R$100 charge erasing R$90 of revenue)
or dropping the event, and the library dropped it.
billing_payments.refunded_amount is integer minor units, the same units as amount, BIGINT for
the same reason. Net revenue for a row is amount - refundedAmount — one subtraction, never a
division. It is on BillingPayment, on the withPayment() mixin, and on PaymentListItem.
null and 0 are different answers and the difference is load-bearing: null is "nothing has
been said about refunds on this row", 0 asserts that nothing has gone back. A driver that sees no
refunds array sends nothing, and the leave-alone rule keeps whatever is stored.
It arrived after the table shipped, so it is applied as a guarded ALTER on the next boot — and
like every other column added that way, it is not backfilled: rows written before it read
null.
Disputes and their clock
A chargeback is the one webhook that takes revenue away, and it comes with a deadline. The three
dispute events — payment.dispute_warning, payment.disputed, payment.dispute_closed — each
write a billing_disputes row in addition to what they already did: a warning still moves no
money, a chargeback still moves the payment to disputed, and a won close still puts it back to
paid.
// The work list: open disputes whose window closes in the next three days, soonest first.
for (const dispute of await store.listDisputesDueWithin({ withinHours: 72 })) {
console.log(dispute.gatewayId, dispute.paymentGatewayId, dispute.evidenceDueBy)
}Three things worth knowing about that read:
- A deadline already past stays in the list. The dispute is still open and still unanswered; dropping it the moment it expires would make an alert go quiet at exactly the moment it became true.
- A dispute with no
evidenceDueByis never in it. Plenty of gateways send no deadline, andnullmeans "the gateway told us nothing", never "no hurry". - It is ordered by the deadline, not by arrival. Every other list in the store is newest-first; this one is in the order the work has to be done.
Disputes are keyed on the dispute's own gateway id. Where a gateway sends none — several do not
— the processor keys on dispute:<provider>:<payment gateway id> instead, so the later events of the
same dispute land on the same row rather than accumulating one row per webhook. The cost is that a
payment disputed twice by such a gateway collapses into one row: a gateway that never names a
dispute gives nothing to tell the two apart, and a row that could never be closed would alert on its
deadline forever.
submitDisputeEvidence on the driver is how you answer one, and nothing in this library calls it
for you: whether to fight a dispute or refund it turns on the fee, the evidence your app holds, and
the chargeback ratio that puts a merchant into a card network's monitoring programme. The table and
the health check exist so the decision is never made for you by
a window closing.
The deadline-free read
listDisputesDueWithin is blind on most installs, because evidence_due_by can only be filled by a
gateway that publishes a deadline — and several of the eighteen drivers here never receive one. On
those, the deadline read answers zero forever while a chargeback sits open with the money already
pulled back.
// Every unanswered dispute — warning, open, under_review — deadline or no deadline.
for (const dispute of await store.listOpenDisputes({ size: 20 })) {
console.log(dispute.gatewayId, dispute.status, dispute.createdAt)
}
await store.countOpenDisputes({}) // what the `open_disputes` health check alerts onOldest first, deliberately: with no deadline to sort on, "how long has nobody answered this" is
the only priority signal left. OPEN_DISPUTE_STATUSES is exported (['warning', 'open', 'under_review']) so an app filtering elsewhere uses the same definition of "still open".
Closing one a gateway will never close
Several gateways publish no lost-dispute event at all — Asaas is one, and its driver hardcodes
outcome: 'won' when it closes a dispute. So a dispute that was lost sits at open forever,
and every deadline check stays red until nobody reads it any more.
saveDispute records the ending, and its absent-fields-are-left-alone rule means the deadline and
the reason the opening event carried survive the write:
await store.saveDispute({
gatewayId: 'dp_1Nx4',
paymentGatewayId: 'pi_3Qx7',
provider: 'asaas',
status: 'lost', // a FINISHED status — see the dashboard page for which ones qualify
outcome: 'lost',
closedAt: new Date(),
})Nothing is sent to a gateway by this. The decision was made at the bank; this writes down which way
it went. The console does the same thing through
POST <dashboard>/api/disputes/:gatewayId/resolve,
and additionally records who said so.
The Billable model
Compose withBillable() into your user model (or scaffold one):
node ace make:billable userimport { BaseModel, compose, column } from '@adonisjs/lucid/orm'
import { withBillable } from '@adonis-agora/payments'
export default class User extends compose(BaseModel, withBillable()) {
@column({ isPrimary: true }) declare id: string
}The mixin adds billingCustomerId and billingProvider, so a user knows which gateway
customer it is — the id every charge/subscription call starts from:
const customer = await payments.driver('pix').createCustomer({
name: user.name,
email: user.email,
taxId: user.taxId,
})
user.billingCustomerId = customer.id
user.billingProvider = 'woovi'
await user.save()Reusing a gateway customer
Getting a gateway customer is so common that the library ships ensureCustomer — reuse
the stored id or create at the gateway, whichever applies:
import { ensureCustomer } from '@adonis-agora/payments'
const customer = await ensureCustomer(driver, user.billingCustomerId, {
name: user.name, email: user.email, taxId: user.taxId,
})
user.billingCustomerId = customer.id
await user.save()Pass the store and the owner, and it also records the mapping in billing_customers:
@inject()
export default class SubscribeService {
constructor(private store: LucidBillingStore) {}
async run(user: User) {
const customer = await ensureCustomer(
payments.driver('pix'),
user.billingCustomerId,
{ name: user.name, email: user.email, taxId: user.taxId },
{ store: this.store, owner: { type: 'User', id: user.id } },
)
user.billingCustomerId = customer.id
await user.save()
}
}It records on the reuse branch too, so an id your app has held since before it recorded anything gets backfilled the next time the flow runs — no gateway call involved. And a later call that knows less (a reconcile holding only the gateway id) does not blank what an earlier one wrote.
Reading it back:
await store.findCustomerByOwner('User', String(user.id), 'stripe')
await store.findCustomerByGatewayId('cus_123')
await store.listCustomers({ provider: 'stripe', size: 50 })provider is part of the owner lookup rather than an optional filter: one user may legitimately have
a customer at every configured gateway, and answering without it would return an arbitrary one.
Subscriptions
Create a subscription at the gateway; the webhook processor keeps the local row in sync after that:
const subscription = await payments.driver('pix').createSubscription({
customerId: user.billingCustomerId!,
planId: 'plan_pro',
amount: 4990,
startDate: '2026-09-01',
externalReference: `sub:${user.id}`,
invoice: true,
})The processor listens for subscription.created/updated/canceled,
payment.succeeded/failed/refunded/updated and the three dispute events, and upserts the local rows
automatically — trial windows, period ends, cancellations all land in billing_subscriptions
without you writing a single sync query.
Webhook processing — why it's safe to trust
The processor's ordering is deliberate. Every event is recorded in billing_webhook_events
before any work runs. If that gateway event id is already present, processing stops
immediately — so a redelivered webhook (gateways retry on timeout, dashboards re-send, ops
replays from logs) is a no-op, not a second grant.
import { onDiagnostic } from '@adonis-agora/diagnostics'
onDiagnostic('payments', 'payment.succeeded', ({ payload }) => {
// grant access, send receipt — safe to assume this event was seen once
})This is the outer idempotency defense. For the money-critical paths, add an inner one too
(a WHERE status = pending update, a unique index on the grant) — defense in depth, not
redundancy. See Webhooks → The lifecycle.
`externalReference` is your routing key
Sync keeps the billing tables correct, but your business logic needs your ids. The
externalReference you set on the charge/subscription comes back on the webhook, is stored on
the payment row (billing_payments.external_reference, indexed), and routes the confirmation to
your own Payment/Subscription rows. Look it up with
store.findPaymentByExternalReference(reference). See
Webhooks → externalReference.
The dispatcher — how a webhook survives a restart
A webhook half-way through granting credits is exactly the work that must not be lost on a deploy or
a crash. billing.dispatcher chooses what carries it:
| Value | Behaviour | Survives a restart |
|---|---|---|
'auto' (default) | durable when its provider is registered; otherwise in-process | depends on what it picked |
'durable' | a durable workflow run — throws when durable is missing | yes |
'in-process' | inline, retrying in the background | no |
The in-process fallback retries up to 5 times with exponential backoff (500 ms base, capped at 30 s). That covers a transient database or gateway failure. It does not survive a deploy: the pending backoff window dies with the process.
A retry works because the ledger lets a failed event be claimed again — a genuine redelivery is still refused, but an attempt that threw is re-runnable. That is what makes "a throwing handler is retried" true rather than aspirational.
Name the dispatcher in production
'auto' degrades silently. An app that meant to run durable and forgot to register its provider
falls back to in-process and nothing says so. Setting dispatcher: 'durable' turns that into a boot
error, which is where you want to find out. See Production.
billing.durable is the legacy alias — true maps to 'durable', false to 'in-process'.
Prefer dispatcher, which names the backend instead of encoding it as a boolean.
Usage-based billing — metered subscriptions
Plans that bill by consumption (API calls, stored GB, minutes) are metered through the
billing_usage_events table. Record consumption as it happens, then aggregate per meter
for a period:
// When a metered event happens (an API call, a message sent):
await store.recordUsage({
subscriptionId: subscription.id,
meter: 'api_calls',
quantity: 1,
})
// At the end of the billing period, roll up what to bill:
const report = await store.usageReport({
subscriptionId: subscription.id,
from: periodStart,
to: periodEnd,
})
// [{ meter: 'api_calls', quantity: 412 }]usageReport filters by subscriptionId, customerId, meter, and a from/to window,
aggregating quantity per meter — the input to a usage-based invoice or overage charge.
billing_usage_events is created with the rest of the schema, so there is nothing to run
first; the in-memory store mirrors it for tests.
Pricing a period — meteredBill
Turn a period's usage into a charge with per-meter rates (price per unit + included
allowance). Feed it the usageReport result, or fetch + price in one call:
import { meteredBillForSubscription } from '@adonis-agora/payments'
const bill = await meteredBillForSubscription(store, {
subscriptionId: subscription.id,
from: periodStart,
to: periodEnd,
rates: [
{ meter: 'api_calls', rate: 0.5, included: 1000 }, // 1000 free, then R$0.005 each
{ meter: 'storage_gb', rate: 200 },
],
})
// bill.total → cents to charge for overage
// bill.lines → per-meter billable quantities and amountsThis is the "how much to bill for what was consumed" half of a metered plan — the invoice for overage is then emitted via the invoice provider or an Asaas/Stripe charge.
The billing overview — the dashboard's data
billingOverview aggregates the KPIs a billing dashboard renders, from the store alone. No gateway
calls, so it is fast, works headless, and is trivially testable:
import { inject } from '@adonisjs/core'
import { billingOverview, LucidBillingStore } from '@adonis-agora/payments'
@inject()
export default class BillingDashboardController {
constructor(private store: LucidBillingStore) {}
async index() {
return billingOverview(this.store, {
from: DateTime.now().startOf('month').toJSDate(),
to: DateTime.now().toJSDate(),
})
}
}
// overview.period → { from, to }
// overview.metrics → [
// { key: 'revenue', label: 'Revenue, gross (cents)', value: 1_284_900 },
// { key: 'net_revenue', label: 'Revenue, net of refunds (cents)', value: 1_231_400 },
// { key: 'active_subscriptions', label: 'Active subscriptions', value: 212 },
// { key: 'meter:api_calls', label: 'Usage · api_calls', value: 481_230 },
// ]Revenue arrives twice, in cents. revenue is gross — the sum of amount on paid payments
in the window, subtracting nothing — and net_revenue is the same rows minus what came back
(amount - COALESCE(refunded_amount, 0)). A charge that was half refunded counts at full value in
the first and at its net in the second, so a screen showing one of them has to say which. Format at
the edge, and never divide before you have finished adding. See
Money.
The billing store
Everything above persists through a BillingStore. You do not construct one: the provider resolves
it at boot and binds it in the container, so it injects like any other dependency:
import { inject } from '@adonisjs/core'
import { LucidBillingStore } from '@adonis-agora/payments'
@inject()
export default class UsageService {
constructor(private store: LucidBillingStore) {}
}The default is Lucid over the tables the library creates for itself — that is what makes the billing layer work with no configuration at all. Name it only when you want different models behind it:
import { billingStores, defineConfig } from '@adonis-agora/payments'
export default defineConfig({
// ...
billing: {
store: billingStores.lucid({
models: { usageEventModel: MyUsageEvent, disputeModel: MyDispute },
}),
},
})A custom store is reached through the service, not the container
billing.store takes any factory returning a BillingStore, so an app can persist somewhere else
entirely. The container binds the LucidBillingStore token only when the resolved store actually is
one — injecting that token otherwise would hand back something the annotation does not describe. For
a custom store, read it from the service:
import { getBillingStore } from '@adonis-agora/payments/services/main'
const store = getBillingStore()getBillingStore() throws until the provider's booted() hook has run, which makes it unsafe in a
constructor or a field initializer. Use lazyBillingStore() there — see
services/main.
Mixins
make:billable composes withBillable() — the base. The library also ships narrower
mixins you can stack on other models:
withSubscription— a model that owns recurring subscriptions.withPayment— a model that owns one-off payments.
Each adds columns only — Lucid @column declarations and their types. No methods: there is no
user.charge(...) or user.subscribe(...).
| Mixin | Columns it declares |
|---|---|
withBillable() | billingCustomerId, billingProvider, billingTrialEndsAt |
withSubscription() | gatewayId, provider, status, planId, customerId, trialEndsAt, endsAt, payload, createdAt, updatedAt |
withPayment() | gatewayId, provider, status, amount, currency, customerId, subscriptionId, externalReference, refundedAmount, payload, createdAt, updatedAt, paidAt |
Charging is not on the model. It goes through the manager and the billing store:
import { getBillingStore, getPayments } from '@adonis-agora/payments/services/main'
// The model holds the gateway's customer id — the mixin's contribution.
// The manager resolves a driver; the driver charges.
const payment = await getPayments().driver('pix').charge({
customerId: user.billingCustomerId!,
amount: 4990, // integer cents
externalReference: order.id,
})
// Reading and writing the billing rows goes through the store.
const store = getBillingStore()See The billing store above and Getting started → Make your first charge.
Client polling
The browser-facing status endpoint and the React hook that polls it — waiting for a Pix or boleto to settle without hand-writing the loop, and without handing one customer's payment to another.
Invoices
Emit invoices attached to a charge or subscription, through an invoice provider that is fully independent of the payment gateway.