Metered billing
Record consumption as it happens, roll it up per meter for a period, price it against per-meter rates with an included allowance, and charge the overage exactly once.
Usage-based plans split into three steps that are deliberately separate: recording, reporting and pricing. Each is a plain store call, so none of them needs a gateway.
1. Record as it happens
import { inject } from '@adonisjs/core'
import { LucidBillingStore } from '@adonis-agora/payments'
@inject()
export default class UsageService {
constructor(private store: LucidBillingStore) {}
async recordApiCall(subscriptionId: string) {
await this.store.recordUsage({ subscriptionId, meter: 'api_calls', quantity: 1 })
}
async recordStorage(subscriptionId: string, gigabytes: number) {
await this.store.recordUsage({ subscriptionId, meter: 'storage_gb', quantity: gigabytes })
}
}The provider binds the store in the container, so it injects like any other dependency — you never construct it. See The billing store.
Rows land in billing_usage_events. Meter names are yours — the library never validates them, and
a meter nobody prices is simply ignored later.
One row per event, not a running total
Recording increments rather than a counter is what makes the period boundary meaningful: you can re-roll any window after the fact, and a late-arriving event lands in the period it belongs to.
2. Roll up a period
const usage = await store.usageReport({
subscriptionId: subscription.id,
from: periodStart,
to: periodEnd,
})
// [{ meter: 'api_calls', quantity: 412 }, { meter: 'storage_gb', quantity: 18 }]usageReport also filters by customerId and meter, so the same call backs a per-customer
dashboard or a single-meter chart.
3. Price it
meteredBill turns a report into money, with a per-unit rate and an optional included allowance:
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 }, // R$ 2,00 per GB
],
})
// bill.lines → [{ meter: 'api_calls', quantity: 412, billable: 0, amount: 0 }, …]
// bill.total → cents to chargeEach line carries quantity (what was consumed), billable (what exceeded the allowance) and
amount (billable × rate, in cents). rate is cents per unit and may be fractional — 0.5 is
half a cent per call — while the resulting amount is a whole number of cents.
A meter with usage but no matching rate is skipped, not billed at zero. That is deliberate: an
unpriced meter is usually an instrumentation meter, and silently charging for it would be worse than
ignoring it. It also means a typo in a rate's meter name produces a bill of zero rather than an
error — worth asserting on bill.lines.length in a test.
4. Charge the overage
if (bill.total > 0) {
const period = periodEnd.toISODate()
await getPayments().driver('pix').charge({
customerId: subscription.customerId,
amount: bill.total,
description: `Consumo · ${periodStart.toISODate()} – ${period}`,
externalReference: `overage:${subscription.id}:${period}`,
idempotencyKey: `overage:${subscription.id}:${period}`,
})
}Key both on the period, never on `now`
externalReference and idempotencyKey derive from the subscription and the period end — so a job
that runs twice, or is replayed after a failure, charges once. A key built from a timestamp or a
random value protects nothing.
Putting it on a schedule
import { BaseWorkflow, type WorkflowCtx } from '@adonis-agora/durable'
export default class ClosePeriodWorkflow extends BaseWorkflow {
static workflow = { name: 'close-billing-period', version: '1' }
static schedule = { cron: '0 3 1 * *' } // 03:00 on the 1st
async run(ctx: WorkflowCtx) {
const subscriptions = await ctx.localStep('list-active', () => listActiveSubscriptions())
for (const subscription of subscriptions) {
// One checkpointed step per subscription: a crash resumes at the one that failed
// instead of re-charging everyone who already succeeded.
await ctx.localStep(`bill-${subscription.id}`, () => billPeriodFor(subscription))
}
}
}The per-subscription step matters more than it looks: without it, a failure halfway through a thousand subscriptions means re-running the whole list, and only the period-derived idempotency key stands between you and a thousand duplicate charges. With it, the completed ones replay their saved result and only the failure re-executes.
Testing it
The in-memory store mirrors the real one, so the whole chain runs with no database:
import { meteredBillForSubscription } from '@adonis-agora/payments'
import { InMemoryBillingStore } from '@adonis-agora/payments/testing'
const store = new InMemoryBillingStore()
await store.recordUsage({ subscriptionId: 'sub_1', meter: 'api_calls', quantity: 1400 })
const bill = await meteredBillForSubscription(store, {
subscriptionId: 'sub_1',
from: start,
to: end,
rates: [{ meter: 'api_calls', rate: 0.5, included: 1000 }],
})
assert.equal(bill.lines[0].billable, 400) // 1400 − 1000 included
assert.equal(bill.total, 200) // 400 × 0.5 centsReacting to payments
Where the business logic lives — the convention folder, billing.handlers in the config, a diagnostics subscriber, or a durable workflow that subscribes itself — what each does when it throws, and how to make the grant safe to run twice.
Marketplace splits
Share a charge across recipients — Asaas splits by percent or fixed amount, Woovi subaccounts keyed by Pix key — and the integer arithmetic that keeps a computed split summing to the total.