OI Payments Docs
Admin dashboard

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:

CheckWhereOn failure
You hold the authority (e.g. payment:read) at all@PreAuthorize on the endpoint403 Forbidden
Your grants cover the requested applicationIdinside 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.

FieldTypeRequiredNotes
applicationIdLongYesThe single app to report on. Out-of-scope → zeroed/empty (not 403).
modeenum TEST | LIVEYesThe dashboard toggle. The exception to credential-derived mode.
fromdate YYYY-MM-DDNoInclusive start of the window. Omit for an open lower bound.
todate YYYY-MM-DDNoInclusive 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:

FieldTypeRequiredNotes
totalCountLongYesPayments created in the window.
succeededCountLongYesHow many reached SUCCEEDED.
failedCountLongYesHow many failed.
grossSucceededMinorLongYesGross succeeded amount, minor units.
successRatedoubleYessucceededCount / 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:

FieldTypeRequiredNotes
totalCountLongYesRefunds created in the window.
succeededCountLongYesHow many succeeded.
totalRefundedMinorLongYesGross succeeded (refunded) amount, minor units.
refundRatedoubleYessucceededCount / 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:

FieldTypeRequiredNotes
totalCountLongYesInvoices created in the window.
paidCountLongYesHow many are PAID.
overdueCountLongYesHow many are OVERDUE — read from the persisted status (set by the overdue sweep), never recomputed from dueDate.
outstandingMinorLongYesGross 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:

FieldTypeRequiredNotes
totalCountLongYesProducts created in the window.
activeCountLongYesACTIVE products.
draftCountLongYesDRAFT products.
archivedCountLongYesARCHIVED products.
{ "data": { "totalCount": 24, "activeCount": 18, "draftCount": 4, "archivedCount": 2 } }

Benefits — GET /admin/benefits/metrics

Requires benefit:read. BenefitMetricsDto:

FieldTypeRequiredNotes
totalCountLongYesBenefits created in the window.
activeCountLongYesHow many are active.
{ "data": { "totalCount": 9, "activeCount": 7 } }

Subscriptions — GET /admin/subscriptions/metrics

Requires subscription:read. SubscriptionMetricsDto:

FieldTypeRequiredNotes
totalCountLongYesSubscriptions created in the window.
activeCountLongYesACTIVE.
trialingCountLongYesTRIALING.
pastDueCountLongYesPAST_DUE.
canceledCountLongYesCANCELED.
{ "data": { "totalCount": 156, "activeCount": 121, "trialingCount": 14, "pastDueCount": 9, "canceledCount": 12 } }

Customers — GET /admin/customers/metrics

Requires customer:read. CustomerMetricsDto:

FieldTypeRequiredNotes
totalCountLongYesTotal customers for the app. Mode-agnostic — a customer reference is shared across TEST and LIVE.
withSucceededPaymentCountLongYesCustomers 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 } }

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:

FieldTypeRequiredNotes
datedate YYYY-MM-DDYesThe calendar day (date part of createdAt).
totalCountLongYesPayments that day.
succeededCountLongYesHow many succeeded.
grossSucceededMinorLongYesGross 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:

FieldTypeRequiredNotes
datedate YYYY-MM-DDYesThe calendar day.
totalCountLongYesRefunds that day.
refundedMinorLongYesGross 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.

FieldTypeRequiredNotes
applicationIdLongYesThe app to age.
modeenum TEST | LIVEYesThe dashboard toggle.
asOfdate YYYY-MM-DDNoThe 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:

FieldTypeRequiredNotes
bucketStringYesOne of 0-30, 31-60, 61-90, 90+ (days overdue).
countlongYesUnpaid invoices in this band.
outstandingMinorlongYesTheir 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:

FieldTypeRequiredNotes
productIdLongYesThe product's id.
nameStringYesThe product's name.
grossSucceededMinorlongYesGross succeeded revenue in the window, minor units.
succeededCountlongYesSucceeded 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:

FieldTypeRequiredNotes
customerRefIdLongYesInternal customer-reference id.
externalCustomerIdStringYesThe app's own customer identifier (for labelling and linking).
applicationIdLongYesThe owning app.
grossSucceededMinorLongYesGross succeeded spend in the window, minor units.
succeededCountLongYesSucceeded 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.

On this page