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.
| Action | Coarse gate (@PreAuthorize) | Per-app re-check (in use case) | Scope resolved from |
|---|---|---|---|
| Search | refund:read | filters rows to the operator's app scope | acting principal's grants |
| Initiate | refund:create | admin.hasPermission("refund:create", appId) | the resolved payment's app |
| Approve | refund:approve | admin.hasPermission("refund:approve", appId) | the refund's app |
| Metrics / Timeseries | refund:read | admin.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
| Field | Type | Required | Notes |
|---|---|---|---|
page | integer | No | Zero-based page index. Default 0. |
size | integer | No | Page size. Default 20. |
sortBy | string | No | Sort field. Default createdAt. |
order | ASC | DESC | No | Sort direction. Default DESC. |
paginate | boolean | No | Default true. Pass false to return the full unpaged result. |
applicationId | integer | No | Filter to one app (still subject to your scope). |
mode | TEST | LIVE | No | Filter by mode. |
status | AWAITING_APPROVAL | PENDING | SUCCEEDED | FAILED | No | Filter by refund status. |
paymentId | integer | No | All refunds against one payment. |
from | date (YYYY-MM-DD) | No | Start day, inclusive. |
to | date (YYYY-MM-DD) | No | End 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
| Field | Type | Notes |
|---|---|---|
id | integer | Refund id. |
paymentId | integer | The refunded payment. |
applicationId | integer | Owning app (always present, for grouping/filtering). |
applicationName | string | Resolved display name; omitted if the app can no longer be resolved (fall back to applicationId). |
invoiceId | integer | Settled invoice, when the payment paid one; otherwise omitted. |
mode | string | TEST or LIVE. |
amountMinor | integer | Refund amount in paisa. |
currency | string | BDT. |
status | string | AWAITING_APPROVAL | PENDING | SUCCEEDED | FAILED. |
reason | string | Refund reason, if supplied. |
createdBy | string | Initiator handle, e.g. app:42 or user:7. |
approvedBy | string | Approver handle (e.g. user:12); omitted until approved or when approval was never required. |
createdAt | datetime | When the refund was created. |
updatedAt | datetime | Last 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
| Field | Type | Required | Notes |
|---|---|---|---|
amountMinor | integer | Yes | Amount to refund in minor units (paisa). Must be positive. Full or partial; bounded by the payment's refundable balance. |
reason | string | No | Operator-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 app — 403 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
| Field | Type | Notes |
|---|---|---|
id | integer | Refund id. |
paymentId | integer | The refunded payment. |
mode | string | TEST or LIVE. |
amountMinor | integer | Refund amount in paisa. |
currency | string | BDT. |
reason | string | Refund reason, if supplied. |
status | string | AWAITING_APPROVAL | PENDING | SUCCEEDED | FAILED. |
gatewayRefundId | string | The gateway's refund id; absent until submitted (i.e. absent while AWAITING_APPROVAL). |
createdBy | string | Initiator handle, e.g. user:7. |
approvedBy | string | Approver handle (e.g. user:12); absent until approved or when approval was never required. |
createdAt | datetime | When the refund was created. |
updatedAt | datetime | Last 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 > thresholdA strict greater-than, so:
refundApprovalThresholdMinor | Requested amount | Starting status |
|---|---|---|
null / unset | any | PENDING — approval is never required |
0 | any positive amount | AWAITING_APPROVAL — approval is always required (any positive amount exceeds 0) |
100000 | 100000 (equal) | PENDING — equal is not above the threshold |
100000 | 150000 (above) | AWAITING_APPROVAL |
100000 | 80000 (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:
- Loads the refund —
404 RESOURCE_NOT_FOUNDif it does not exist. - Requires it to be
AWAITING_APPROVAL—422 INVALID_OPERATION_STATEfor any other status (already submitted, succeeded or failed). A duplicate approval is therefore a safe no-op rejection, not a second gateway call. - Re-checks
refund:approveagainst the refund's app —403 FORBIDDENif you are not scoped to it. - Stamps
approvedBy = user:{operatorId}, submits to the gateway, and advances the refund toPENDING(or a terminal status the gateway returns). - Writes a
REFUND_APPROVEDentry 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.
| Field | Type | Required | Notes |
|---|---|---|---|
applicationId | integer | Yes | App to aggregate. Out-of-scope → zeros / empty. |
mode | TEST | LIVE | Yes | Bound filter — honours the dashboard's mode toggle. |
from | date (YYYY-MM-DD) | No | Start day, inclusive. Unbounded if omitted. |
to | date (YYYY-MM-DD) | No | End 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.
| Field | Type | Notes |
|---|---|---|
totalCount | integer | Refunds in the window. |
succeededCount | integer | How many reached SUCCEEDED. |
totalRefundedMinor | integer | Gross succeeded (refunded) amount in paisa. |
refundRate | number | Derived 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).
| Field | Type | Notes |
|---|---|---|
date | date | The calendar day (YYYY-MM-DD). |
totalCount | integer | Refunds created that day. |
refundedMinor | integer | Gross 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.
Related
- Refunds guide — the app-API refund path and the over-refund guard.
- Transactions — find the payment to refund.
- RBAC —
refund:read/refund:create/refund:approvegrants and app scope. - Dashboard analytics — the mode-as-query-param convention for aggregates.
- Webhooks —
refund.pending/refund.succeeded/refund.failedevents.