Dashboard analytics
Per-app, mode-aware metrics rows, time-series trends, invoice aging and revenue leaderboards for the App-Selector dashboard.
The operations dashboard is organised around an App-Selector: you pick one
application and one mode, and every widget on the screen —
metric cards, trend charts, aging bars, leaderboards — reads against that single
applicationId + mode pair. These read-only analytics endpoints all live under
/admin/**, are authenticated by an admin session, and
gate on the same fine-grained authorities as their list screens
(payment:read, invoice:read, and so on).
All amounts are integer minor units (paisa): 150000 means 1,500.00 BDT.
See money. Rates (successRate, refundRate) are
fractions in 0..1.
The App-Selector model
Unlike the app-facing API — where the mode is fixed by the credential — an admin
session carries no credential mode. There is no oi_test_… / oi_live_… key
behind a logged-in operator, so the dashboard cannot infer which dataset you want.
It exposes a TEST/LIVE toggle, and that choice is sent on every request.
The single documented exception to the credential-derived-mode rule.
Everywhere else in this API, "the mode (TEST or LIVE) is derived from the API
credential that authenticated the request — never from the request body"
(modes). These dashboard endpoints are the exception:
mode is a required query parameter on every */metrics, */timeseries,
*/aging and */top call, because an admin session has no credential mode to
derive from. It is the only place a caller names the mode explicitly.
applicationId is also required on every endpoint here. You always scope the
dashboard to exactly one app within your grants.
Out of scope returns zeros, not 403
Two layers of authorization apply, and they fail differently:
| Check | Where | On failure |
|---|---|---|
You hold the authority (e.g. payment:read) at all | @PreAuthorize on the endpoint | 403 Forbidden |
Your grants cover the requested applicationId | inside the use case (RBAC-4) | zeroed metrics / empty lists — 200 OK |
So requesting an app you are not scoped to does not leak its existence with a
403 — you get a well-formed, all-zero payload (or an empty array, or the four
aging bands at zero). This mirrors how a cross-app
transaction search silently omits rows you cannot see.
Test and live data stay fully isolated regardless — see
app isolation.
Shared parameters
Every endpoint on this page takes applicationId + mode. The metrics and
time-series endpoints add an optional date window; aging takes a single snapshot
date instead.
| Field | Type | Required | Notes |
|---|---|---|---|
applicationId | Long | Yes | The single app to report on. Out-of-scope → zeroed/empty (not 403). |
mode | enum TEST | LIVE | Yes | The dashboard toggle. The exception to credential-derived mode. |
from | date YYYY-MM-DD | No | Inclusive start of the window. Omit for an open lower bound. |
to | date YYYY-MM-DD | No | Inclusive end of the window (the whole day counts). Omit for an open upper bound. |
The window is half-open internally — [from 00:00, to+1 day 00:00) — so to
includes its entire calendar day. Omitting a bound substitutes a wide sentinel,
so an unbounded call reports over all time. Aging replaces from/to with
asOf (covered below).
Every response uses the standard envelope:
{ "data": <payload>, "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "…" }, "pagination": null }.
These endpoints are not paginated (pagination is always null), so the
examples below show just the data payload. See
responses & errors.
How the dashboard fans out
One selector choice drives a parallel fan-out: the screen fires every widget's
endpoint with the same applicationId + mode.
Metrics rows
Each domain exposes a single GET …/metrics call returning one compact DTO for
the metric-card row. All counts are concrete zeros (never null) for an app with
no rows in the window.
Payments — GET /admin/payments/metrics
Requires payment:read.
curl "http://localhost:8080/api/v1/admin/payments/metrics?applicationId=42&mode=LIVE&from=2026-06-01&to=2026-06-30" \
-H "Authorization: Bearer sess_3f8a9c12d4e5f6a7b8c9d0e1"PaymentMetricsDto:
| Field | Type | Required | Notes |
|---|---|---|---|
totalCount | Long | Yes | Payments created in the window. |
succeededCount | Long | Yes | How many reached SUCCEEDED. |
failedCount | Long | Yes | How many failed. |
grossSucceededMinor | Long | Yes | Gross succeeded amount, minor units. |
successRate | double | Yes | succeededCount / totalCount, in 0..1 (0.0 over an empty window). |
{
"data": {
"totalCount": 1280,
"succeededCount": 1193,
"failedCount": 87,
"grossSucceededMinor": 179450000,
"successRate": 0.932
},
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}Refunds — GET /admin/refunds/metrics
Requires refund:read. RefundMetricsDto:
| Field | Type | Required | Notes |
|---|---|---|---|
totalCount | Long | Yes | Refunds created in the window. |
succeededCount | Long | Yes | How many succeeded. |
totalRefundedMinor | Long | Yes | Gross succeeded (refunded) amount, minor units. |
refundRate | double | Yes | succeededCount / totalCount, in 0..1 (0.0 over an empty window). |
{
"data": { "totalCount": 41, "succeededCount": 38, "totalRefundedMinor": 5120000, "refundRate": 0.927 }
}Refund creation and the approval queue live on refund approval; this is read-only reporting.
Invoices — GET /admin/invoices/metrics
Requires invoice:read. InvoiceMetricsDto:
| Field | Type | Required | Notes |
|---|---|---|---|
totalCount | Long | Yes | Invoices created in the window. |
paidCount | Long | Yes | How many are PAID. |
overdueCount | Long | Yes | How many are OVERDUE — read from the persisted status (set by the overdue sweep), never recomputed from dueDate. |
outstandingMinor | Long | Yes | Gross outstanding balance (total − paid) summed over every invoice that is neither PAID nor VOID. A voided bill carries no balance. |
{
"data": { "totalCount": 312, "paidCount": 270, "overdueCount": 17, "outstandingMinor": 8640000 }
}Products — GET /admin/products/metrics
Requires product:read. Products carry no money (prices live on a separate
entity), so this is counts only. ProductMetricsDto:
| Field | Type | Required | Notes |
|---|---|---|---|
totalCount | Long | Yes | Products created in the window. |
activeCount | Long | Yes | ACTIVE products. |
draftCount | Long | Yes | DRAFT products. |
archivedCount | Long | Yes | ARCHIVED products. |
{ "data": { "totalCount": 24, "activeCount": 18, "draftCount": 4, "archivedCount": 2 } }Benefits — GET /admin/benefits/metrics
Requires benefit:read. BenefitMetricsDto:
| Field | Type | Required | Notes |
|---|---|---|---|
totalCount | Long | Yes | Benefits created in the window. |
activeCount | Long | Yes | How many are active. |
{ "data": { "totalCount": 9, "activeCount": 7 } }Subscriptions — GET /admin/subscriptions/metrics
Requires subscription:read. SubscriptionMetricsDto:
| Field | Type | Required | Notes |
|---|---|---|---|
totalCount | Long | Yes | Subscriptions created in the window. |
activeCount | Long | Yes | ACTIVE. |
trialingCount | Long | Yes | TRIALING. |
pastDueCount | Long | Yes | PAST_DUE. |
canceledCount | Long | Yes | CANCELED. |
{ "data": { "totalCount": 156, "activeCount": 121, "trialingCount": 14, "pastDueCount": 9, "canceledCount": 12 } }Customers — GET /admin/customers/metrics
Requires customer:read. CustomerMetricsDto:
| Field | Type | Required | Notes |
|---|---|---|---|
totalCount | Long | Yes | Total customers for the app. Mode-agnostic — a customer reference is shared across TEST and LIVE. |
withSucceededPaymentCount | Long | Yes | Customers with at least one succeeded payment in the selected mode and window. |
totalCount is the one count on this page that ignores mode (the customer
record is shared across modes); withSucceededPaymentCount honours it, because
the proving payment is mode-scoped. See customers.
{ "data": { "totalCount": 4820, "withSucceededPaymentCount": 1193 } }Time-series trends
Two endpoints return a daily series for the trend charts. Each row is one calendar day; days with no activity are omitted (the chart fills gaps client-side). Both honour the TEST/LIVE toggle and aggregate only the selected app's rows.
Payments — GET /admin/payments/timeseries
Requires payment:read. Returns List<PaymentDayPointDto>. An out-of-scope app
yields an empty array.
curl "http://localhost:8080/api/v1/admin/payments/timeseries?applicationId=42&mode=LIVE&from=2026-06-01&to=2026-06-30" \
-H "Authorization: Bearer sess_3f8a9c12d4e5f6a7b8c9d0e1"PaymentDayPointDto:
| Field | Type | Required | Notes |
|---|---|---|---|
date | date YYYY-MM-DD | Yes | The calendar day (date part of createdAt). |
totalCount | Long | Yes | Payments that day. |
succeededCount | Long | Yes | How many succeeded. |
grossSucceededMinor | Long | Yes | Gross succeeded amount that day, minor units. |
{
"data": [
{ "date": "2026-06-01", "totalCount": 44, "succeededCount": 41, "grossSucceededMinor": 6150000 },
{ "date": "2026-06-02", "totalCount": 39, "succeededCount": 37, "grossSucceededMinor": 5480000 },
{ "date": "2026-06-04", "totalCount": 51, "succeededCount": 48, "grossSucceededMinor": 7200000 }
],
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}Note the gap: 2026-06-03 has no payments, so it is absent from the series.
Refunds — GET /admin/refunds/timeseries
Requires refund:read. Returns List<RefundDayPointDto>.
RefundDayPointDto:
| Field | Type | Required | Notes |
|---|---|---|---|
date | date YYYY-MM-DD | Yes | The calendar day. |
totalCount | Long | Yes | Refunds that day. |
refundedMinor | Long | Yes | Gross succeeded (refunded) amount that day, minor units. |
{
"data": [
{ "date": "2026-06-05", "totalCount": 3, "refundedMinor": 420000 },
{ "date": "2026-06-12", "totalCount": 1, "refundedMinor": 150000 }
]
}Invoice aging
GET /admin/invoices/aging requires invoice:read. Aging is a snapshot as of a
chosen day, not a [from, to) window — so it takes asOf instead of from/to.
| Field | Type | Required | Notes |
|---|---|---|---|
applicationId | Long | Yes | The app to age. |
mode | enum TEST | LIVE | Yes | The dashboard toggle. |
asOf | date YYYY-MM-DD | No | The snapshot day. Defaults to today when omitted. |
An invoice is aged only if it is neither PAID nor VOID and its dueDate is on
or before asOf; it falls into a band by how many days its dueDate precedes the
snapshot. An invoice not yet due (or with no due date) never appears. The four
bands are always present (zeroed when empty) so the chart axis stays stable.
curl "http://localhost:8080/api/v1/admin/invoices/aging?applicationId=42&mode=LIVE&asOf=2026-06-30" \
-H "Authorization: Bearer sess_3f8a9c12d4e5f6a7b8c9d0e1"Returns List<InvoiceAgingBucketDto> — exactly four rows, in this order.
InvoiceAgingBucketDto:
| Field | Type | Required | Notes |
|---|---|---|---|
bucket | String | Yes | One of 0-30, 31-60, 61-90, 90+ (days overdue). |
count | long | Yes | Unpaid invoices in this band. |
outstandingMinor | long | Yes | Their gross outstanding (total − paid), minor units. |
{
"data": [
{ "bucket": "0-30", "count": 11, "outstandingMinor": 4200000 },
{ "bucket": "31-60", "count": 4, "outstandingMinor": 1980000 },
{ "bucket": "61-90", "count": 2, "outstandingMinor": 1460000 },
{ "bucket": "90+", "count": 1, "outstandingMinor": 1000000 }
],
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}An out-of-scope app returns the same four bands, all zero.
Leaderboards
Two "top by revenue" endpoints power the dashboard leaderboards. Both are
mode-aware and app-scoped, take the standard from/to window, and accept an
optional limit (default 10, clamped to [1, 100]). Out-of-scope → empty
array.
Top products — GET /admin/products/top
Requires product:read. Returns List<TopProductDto>, ranked by succeeded
revenue descending.
curl "http://localhost:8080/api/v1/admin/products/top?applicationId=42&mode=LIVE&from=2026-06-01&to=2026-06-30&limit=5" \
-H "Authorization: Bearer sess_3f8a9c12d4e5f6a7b8c9d0e1"TopProductDto:
| Field | Type | Required | Notes |
|---|---|---|---|
productId | Long | Yes | The product's id. |
name | String | Yes | The product's name. |
grossSucceededMinor | long | Yes | Gross succeeded revenue in the window, minor units. |
succeededCount | long | Yes | Succeeded payments that contributed. |
Coverage caveat. Revenue links to a product only through a price-pinned
invoice (payment → invoice.priceId → price.productId → product). Standalone
payments (no invoiceId) and ad-hoc invoices (no priceId) carry no product
linkage and contribute nothing — so this is "revenue from catalogue products,"
not all revenue. See products & prices.
{
"data": [
{ "productId": 8801, "name": "Pro Plan (annual)", "grossSucceededMinor": 96000000, "succeededCount": 640 },
{ "productId": 8802, "name": "Pro Plan (monthly)", "grossSucceededMinor": 41250000, "succeededCount": 825 },
{ "productId": 8810, "name": "Add-on: Priority Support", "grossSucceededMinor": 12000000, "succeededCount": 240 }
]
}Top customers — GET /admin/customers/top
Requires customer:read. Returns List<TopCustomerRow>, ranked by succeeded
spend descending.
TopCustomerRow:
| Field | Type | Required | Notes |
|---|---|---|---|
customerRefId | Long | Yes | Internal customer-reference id. |
externalCustomerId | String | Yes | The app's own customer identifier (for labelling and linking). |
applicationId | Long | Yes | The owning app. |
grossSucceededMinor | Long | Yes | Gross succeeded spend in the window, minor units. |
succeededCount | Long | Yes | Succeeded payments that contributed. |
{
"data": [
{ "customerRefId": 50231, "externalCustomerId": "user_a1b2c3", "applicationId": 42, "grossSucceededMinor": 8400000, "succeededCount": 12 },
{ "customerRefId": 50118, "externalCustomerId": "user_d4e5f6", "applicationId": 42, "grossSucceededMinor": 6150000, "succeededCount": 9 }
]
}Drill into a row from the customers screen for the full profile.
Related
RBAC & grants
The authorities and app-scope that gate every endpoint here.
Transactions
Cross-app payment search and CSV export behind the same payment:read gate.
Reports
LIVE-only summary reporting (§6.7) — distinct from these mode-aware dashboard reads.
Test & live modes
Why mode is a query param here and a credential property everywhere else.
Money
Minor units (paisa) and BDT-only handling.
App isolation
How app_id + mode keep one app's data invisible to another.