Agora

Dashboard

The embedded billing console — a React SPA plus the JSON API it runs on, mounted into your AdonisJS routes. Lead with what needs attention today, read revenue and subscriptions, find one payment by your own reference, and refund, retry or close a dispute from the same page.

The dashboard mounts a billing console into your app: a React single-page app plus the JSON API it runs on. It is built for daily management — someone opening it in the morning to ask is money coming in, what needs my attention, which customer is stuck, and can I fix it from here — not for debugging a single gateway call. Raw payloads, signature traces and timings belong in @adonis-agora/telescope; you will not find them here.

Every screen reads through the billing store, so it answers instantly and shows exactly what your database recorded. Only one action leaves your app at all: a refund calls the gateway that took the payment. A webhook retry re-runs your own handlers, and resolving a dispute writes a local row and sends nothing anywhere.

node ace configure @adonis-agora/payments

configure registers @adonis-agora/payments/dashboard_provider in adonisrc.ts and publishes config/payments_dashboard.ts. The provider registers the routes on boot — nothing else to wire, and no extra package to install.

It mounts at /payments, next to the machine endpoints

The dashboard shares its prefix with POST /payments/webhook/:provider and GET /payments/client/status, and that is safe for exactly one reason: every route the provider registers is an exact path/payments, /payments/assets/:file, /payments/api/…, and the login routes. There is no SPA catch-all, so the dashboard's authorization guard can never end up in front of a gateway's delivery endpoint.

If you fork this and add a wildcard for client-side routing, the webhook route is what it swallows. A webhook answering 403 to Stripe looks exactly like a gateway outage from the outside, and you find out by reading someone else's retry logs. A test in the package asserts no route here contains a wildcard, for that reason.

What it shows

ScreenReadsWhat it answers
OverviewbillingHealth + billingOverviewWhat needs attention today, then revenue — gross and net of refunds — and active subscriptions for a period, plus usage per meter.
PaymentslistPaymentsA page of payments, filterable by status and gateway, searchable by your reference or the gateway id — which charge failed, and when. Refund from here, and open one row in full.
CustomerslistCustomersThe billing_customers mapping — which app user a cus_… belongs to, filterable by owner, gateway id and provider.
SubscriptionslistSubscriptionsWho is past_due or paused, on which plan, with the trial and period end dates.
DisputeslistDisputes / listDisputesDueWithinEvery chargeback and pre-chargeback alert, and — the reason the screen exists — which evidence windows close next. Record how one ended from here.
Webhook eventslistWebhookEventsThe ledger, defaulting to failed, filterable by event type, with each handler's error rendered in full. Retry from here.
ActivitylistAuditEventsWho refunded what, who closed which dispute, and every delivery this endpoint refused.

Linking to a screen

The screen you are on is in the URL fragment, so the back button works, a reload comes back to the same place, and a view can be pasted into a ticket:

HashOpens
#/webhooksa screen, on its own default filter
#/webhooks?status=faileda screen narrowed to a status — what the health panel's buttons write
#/activity?status=webhook.rejectedthe activity screen narrowed to one action
#/payments?customer=cus_9f2every payment recorded for one gateway customer
#/payments/pay_8f2…the payments screen with that one payment open in full

It is a fragment rather than a path on purpose. The provider registers <path> as an exact route and no catch-all — the prefix is shared with the package's own webhook endpoints — so <path>/webhooks would be a 404 on reload. The fragment never reaches the server, and it works at any mount path with nothing to configure. Anything it does not recognise opens the overview.

The health panel

The first thing on the Overview, above the revenue tiles, is billingHealth — the same six checks node ace payments:health runs, on the same thresholds:

  • Events claimed and never finished. Nothing is consuming the dispatcher; the worker is not running.
  • Events the dispatcher gave up on. Handlers threw and retries ran out, so those events never took effect.
  • Charges created that never confirmed. What a webhook endpoint that stopped being reachable looks like from the inside.
  • Dispute windows closing. An open chargeback whose evidence deadline is inside the next three days. The panel names each one — gateway, dispute id, how long is left — and its button opens the Disputes screen on exactly those rows.
  • Disputes still open and unanswered. No deadline required, which is the point: most gateways publish none, and on those installs the check above reports zero forever. The panel names these too, oldest first.
  • Deliveries the endpoint refused, last 24 h. A bad signature, an unparsable body, a provider nobody configured. Usually a rotated webhook secret that never reached the deployment — and invisible everywhere else, because a rejected delivery never becomes a ledger row.

Every one of these is a silent failure: the endpoints keep answering 200 while revenue quietly stops landing. A healthy install shows one green line. An unhealthy one leads with the count, says what it means, names which provider and event type is failing, and links straight to the rows.

Why health sits above revenue

Revenue answers "how did we do". Health answers "is anything broken right now". On the morning the worker is dead, the revenue tile still shows yesterday's healthy number.

The two revenue tiles

Under the health panel the console shows revenue twice, because there are two true answers:

  • Revenue (gross) — paid payments settled in the window, with refunds not subtracted.
  • Revenue (net) — the same payments minus what went back (amount - refunded_amount).

A partial refund keeps its payment row paid at the full amount and records what came back separately, so it moves the second tile and leaves the first alone. That is why both are on screen and both say which one they are: for two releases the console showed only the gross figure under the word "Revenue", and a charge that was half refunded was reported as fully earned with nothing disagreeing. Both tiles are integer minor units formatted once, at render.

Filtering by gateway

Every list filters by provider as well as status, and the list of providers comes from your own data — not from the eighteen drivers this package ships. With Stripe and Pix in production, the filter offers Stripe and Pix.

The store's list queries have no provider column filter, so the provider narrowing is applied over pages read from the store, up to a bounded scan. When that scan stops short the console says so, because "no Asaas failures" and "no Asaas failures in the rows I looked at" are different answers. The Customers and Activity lists report no such caveat, and that is deliberate: every filter they offer is a column the store applies, so there is no bounded scan behind them and claiming the caveat would be claiming one that does not apply.

The webhook-event type filter is built the same way — GET <path>/api/providers reports the event types this install has actually received, rather than the twenty this package can emit. A filter offering twenty that return nothing hides the three that do not.

Finding one payment

The question a console gets opened for is "did this person's payment land?", and the operator holds one of three ids: the app's own reference for the order, the gateway's id for the payment, or the gateway's id for the customer. The Payments screen has one search box that tries the app's reference first and the gateway id second — an operator pasting from a support ticket does not know which of the two they are holding.

All three lookups are exact. They are join keys, and letting order-4 return order-42 is a wrong answer to a question about money.

Every payment row now carries the app's externalReference, the refundedAmount, and an owner. A gateway customer nobody mapped reads as unmapped, not as a blank: it means charges are landing that this console can never attribute to a person. The mapping is written by ensureCustomer({ store, owner }) and by nothing else.

One payment in full

Clicking a row opens GET <path>/api/payments/:gatewayId — the current state, the owner, the disputes filed against it, the ledger rows whose stored delivery names it, and who refunded it from this console.

It is not a history, and it does not claim to be

billing_payments is a single mutable row upserted in place. Its earlier states are recorded nowhere, so "what changed on this payment, and when?" has no answer here.

The ledger strand in particular is a CAST(payload AS TEXT) LIKE scan over the stored payloads, because nothing links a ledger row to a payment — the link lives inside the payload. The response says so on the wire (events.matchedBy: 'payload-substring') and the panel says so on screen: it is unindexed, it can over-match a delivery that merely mentions the id, and it cannot see a delivery that never stored it. Reading "3 events" as "exactly the 3 events that touched this payment" is the mistake that caveat exists to stop.

No history table was invented for this. The honest fix is a payment_gateway_id column the processor fills on the way in — a write path this does not touch.

The stored payload itself is still never returned. The timeline says an event arrived and what type it was; what was inside it is telescope's job.

Money

Amounts are integer minor units everywhere — on the wire too — and the divisor is not always 100. The SPA renders with the currency's own exponent, the same table src/money.ts uses, so a ¥1990 charge shows as ¥1,990 rather than ¥19.90 and a KWD amount keeps its three decimals.

Disputes

A chargeback has a clock, and it is the only clock in this package that runs against you: every network gives a fixed window to respond, and missing it loses the money by default rather than on the merits. So the screen is two panels over the same table, in this order:

  1. Evidence windows closing — the work list. Only the OPEN disputes that carry a deadline, soonest first, with the deadline as the leading column and a countdown in hours beside it (in 5 hours, not "today" — the difference is a morning). The horizon picker offers 24h, 3 days, 7 days and 30 days, and opens on 3 days, the same horizon payments:health alerts on. The count above the table is the server's dueWithin.total, unbounded by the page.
  2. All disputes — the log. Every dispute newest first, filterable by status (warning, open, under_review, then the resolutions) and by gateway.

The work list is deliberately not filterable by gateway. It is the one list whose whole job is that nothing gets missed, and narrowing it to Stripe while an Asaas window shuts tonight is exactly the failure it exists to prevent. Filter the log underneath; it is the same table.

Three things the rows are careful about:

  • A window that already shut still appears, marked past due with how long ago. It is still open and still unanswered, and going quiet the moment it expires reads as resolved.
  • A dispute with no deadline is not in the work list at all, and in the log its deadline cell says the gateway sends no deadline rather than showing a dash. evidence_due_by is null when the gateway told us nothing — several send no date, and Woovi's three-day rule is policy rather than a field — and "we were told nothing" is not "there is time".
  • A dispute can name no money. Stripe's early fraud warning carries no amount, so the cell says so instead of rendering R$ 0,00, which would be a claim about an amount that does not exist.

warning is not a chargeback and does not look like one: nothing has been pulled back, a refund now stops the debit from ever being filed, and the row says no money moved in words rather than leaving that to a hue.

There is still no Fight button, on purpose

No "fight this", no "accept", no "refund from the dispute". Not an unfinished screen — a decision. Whether to submit evidence or simply refund turns on your margin, the customer's value, the dispute fee, the evidence your app actually holds, and the chargeback ratio that puts a merchant into a card network's monitoring programme — which is why refunding a dispute you would have won is sometimes right. That decision belongs in your code, against your data, not behind a console button someone can press without any of it. Submitting evidence is submitDisputeEvidence on the driver.

The one action the screen does have — Resolve — is a different thing entirely: it records an outcome that already happened at the bank. See below.

Actions

Three things can be changed from the console. All are POST (a refund reachable by URL is a refund a crawler can trigger) and all run through the same authorize guard — and the dashboardAuth session, when configured — as every read.

They also carry a CSRF token. @adonisjs/shield guards every state-changing route of the host app, and the SPA sent none — so both POSTs were refused before they ever reached the console's own guard, and the button did nothing with nothing on screen saying why. Shield publishes the token as an XSRF-TOKEN cookie precisely so a browser client can echo it in x-xsrf-token, and the SPA now does. No cookie means no header, which is the right answer for a host that does not run shield. Reads are untouched: CSRF only guards mutations.

Refund a payment

Offered on a paid row. The dialog restates the amount and the customer before doing anything, takes an optional partial amount, and disables itself for the round-trip so a double click is not a double refund. The refund goes through the driver that took the payment, resolved by name from your config/payments.ts:

  • A gateway with no refund API (Woovi/OpenPix) is refused before the call, with a sentence naming the gateway.
  • A provider no longer in your config answers 503 with the manager's own message, which lists the providers that are configured.
  • A gateway that refuses answers 502 carrying its message verbatim.

The local row is not rewritten. The gateway confirms a refund with a payment.refunded webhook, and that is what moves the row to refunded — writing it optimistically would show a refund that may never have settled.

A successful refund writes an audit row naming the person who authorised it, the amount asked for and whether it was partial. enforce() had already verified exactly who was asking; until now that was thrown away and the only record was a diagnostic carrying a gateway id and an amount. A console with no dashboardAuth records actor: null — "unattributed", never an invented "system". A refund the gateway refused writes nothing: an audit of refunds that never happened is an audit nobody can trust.

Recording how a dispute ended

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 sat at open forever, the deadline check stayed red, and a fifteen-minute cron logged the same failure until nobody read it, burying every other finding with it.

POST <path>/api/disputes/:gatewayId/resolve records the ending: a finished status, an outcome, a note, and who said so.

{ "status": "lost", "outcome": "lost", "note": "acquirer sided with the cardholder" }

status must be one of the finished dispute statuses — the full DISPUTE_STATUSES list minus the open ones (warning, open, under_review). A "resolve" that could put a row back into open is not a resolution, it is an edit box over a money table. outcome defaults to the status.

Nothing is sent to a gateway. The dialog says so twice: the decision was made at the bank, this writes down which way it went. The write goes through saveDispute, whose absent-fields-are-left- alone rule means the deadline and the reason the opening event carried survive it. It also writes a dispute.resolved audit row — on a gateway that publishes no closing event, a human is the entire provenance of that outcome.

Retry a failed webhook event

The one thing that closes the loop after you fix a handler bug: a failed row means the effect that event described never happened and nothing will retry it. Retry re-runs the event through the same WebhookProcessor your app uses — built-in store sync plus your own handlers.

It is safe to repeat. The ledger re-claims a failed event and refuses an in-flight or already processed one, which is the same guard that makes a gateway redelivery a no-op.

Every gateway replays, signed ones included. The event is rebuilt from the ledger row's own two columns — the raw payload and the normalized event recorded beside it — and handed straight to the processor. No driver is involved and parseWebhook is never called again, so there is no signature to re-verify from headers the ledger does not keep. Rebuilding through the driver would make Stripe, Adyen and every other signing gateway answer 422, leaving retry usable only on the minority that do not sign.

Events recorded before the `normalized` column existed

billing_webhook_events.normalized arrived after the first release. There is nothing to run for it — the library owns its schema and adds the column on the next boot — but the rows already in the ledger are not backfilled, because the normalized event was never stored to backfill from.

So a row older than the column has nothing to replay (the raw payload alone is not enough, and rebuilding through parseWebhook would re-verify a signature computed from headers the ledger never kept). Retry answers 422 saying so and leaves the ledger row exactly as it was, original error and all. Redeliver those from the gateway's own dashboard; everything recorded since replays normally.

Configuration

config/payments_dashboard.ts
import { defineConfig } from '@adonis-agora/payments/dashboard'

export default defineConfig({
  // enabled: true,
  // path: '/payments',
  // currency: 'BRL',
  // authorize: (ctx) => ctx.auth.user?.isAdmin === true,
})
OptionDefaultDescription
enabledtrueWhen false, no routes are registered at all.
path'/payments'Route prefix. The HTML serves at the root; the JSON API at <path>/api.
currency'BRL'ISO 4217 code the money columns render in. Does not change what is stored.
authorizesee belowPer-request guard: (ctx: HttpContext) => boolean | Promise<boolean>.
dashboardAuthabsentOpt-in session layer on top of authorize. The console's Sign out link exists only when this is set — it is the only configuration that registers a <path>/logout route.

Authorization

The default authorize is open outside production. In production it requires a bearer token equal to PAYMENTS_DASHBOARD_TOKEN, compared in constant time, and denies when the variable is unset — it fails closed. The token is read from an Authorization: Bearer <token> header, an x-payments-token header, or a ?token= query param.

Replace it with your own guard; a denied request gets a 403 — JSON for an API call, the access-denied page for a browser:

export default defineConfig({
  authorize: async (ctx) => {
    await ctx.auth.check()
    return ctx.auth.user?.isAdmin === true
  },
})

With @adonis-agora/authz the shared helper reads your user and roles for you — the same one every Agora console takes, so one RBAC gate reads identically across durable, telescope, media, agent and payments:

import { authorizeByRoles } from '@adonis-agora/authz'

export default defineConfig({
  authorize: authorizeByRoles({ roles: ['ADMIN'] }),
})

This console shows revenue — and can move it

Payment amounts and customer ids are reachable through it, and the refund action moves money at your gateway. The default token gate is a floor for staging, not an answer for production — put a real guard in front of it.

The access-denied page

A refused API request gets JSON (403 { "error": "forbidden" }, or 401 { "error": "unauthorized", "auth": { "modes": [...] } } without a session) — that is what the console's own fetch calls expect. A refused page navigation — the console shell, its assets, or the Mode-A-only "open this from your app" case — gets a real page instead: a dark card in the console's visual language showing the status, a sentence explaining the refusal, a "Back to app" link and, when dashboardAuth.login is configured, a "Sign in" button. It carries no inline script, so a nonce'd script-src CSP cannot break it (its one inline <style> picks up @adonisjs/shield's request nonce).

Tweak it with accessDenied — every field optional:

config/payments_dashboard.ts
export default defineConfig({
  accessDenied: {
    brand: 'Entre Textos',           // eyebrow + <title>; default "Payments"
    title: 'Sem acesso',             // default depends on the refusal
    message: 'Peça ao admin para liberar o console de pagamentos.',
    homeHref: '/admin',              // "Back to app"; default "/", `false` hides it
    homeLabel: 'Voltar',
    loginHref: '/entrar',            // default: the built-in login page when one exists
    loginLabel: 'Entrar',
    accent: '#f59e0b',               // any CSS colour; default: the console's cyan
  },
})

Or replace it. Pass a function and it receives the refusal (status, reason'forbidden', 'unauthenticated' or 'session-required'basePath, loginHref, and the CSP nonce when there is one) plus the HttpContext. Return an HTML string to have it served with the right status; answer the request yourself and return nothing to make the provider stand down:

config/payments_dashboard.ts
export default defineConfig({
  accessDenied: (info, ctx) => {
    if (info.reason === 'unauthenticated') {
      ctx.response.redirect(`/login?next=${encodeURIComponent(info.basePath)}`)
      return
    }
    return `<!doctype html><title>${info.status}</title><h1>Sem acesso</h1>`
  },
})

An authorize hook that already wrote a redirect still wins — the provider never overwrites a location header, with or without accessDenied.

A session instead of a token

dashboardAuth adds a signed session cookie on top of authorize (both must pass). It is opt-in: omit it and there are no auth routes and no cookie. Two hooks, either or both:

  • login — the console serves its own sign-in page at <path>/login and calls your hook to verify the credentials. The right shape when the console stands alone.
  • session — your already-authenticated app calls the console's session endpoint, and your hook reads whatever you already trust to identify the operator. No second login.
config/payments_dashboard.ts
export default defineConfig({
  dashboardAuth: {
    secret: env.get('PAYMENTS_DASHBOARD_SECRET'), // HMAC-SHA256 key, 32+ bytes
    ttl: '8h',
    login: async (username, password) => {
      const user = await User.verifyCredentials(username, password).catch(() => null)
      if (!user?.isAdmin) return null // null denies
      return { id: String(user.id), name: user.fullName }
    },
  },
})

With a session configured, an unauthenticated page navigation is redirected (302) to the login page and an unauthenticated API call gets 401. The login page is a plain HTML form that works without JavaScript (a form submit is answered with a redirect — to the page the operator came from, or back to the form with the error shown) and whose inline script and style carry @adonisjs/shield's request nonce, so a script-src 'self' @nonce policy keeps it working. secret is always required, plus at least one of login/session — a dashboardAuth missing either fails at boot rather than serving an ungated console.

Content Security Policy

The console works under a strict CSP. Everything it needs is same-origin: the bundle and the stylesheet are served from <path>/assets/, the API is <path>/api, and the deployment config (mount path, API base, currency, auth surface) reaches the page as a <script type="application/json"> data block, which is never executed and so cannot be refused. script-src 'self' is enough — including @adonisjs/shield's scriptSrc: ["'self'", '@nonce'], the recommended setup.

Before 0.4.1 the config was injected as an inline <script> assigning window.__PAYMENTS_*__ globals. Under a nonce-only script-src the browser drops that script without a word, the SPA falls back to its default mount and every request 404s from a console that rendered perfectly well. If you saw that, this is why; upgrading fixes it with no change on your side.

Two things stay optional. The page links the JetBrains Mono / Space Grotesk web fonts from Google; a style-src/font-src that does not allow fonts.googleapis.com / fonts.gstatic.com just falls back to the system monospace and sans — nothing breaks. And img-src is never needed: the favicon is an inline data: SVG, which img-src 'self' data: covers.

The JSON API

The SPA is a client of a plain API, and so can you be — the same guard covers it:

RouteQuery / bodyReturns
GET <path>/api/healththe billingHealth checks plus the failure breakdown
GET <path>/api/overviewperiod, from, tobillingOverview metrics for the period
GET <path>/api/paymentsstatus, provider, reference, gatewayId, customerId, page, sizea page of payments, newest first, each with externalReference, refundedAmount and an owner
GET <path>/api/payments/:gatewayIdone payment: current state, owner, disputes, ledger rows naming it (with events.matchedBy), and its audit rows
GET <path>/api/customersprovider, ownerType, ownerId, gatewayId, page, sizea page of the owner mapping, newest first
GET <path>/api/disputesstatus, provider, page, sizea page of disputes, newest first
GET <path>/api/disputesdueWithin (hours), provider, page, sizeonly the OPEN windows closing inside the horizon, soonest first, plus the full dueWithin.total
GET <path>/api/subscriptionsstatus, provider, page, sizea page of subscriptions, plus the whole-table past_due count
GET <path>/api/webhook-eventsstatus, provider, type, page, sizea page of the ledger, newest first
GET <path>/api/auditaction, actor, provider, subjectType, subjectId, page, sizethe audit trail, newest first, plus the action filter list
GET <path>/api/providersthe gateways and the event types present in your data
POST <path>/api/payments/:gatewayId/refund{ amount? }the gateway's refund; amount is integer minor units
POST <path>/api/disputes/:gatewayId/resolve{ status, outcome?, note? }the closed dispute plus the audit row; sends nothing to a gateway
POST <path>/api/webhook-events/:gatewayEventId/retry{ status: 'processed' }

?reference= also accepts ?externalReference=, so a link built from either keeps working. All three payment lookups are exact matches. The response echoes them back under filters, so the SPA can say "no payment carries reference X" rather than "no payments".

Paging is ?page=1&size=25page is 1-based, size is the page size — the same shape @adonis-agora/filter takes, so every Agora console reads the same query string. Each list echoes it back under meta ({ page, size, count, … }) — Lucid's own spelling, since .paginate() answers { meta, data }, and the key every Agora console reads. count === size is the only "there might be more" signal, because nothing here counts the full match set. size is clamped (50 by default, 200 maximum), so an unbounded page size from a query string cannot select the whole table. A provider-filtered page also reports scanned and truncated. The customers and audit pages report neither, because every filter they offer is a column the store applies — there is no bounded scan behind them to warn about.

?dueWithin with no value uses the same 72-hour horizon payments:health alerts on, so the console and the cron agree about "soon". A value that is present and unreadable answers 400 rather than quietly falling back to the default — you would have no way to tell which question you asked. The dueWithin.total beside the page is a separate count, unbounded by size: a full page says nothing about how many more windows are closing, and that number is the one you plan the day around.

The action responses distinguish their failures rather than collapsing them into 500: 404 for a row that is not there, 409 for a row in the wrong state (a payment that is not paid, an event that is not failed), 422 for an event with no stored normalized form to rebuild from, 502 for a gateway or handler that refused, and 503 when the payments manager is not reachable at all.

When the billing layer is disabled (billing.enabled: false) the API answers 503 rather than 500 — the store being absent is a deployment state, not a crash.

Activity — the trail nothing else keeps

Three kinds of row, each of which is otherwise only a diagnostic — a log line, gone by the time somebody asks:

ActionWritten when
webhook.rejectedthe endpoint answered 400. The one that matters most: a rejected delivery never becomes a ledger row, so a rotated webhook secret looks exactly like a quiet week while every refund, chargeback and dispute closure is dropped on the floor
payment.refundedsomeone refunded from this console. The payment row moves only when the gateway's webhook lands, and that row names no person
dispute.resolvedsomeone closed a dispute the gateway will never close itself

actor is null when nothing authorised it in a human sense — a rejected delivery has none, and a console with no dashboardAuth cannot name one. null means unattributed, never "the system".

action is a free string in the column, so an app recording its own actions with store.recordAuditEvent(...) sees them here too; the filter list is a UI convenience, not a validation whitelist.

An install that upgraded before running the schema

billing_audit_events is a new table, so CREATE TABLE IF NOT EXISTS carries it to an existing install exactly as well as to a fresh one — nothing from the post-ship ALTER phase is involved. An app that upgrades the package before the table exists keeps working: every audit write is additional to an action that already happened, so a missing table skips the note and answers null rather than failing a refund the gateway already accepted. The screen is then simply empty.

Without the console

Everything the dashboard renders is a plain function over the store, so a custom console, a Metabase query or a Slack digest can read the same data without mounting anything. See Building your own.

On this page