Agora

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:

TablePurpose
billing_customersthe polymorphic mapping between your rows and their gateway customers — opt-in (see below)
billing_subscriptionssubscriptions — status, plan_id, trial_ends_at, ends_at
billing_paymentsevery payment, keyed by gateway id, indexed on external_referenceyour id for the charge — and carrying refunded_amount
billing_webhook_eventsthe idempotency ledger, keyed by gateway event id, storing the raw payload and the normalized event
billing_disputeschargebacks and pre-chargeback alerts, keyed by the dispute's gateway id — and evidence_due_by, the deadline to answer
billing_usage_eventsmetered consumption, one row per event — the input to usage-based billing
billing_audit_eventswhat 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_ACTIONSWritten 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, deliberately

Rows 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 evidenceDueBy is never in it. Plenty of gateways send no deadline, and null means "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 on

Oldest 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 user
app/models/user.ts
import { 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:

app/services/subscribe_service.ts
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:

app/services/subscribe_service.ts
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:

app/services/subscribe_service.ts
@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:

app/services/subscribe_service.ts
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.

start/payments.ts
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:

ValueBehaviourSurvives a restart
'auto' (default)durable when its provider is registered; otherwise in-processdepends on what it picked
'durable'a durable workflow run — throws when durable is missingyes
'in-process'inline, retrying in the backgroundno

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:

app/services/usage_service.ts
// 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:

app/services/usage_service.ts
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 amounts

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

app/controllers/billing_dashboard_controller.ts
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:

app/services/usage_service.ts
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:

config/payments.ts
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(...).

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

On this page