Agora
Patterns

Building your own dashboard

The headless data layer behind the console — billingOverview and the store's read API, what each metric counts, and how to render cents without leaking the division into the arithmetic.

The package ships a console already — mount it and you are done. This page is for when you want the numbers somewhere else: inside your own admin, in a Slack digest, on a status page.

Everything that console renders is a plain function over the billing store, and those functions are exported. billingOverview is the first one: pure store queries — no gateway calls — so it is fast, works headless, and needs no network in tests.

app/controllers/billing_dashboard_controller.ts
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { billingOverview, LucidBillingStore } from '@adonis-agora/payments'
import { DateTime } from 'luxon'

@inject()
export default class BillingDashboardController {
  constructor(private store: LucidBillingStore) {}

  async index({ request }: HttpContext) {
    const from = DateTime.fromISO(request.input('from')).startOf('day')
    const to = DateTime.fromISO(request.input('to')).endOf('day')

    return billingOverview(this.store, { from: from.toJSDate(), to: to.toJSDate() })
  }
}

What comes back

{
  period: { from: Date, to: Date },
  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 },
    { key: 'meter:storage_gb',     label: 'Usage · storage_gb',              value: 3_042 },
  ]
}

A flat list rather than a fixed object, because the meters are yours — one entry per meter that saw usage in the window, keyed meter:<name>.

MetricCounts
revenuegross: the sum of amount on paid payments whose paidAt falls in the window, in cents
net_revenuethe same rows, net: amount - COALESCE(refunded_amount, 0), in the same cents
active_subscriptionssubscriptions currently active or trialing — not windowed
meter:<name>quantity consumed for that meter in the window

The two revenue lines are the same query with a different figure summed, and the pair exists because a partially refunded charge stays paid and keeps its full amount — what went back is recorded separately, on billing_payments.refunded_amount. So the charge counts at full value in revenue and at its net in net_revenue, and a screen showing one of them has to say which: a tile labelled only "Revenue" over the gross figure is money that came back still being reported as earned. Both are integers in minor units; subtract and add freely, divide only at the edge.

Two details worth knowing before you put a number on a screen:

Revenue windows on paidAt, not on when the charge was created

A Pix charge created on the 31st and paid on the 1st counts toward the second month. That is almost always what you want from a revenue figure, and almost never what a created_at filter would have given you — so do not reimplement this with your own query and expect the same total.

active_subscriptions ignores the period, and includes trials

It is a current count, not a historical one — the same number regardless of from/to — and a subscription still in its trial is counted. Label it as "now" in the UI, and split trials out yourself if you bill on converted subscribers:

countActiveSubscriptions() deliberately includes both; for a converted-only count, keep your own active-only tally alongside the subscription rows your app already writes.

Rendering it

Revenue is in the currency's minor units, and the divisor is not always 100 — JPY has no minor unit at all and KWD has three. Shift once, at the edge, using the package's own exponent table rather than a hardcoded / 100:

app/components/billing_overview.tsx
import { currencyExponent } from '@adonis-agora/payments'

const money = (minorUnits: number, currency = 'brl') =>
  new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency.toUpperCase() }).format(
    minorUnits / 10 ** currencyExponent(currency),
  )

export function BillingOverview({ overview }: { overview: BillingOverview }) {
  return (
    <dl>
      {overview.metrics.map((metric) => (
        <div key={metric.key}>
          <dt>{metric.label}</dt>
          <dd>
            {/* BOTH revenue keys are money. A new money metric rendered as a plain count is
                the figure wrong by 100×. */}
            {metric.key === 'revenue' || metric.key === 'net_revenue'
              ? money(metric.value)
              : metric.value.toLocaleString('pt-BR')}
          </dd>
        </div>
      ))}
    </dl>
  )
}

See Money for why the division belongs there and nowhere else.

Going beyond it

billingOverview is deliberately small — the metrics almost every dashboard opens with. The store answers the narrower questions directly, and going through it rather than the tables is what keeps your dashboard working when someone points billing.store somewhere else:

// Which charges failed, and when
const failed = await store.listPayments({ status: 'failed', size: 20 })

// How many charges were created and never confirmed
const stranded = await store.countPayments({ status: 'pending', createdBefore: twoHoursAgo })

// Which gateway and which event type is failing
const breakdown = await store.webhookEventBreakdown({ status: 'failed', createdAfter: yesterday })
// [{ provider: 'stripe', type: 'payment.succeeded', count: 4 }]

// Chargeback windows closing in the next three days, soonest first — and the unbounded
// count behind them, because a count read off a capped page saturates at the cap.
const closing = await store.listDisputesDueWithin({ withinHours: 72, size: 20 })
const closingTotal = await store.countDisputesDueWithin({ withinHours: 72 })

// Every dispute, newest first — including the ones that carry no deadline and therefore
// never appear above. `status` takes any DisputeStatus; OPEN_DISPUTE_STATUSES is the
// unresolved set.
const disputes = await store.listDisputes({ status: 'warning', size: 50 })

// Unanswered disputes, OLDEST first, deadline or no deadline. On a gateway that publishes
// none, this is the only read that can see a chargeback at all.
const unanswered = await store.listOpenDisputes({ size: 20 })
const unansweredTotal = await store.countOpenDisputes({})

// One payment by YOUR id, and the owner behind a page of them in one read.
const [payment] = await store.listPayments({ externalReference: 'order-1042' })
const owners = await store.listCustomersByGatewayIds(rows.map((row) => row.customerId))

// Deliveries the endpoint refused — invisible in the ledger, because they never became one.
const rejected = await store.listAuditEvents({ action: 'webhook.rejected', size: 20 })

The full read API is listed under Reading the billing tables, and billingHealth(store) rolls the operational ones into a single report — its six checks plus, on deadlines, the closing dispute windows by name and, on openDisputes, the unanswered ones oldest first.

Two dispute fields are nullable and must not render as zero

evidenceDueBy is null when the gateway sent no deadline — eleven of the eighteen never do — and amount is null when the alert named no money (Stripe's early fraud warning carries neither amount nor currency). Both mean "we were told nothing". Say so in the cell; a dash reads as slack and R$ 0,00 is a claim about an amount that does not exist. See Disputes.

Do not query the billing tables directly

billing.store is a configured seam — an app can point it at its own implementation, and one day yours might. A dashboard built on db.from('billing_payments') silently reads the wrong database the day that happens, and reads nothing at all if the store is not Lucid. Everything the console shows is reachable through the store; if something you need is not, that is a gap worth reporting.

Testing it

The in-memory store makes the whole thing a unit test:

tests/unit/billing_overview.spec.ts
import { billingOverview } from '@adonis-agora/payments'
import { InMemoryBillingStore } from '@adonis-agora/payments/testing'

const store = new InMemoryBillingStore()
await store.savePayment({
  gatewayId: 'pi_1',
  provider: 'stripe',
  status: 'paid',
  amount: 1990,
  currency: 'brl',
  paidAt: new Date(),
})

const overview = await billingOverview(store, { from, to })
const gross = overview.metrics.find((metric) => metric.key === 'revenue')
const net = overview.metrics.find((metric) => metric.key === 'net_revenue')

assert.equal(gross?.value, 1990)
assert.equal(net?.value, 1990)   // nothing refunded yet — the two only diverge once something is

// Refund R$5 of it: the row stays `paid` at 1990, and only the net figure moves.
await store.savePayment({
  gatewayId: 'pi_1',
  provider: 'stripe',
  status: 'paid',
  amount: 1990,
  currency: 'brl',
  refundedAmount: 500,
})

const after = await billingOverview(store, { from, to })
assert.equal(after.metrics.find((m) => m.key === 'revenue')?.value, 1990)
assert.equal(after.metrics.find((m) => m.key === 'net_revenue')?.value, 1490)

On this page