OI Payments Docs
Admin dashboard

Operator refunds, approvals & refund analytics

Initiate refunds from the dashboard, approve above-threshold ones with four-eyes control, and read per-app refund metrics — all session-authenticated and app-scoped.

Operators work refunds from the operations dashboard: search refunds across every app they are scoped to, initiate a refund against any app's settled payment, and release an above-threshold refund that is parked awaiting approval. This is the session-authenticated counterpart to the app-API refunds in the Refunds guide — same money rules and same lifecycle, but driven by an operator rather than an API key.

All endpoints on this page are admin endpoints under /admin/**, so every request carries an operator session token:

-H "Authorization: Bearer SESSION_TOKEN"

Every response is the standard envelope — { "data": …, "meta": …, "pagination": … }. The examples below show just the data payload (and meta on errors). Amounts are integer minor units (paisa): 250000 means 2,500.00 BDT. Currency is BDT-only in v1.

Two-tier authorization

Refund administration is gated twice. A coarse Spring @PreAuthorize on the controller checks that the operator holds the authority at all; the use case then re-checks the same permission scoped to the target app (RBAC-4) once the app is known — something the coarse gate cannot express. If the operator holds the authority globally but is not scoped to the target app, the second check fails with 403 FORBIDDEN.

ActionCoarse gate (@PreAuthorize)Per-app re-check (in use case)Scope resolved from
Searchrefund:readfilters rows to the operator's app scopeacting principal's grants
Initiaterefund:createadmin.hasPermission("refund:create", appId)the resolved payment's app
Approverefund:approveadmin.hasPermission("refund:approve", appId)the refund's app
Metrics / Timeseriesrefund:readadmin.hasPermission("refund:read", applicationId)the applicationId query param

See RBAC for how grants map to apps, and app isolation for the underlying app_id + mode partitioning.

The mode of a refund follows the record, never caller input. Initiate, approve and search read mode from the underlying payment/refund. The analytics endpoints (metrics & timeseries) take mode as a required query parameter, exactly like the other dashboard aggregates — see dashboard analytics for the rationale. The app-API refund path derives mode from the API credential; see test & live modes.

Search refunds

GET /admin/refunds (requires refund:read) is a cross-app, paginated, filterable refund search. Results are narrowed to the apps your grants cover — an app you are not scoped to never appears, even if you pass its applicationId.

Query parameters

FieldTypeRequiredNotes
pageintegerNoZero-based page index. Default 0.
sizeintegerNoPage size. Default 20.
sortBystringNoSort field. Default createdAt.
orderASC | DESCNoSort direction. Default DESC.
paginatebooleanNoDefault true. Pass false to return the full unpaged result.
applicationIdintegerNoFilter to one app (still subject to your scope).
modeTEST | LIVENoFilter by mode.
statusAWAITING_APPROVAL | PENDING | SUCCEEDED | FAILEDNoFilter by refund status.
paymentIdintegerNoAll refunds against one payment.
fromdate (YYYY-MM-DD)NoStart day, inclusive.
todate (YYYY-MM-DD)NoEnd day, inclusive (advanced internally to the next day's start, so the whole to day is covered).

The date window is half-open [from 00:00, to + 1 day 00:00), which means both the from and to calendar days are fully included. Omit either bound to leave that side unbounded.

Response — RefundSummaryDto

FieldTypeNotes
idintegerRefund id.
paymentIdintegerThe refunded payment.
applicationIdintegerOwning app (always present, for grouping/filtering).
applicationNamestringResolved display name; omitted if the app can no longer be resolved (fall back to applicationId).
invoiceIdintegerSettled invoice, when the payment paid one; otherwise omitted.
modestringTEST or LIVE.
amountMinorintegerRefund amount in paisa.
currencystringBDT.
statusstringAWAITING_APPROVAL | PENDING | SUCCEEDED | FAILED.
reasonstringRefund reason, if supplied.
createdBystringInitiator handle, e.g. app:42 or user:7.
approvedBystringApprover handle (e.g. user:12); omitted until approved or when approval was never required.
createdAtdatetimeWhen the refund was created.
updatedAtdatetimeLast status change.
curl "http://localhost:8080/api/v1/admin/refunds?applicationId=42&mode=LIVE&status=AWAITING_APPROVAL&page=0&size=20" \
  -H "Authorization: Bearer SESSION_TOKEN"
{
  "data": [
    {
      "id": 9012,
      "paymentId": 4521,
      "applicationId": 42,
      "applicationName": "Acme Store",
      "invoiceId": 3300,
      "mode": "LIVE",
      "amountMinor": 250000,
      "currency": "BDT",
      "status": "AWAITING_APPROVAL",
      "reason": "Customer returned item",
      "createdBy": "user:7",
      "createdAt": "2026-06-30T11:55:12",
      "updatedAt": "2026-06-30T11:55:12"
    }
  ],
  "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
  "pagination": { "page": 0, "size": 20, "totalElements": 1, "totalPages": 1 }
}

See pagination & filtering for the shared list conventions.

Initiate a refund

POST /admin/payments/{id}/refunds (requires refund:create) refunds a settled payment as the operator. The path identifies the payment; the body carries the amount and an optional reason.

The create path lives under the payment collection — POST /admin/payments/{id}/refunds — not under /admin/refunds. A refund is created against its payment but approved under its own collection (/admin/refunds/{id}/approve).

Request body — CreateRefundRequest

FieldTypeRequiredNotes
amountMinorintegerYesAmount to refund in minor units (paisa). Must be positive. Full or partial; bounded by the payment's refundable balance.
reasonstringNoOperator-supplied reason. Max 500 characters.

What happens

Load and authorize

The payment is loaded under a row lock (the operator is not an app, so this is a cross-app load). The use case then re-checks refund:create against that payment's app403 FORBIDDEN if you are not scoped to it.

Require a settled payment

The payment must be SUCCEEDED. Refunding anything else fails with 422 INVALID_OPERATION_STATE.

Over-refund guard

The requested amount, plus all already-reserving refunds (AWAITING_APPROVAL + PENDING + SUCCEEDED), must not exceed the captured amount. Exceeding the refundable balance fails with 422 INVALID_OPERATION_STATE.

Threshold decision

The app's refundApprovalThresholdMinor decides the starting status (see the threshold rule). Above threshold → parked in AWAITING_APPROVAL with no gateway call. At or below (or threshold unset) → goes straight to PENDING and is submitted to the gateway immediately.

The refund's createdBy is stamped user:{operatorId} (e.g. user:7), and a REFUND_CREATED entry is written to the append-only audit log either way.

Admin-initiated refunds are not idempotent. Unlike the app-API refund, the dashboard create does not honour an Idempotency-Key — a session operator has no API-key principal for the idempotency layer to resolve. A duplicate submission is instead prevented by the over-refund guard and the parked-then-approved two-step. See idempotency for the app-API behaviour.

Flow

Examples

curl -X POST "http://localhost:8080/api/v1/admin/payments/4521/refunds" \
  -H "Authorization: Bearer SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "amountMinor": 250000, "reason": "Customer returned item" }'

Returns 201 Created. Above the app's threshold, so it is parked — gatewayRefundId and approvedBy are still absent:

{
  "data": {
    "id": 9012,
    "paymentId": 4521,
    "mode": "LIVE",
    "amountMinor": 250000,
    "currency": "BDT",
    "reason": "Customer returned item",
    "status": "AWAITING_APPROVAL",
    "createdBy": "user:7",
    "createdAt": "2026-06-30T11:55:12",
    "updatedAt": "2026-06-30T11:55:12"
  },
  "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T11:55:12Z" },
  "pagination": null
}
curl -X POST "http://localhost:8080/api/v1/admin/payments/4521/refunds" \
  -H "Authorization: Bearer SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "amountMinor": 50000, "reason": "Goodwill credit" }'

Returns 201 Created. At/below the threshold, so it is submitted to the gateway right away and comes back PENDING:

{
  "data": {
    "id": 9013,
    "paymentId": 4521,
    "mode": "LIVE",
    "amountMinor": 50000,
    "currency": "BDT",
    "reason": "Goodwill credit",
    "status": "PENDING",
    "gatewayRefundId": "GW-REF-88210",
    "createdBy": "user:7",
    "createdAt": "2026-06-30T11:58:01",
    "updatedAt": "2026-06-30T11:58:02"
  },
  "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T11:58:02Z" },
  "pagination": null
}

Response — RefundDto

FieldTypeNotes
idintegerRefund id.
paymentIdintegerThe refunded payment.
modestringTEST or LIVE.
amountMinorintegerRefund amount in paisa.
currencystringBDT.
reasonstringRefund reason, if supplied.
statusstringAWAITING_APPROVAL | PENDING | SUCCEEDED | FAILED.
gatewayRefundIdstringThe gateway's refund id; absent until submitted (i.e. absent while AWAITING_APPROVAL).
createdBystringInitiator handle, e.g. user:7.
approvedBystringApprover handle (e.g. user:12); absent until approved or when approval was never required.
createdAtdatetimeWhen the refund was created.
updatedAtdatetimeLast status change.

Approval threshold

Whether a human-initiated refund needs approval is decided entirely by the app's refundApprovalThresholdMinor (configured per app — see Apps) against the requested amount. The exact rule is:

needsApproval = threshold != null && requestedAmount > threshold

A strict greater-than, so:

refundApprovalThresholdMinorRequested amountStarting status
null / unsetanyPENDING — approval is never required
0any positive amountAWAITING_APPROVAL — approval is always required (any positive amount exceeds 0)
100000100000 (equal)PENDING — equal is not above the threshold
100000150000 (above)AWAITING_APPROVAL
10000080000 (below)PENDING

App-API refunds and below/at-threshold operator refunds skip AWAITING_APPROVAL entirely and start at PENDING.

Approve an above-threshold refund

POST /admin/refunds/{id}/approve (requires refund:approve) releases a parked refund to the gateway. There is no request body — the refund id is in the path.

On approval the use case:

  1. Loads the refund — 404 RESOURCE_NOT_FOUND if it does not exist.
  2. Requires it to be AWAITING_APPROVAL422 INVALID_OPERATION_STATE for any other status (already submitted, succeeded or failed). A duplicate approval is therefore a safe no-op rejection, not a second gateway call.
  3. Re-checks refund:approve against the refund's app403 FORBIDDEN if you are not scoped to it.
  4. Stamps approvedBy = user:{operatorId}, submits to the gateway, and advances the refund to PENDING (or a terminal status the gateway returns).
  5. Writes a REFUND_APPROVED entry to the append-only audit log with the approver's identity.

If the gateway call fails, the request returns 502 PAYMENT_GATEWAY_ERROR and the whole transaction rolls back, so the refund stays AWAITING_APPROVAL and can be approved again cleanly.

Four-eyes, but no step-up re-auth. Separating refund:create from refund:approve is what enforces two-person control on large refunds — the approver must hold refund:approve for that app, and the approval is recorded in the audit log. The approve use case does not require a step-up / recent-reauth (SensitiveActionGuard) — it enforces only the app-scoped refund:approve permission. The over-refund guard is not re-run on approve either: the parked refund already reserved its balance when it was created.

curl -X POST "http://localhost:8080/api/v1/admin/refunds/9012/approve" \
  -H "Authorization: Bearer SESSION_TOKEN"
{
  "data": {
    "id": 9012,
    "paymentId": 4521,
    "mode": "LIVE",
    "amountMinor": 250000,
    "currency": "BDT",
    "reason": "Customer returned item",
    "status": "PENDING",
    "gatewayRefundId": "GW-REF-88213",
    "createdBy": "user:7",
    "approvedBy": "user:12",
    "createdAt": "2026-06-30T11:55:12",
    "updatedAt": "2026-06-30T12:03:40"
  },
  "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:03:40Z" },
  "pagination": null
}

Error responses

Approving a refund that is not awaiting approval (422):

{
  "data": null,
  "meta": {
    "success": false,
    "message": "Refund 9012 is not awaiting approval (status=PENDING)",
    "errorCode": "INVALID_OPERATION_STATE",
    "timestamp": "2026-06-30T12:05:00Z"
  },
  "pagination": null
}

Approving for an app you are not scoped to (403):

{
  "data": null,
  "meta": {
    "success": false,
    "message": "Operator lacks refund:approve for app 42",
    "errorCode": "FORBIDDEN",
    "timestamp": "2026-06-30T12:05:00Z"
  },
  "pagination": null
}

Refund lifecycle

AWAITING_APPROVAL, PENDING and SUCCEEDED all reserve refundable balance against the captured amount, so a parked refund cannot be over-committed by a concurrent one. A FAILED refund consumed no balance and frees it.

Refund analytics

Two per-app, mode-aware aggregates drive the dashboard refund cards. Both require refund:read (coarse) plus the app-scoped re-check. applicationId and mode are required query parameters; from/to are optional and follow the same inclusive-day window as the search. An app you are not scoped to yields zeroed metrics / an empty series rather than a 403.

FieldTypeRequiredNotes
applicationIdintegerYesApp to aggregate. Out-of-scope → zeros / empty.
modeTEST | LIVEYesBound filter — honours the dashboard's mode toggle.
fromdate (YYYY-MM-DD)NoStart day, inclusive. Unbounded if omitted.
todate (YYYY-MM-DD)NoEnd day, inclusive. Unbounded if omitted.

These analytics endpoints are part of the generated API reference / OpenAPI spec — the reference is the machine-readable source of truth and is not hand-edited.

Metrics — GET /admin/refunds/metrics

Returns a single RefundMetricsDto. Always concrete zeros (never null) for an app with no refunds in the window.

FieldTypeNotes
totalCountintegerRefunds in the window.
succeededCountintegerHow many reached SUCCEEDED.
totalRefundedMinorintegerGross succeeded (refunded) amount in paisa.
refundRatenumberDerived succeededCount / totalCount (0.0 when totalCount is 0).
curl "http://localhost:8080/api/v1/admin/refunds/metrics?applicationId=42&mode=LIVE&from=2026-06-01&to=2026-06-30" \
  -H "Authorization: Bearer SESSION_TOKEN"
{
  "data": {
    "totalCount": 37,
    "succeededCount": 31,
    "totalRefundedMinor": 4850000,
    "refundRate": 0.8378
  },
  "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
  "pagination": null
}

Timeseries — GET /admin/refunds/timeseries

Returns a list of RefundDayPointDto, one per calendar day. Days with no refund are omitted (the dashboard fills the gaps client-side).

FieldTypeNotes
datedateThe calendar day (YYYY-MM-DD).
totalCountintegerRefunds created that day.
refundedMinorintegerGross succeeded (refunded) amount that day, in paisa.
curl "http://localhost:8080/api/v1/admin/refunds/timeseries?applicationId=42&mode=LIVE&from=2026-06-28&to=2026-06-30" \
  -H "Authorization: Bearer SESSION_TOKEN"
{
  "data": [
    { "date": "2026-06-28", "totalCount": 3, "refundedMinor": 450000 },
    { "date": "2026-06-30", "totalCount": 5, "refundedMinor": 1200000 }
  ],
  "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
  "pagination": null
}

Note 2026-06-29 is absent — no refunds were created that day.

Retrying a failed refund

A FAILED refund freed its reserved balance, so it can be retried safely by initiating again for the same amount — the over-refund guard guarantees the retry can't compound past the captured amount. Retry is just another POST /admin/payments/{id}/refunds, gated by refund:create. If the original was above threshold, the retry parks in AWAITING_APPROVAL again and needs a fresh approval.

  • Refunds guide — the app-API refund path and the over-refund guard.
  • Transactions — find the payment to refund.
  • RBACrefund:read / refund:create / refund:approve grants and app scope.
  • Dashboard analytics — the mode-as-query-param convention for aggregates.
  • Webhooksrefund.pending / refund.succeeded / refund.failed events.

On this page