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/paymentsconfigure 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
| Screen | Reads | What it answers |
|---|---|---|
| Overview | billingHealth + billingOverview | What needs attention today, then revenue — gross and net of refunds — and active subscriptions for a period, plus usage per meter. |
| Payments | listPayments | A 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. |
| Customers | listCustomers | The billing_customers mapping — which app user a cus_… belongs to, filterable by owner, gateway id and provider. |
| Subscriptions | listSubscriptions | Who is past_due or paused, on which plan, with the trial and period end dates. |
| Disputes | listDisputes / listDisputesDueWithin | Every chargeback and pre-chargeback alert, and — the reason the screen exists — which evidence windows close next. Record how one ended from here. |
| Webhook events | listWebhookEvents | The ledger, defaulting to failed, filterable by event type, with each handler's error rendered in full. Retry from here. |
| Activity | listAuditEvents | Who 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:
| Hash | Opens |
|---|---|
#/webhooks | a screen, on its own default filter |
#/webhooks?status=failed | a screen narrowed to a status — what the health panel's buttons write |
#/activity?status=webhook.rejected | the activity screen narrowed to one action |
#/payments?customer=cus_9f2 | every 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:
- 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 horizonpayments:healthalerts on. The count above the table is the server'sdueWithin.total, unbounded by the page. - 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 duewith 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_byis 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
503with the manager's own message, which lists the providers that are configured. - A gateway that refuses answers
502carrying 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
import { defineConfig } from '@adonis-agora/payments/dashboard'
export default defineConfig({
// enabled: true,
// path: '/payments',
// currency: 'BRL',
// authorize: (ctx) => ctx.auth.user?.isAdmin === true,
})| Option | Default | Description |
|---|---|---|
enabled | true | When 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. |
authorize | see below | Per-request guard: (ctx: HttpContext) => boolean | Promise<boolean>. |
dashboardAuth | absent | Opt-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:
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:
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>/loginand 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.
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:
| Route | Query / body | Returns |
|---|---|---|
GET <path>/api/health | — | the billingHealth checks plus the failure breakdown |
GET <path>/api/overview | period, from, to | billingOverview metrics for the period |
GET <path>/api/payments | status, provider, reference, gatewayId, customerId, page, size | a page of payments, newest first, each with externalReference, refundedAmount and an owner |
GET <path>/api/payments/:gatewayId | — | one payment: current state, owner, disputes, ledger rows naming it (with events.matchedBy), and its audit rows |
GET <path>/api/customers | provider, ownerType, ownerId, gatewayId, page, size | a page of the owner mapping, newest first |
GET <path>/api/disputes | status, provider, page, size | a page of disputes, newest first |
GET <path>/api/disputes | dueWithin (hours), provider, page, size | only the OPEN windows closing inside the horizon, soonest first, plus the full dueWithin.total |
GET <path>/api/subscriptions | status, provider, page, size | a page of subscriptions, plus the whole-table past_due count |
GET <path>/api/webhook-events | status, provider, type, page, size | a page of the ledger, newest first |
GET <path>/api/audit | action, actor, provider, subjectType, subjectId, page, size | the audit trail, newest first, plus the action filter list |
GET <path>/api/providers | — | the 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=25 — page 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:
| Action | Written when |
|---|---|
webhook.rejected | the 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.refunded | someone refunded from this console. The payment row moves only when the gateway's webhook lands, and that row names no person |
dispute.resolved | someone 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.
Custom providers
Write a custom payment or invoice provider as a plain config factory, using the exported building blocks — httpRequest, toDecimal, emitInvoiceIfRequested, ensureCustomer, webhook security helpers.
Diagnostics
Every payments event on the @adonis-agora/diagnostics bus — the gateway-action, business and debug layers, the structural emit slot, and debugging one payment in Telescope.