Order Module — Admin
HTTP surface for platform-admin oversight of orders, returns, and vendor payouts. Read every order on the platform; perform ops actions (cancel on behalf of the customer, manually…
HTTP surface for platform-admin oversight of orders, returns, and vendor payouts. Read every order on the platform; perform ops actions (cancel on behalf of the customer, manually mark a stuck pending payment as paid, mark a paid order as refunded, force-create or override returns); browse vendor ledgers and disburse payouts.
Source:
api-modules/order/src/controllers/admin-orders.controller.ts,api-modules/order/src/controllers/admin-returns.controller.ts,api-modules/order/src/controllers/admin-payouts.controller.ts,api-modules/order/src/controllers/admin-vendor-payout-config.controller.ts,api-modules/vendor/src/controllers/admin-vendor-bank-account.controller.ts.Orders are split per-vendor — a parent
orderrow aggregates the customer-facing totals while eachorder_vendorrow is the fulfilment unit. Inventory reservations, fulfilment status, and the vendor ledger all key offorder_vendor.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching permission.
| Endpoint group | Permission |
|---|---|
GET /admin/orders, GET /admin/orders/:id, GET /admin/orders/:id/events | order: view |
POST /admin/orders/:id/cancel, POST /admin/orders/cleanup-stale-pending | order: cancel |
POST /admin/orders/:id/mark-paid, POST /admin/orders/:id/mark-refunded | order: update |
POST/GET /admin/orders/:id/refunds, POST /admin/orders/:id/refunds/:merchantRefundId/sync | order: refund |
GET /admin/returns, GET /admin/returns/:id | order: view |
POST /admin/orders/:id/returns, POST /admin/returns/:id/override | order: update |
GET /admin/vendors/:id/balance, GET /admin/vendors/:id/ledger, GET /admin/vendors/:id/payouts, GET /admin/payouts, GET /admin/payouts/:id, GET /admin/vendors/:id/payout-config, GET /admin/vendors/:id/bank-account | payout: view |
POST /admin/vendors/:id/payouts, POST /admin/payouts/promote | payout: create |
POST /admin/payouts/:id/mark-paid | payout: mark_paid |
POST /admin/payouts/:id/cancel | payout: cancel |
POST /admin/vendors/:id/ledger/adjust | payout: adjust |
PUT /admin/vendors/:id/payout-config | payout: configure |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT (illegal state transition, e.g. cancelling a delivered order) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Money fields
Every amount field is an integer subunit (paise / cents). commissionRate is in basis points (10000 = 100.00%).
Domain types
OrderResponse
type OrderResponse = {
id: string;
orderNumber: string;
customer: { id: string; name: string; email: string } | null; // The customer account that placed the order; null for legacy/guest orders.
status: OrderStatus;
paymentStatus: PaymentStatus;
paymentProvider: string;
paymentMethod: string;
platform: Platform;
shippingAddress: AddressBlock;
billingAddress: AddressBlock;
customerNote: string | null; // shopper's checkout instruction; null when none
subtotal: number; // subunits
discountTotal: number;
shippingTotal: number;
taxTotal: number;
grandTotal: number;
vendorBreakdowns: OrderVendorResponse[]; // per-vendor sub-orders + lines
events: OrderEventResponse[]; // recent audit tail (up to 50)
pendingClientAction: {
provider: string;
payload: Record<string, unknown>;
} | null;
placedAt: string;
confirmedAt: string | null;
paidAt: string | null;
cancelledAt: string | null;
cancellationReason: string | null;
};OrderVendorSummary (admin detail only)
On GET /admin/orders/:id, each vendorBreakdowns entry additionally carries a live vendor summary resolved at read time (unlike vendorNameAtOrder, which is the placement-time snapshot). The key is omitted on list responses and on the store surface; it is null when the vendor account no longer exists.
type OrderVendorSummary = {
id: string;
name: string; // current business name (org name if no profile yet)
slug: string;
logo: string | null;
email: string | null; // vendor_profile.business_email
phone: string | null; // vendor_profile.business_phone
};
// vendorBreakdowns entries on the admin detail endpoint:
type OrderVendorResponse = /* shared shape */ & {
vendor?: OrderVendorSummary | null;
};OrderEventResponse
type OrderEventResponse = {
id: string;
orderVendorId: string | null;
eventType: string; // e.g. "payment.captured", "order_vendor.delivered", "return.requested"
actorType: "customer" | "vendor" | "admin" | "system";
actorId: string | null;
actor: { id: string; name: string; email: string } | null; // Resolved actor account; null for system events.
source: string; // free-form, e.g. "admin-panel", "webhook"
changes: Record<string, unknown>;
metadata: Record<string, unknown>;
createdAt: string; // ISO
};ReturnResponse
type ReturnResponse = {
id: string;
returnNumber: string;
orderId: string;
orderVendorId: string;
customerId: string | null;
vendorId: string;
type: string; // service-defined ("refund", "exchange")
status: string; // "requested" | "approved" | "rejected" | "picked_up" | "received" | "qc_passed" | "qc_failed" | "refunded" | "cancelled"
reasonCode: string | null;
reasonNotes: string | null;
refundAmount: number; // subunits
refundedAmount: number;
externalRefundReference: string | null;
shippingProvider: string | null;
awbNumber: string | null;
trackingCode: string | null;
rejectionReason: string | null;
qcFailureReason: string | null;
requestedAt: string;
approvedAt: string | null;
rejectedAt: string | null;
pickedUpAt: string | null;
receivedAt: string | null;
qcPassedAt: string | null;
qcFailedAt: string | null;
refundedAt: string | null;
cancelledAt: string | null;
lines: ReturnLineResponse[];
photos: ReturnPhotoResponse[];
};PayoutResponse
type PayoutResponse = {
id: string;
payoutNumber: string;
vendorId: string;
status: "pending" | "paid" | "cancelled" | "failed";
periodStart: string;
periodEnd: string;
grossTotal: number;
commissionTotal: number;
netTotal: number;
entryCount: number;
bankAccountId: string | null;
bankReference: string | null;
notes: string | null;
createdAt: string;
paidAt: string | null;
cancelledAt: string | null;
entries?: LedgerEntryResponse[]; // only populated on the detail endpoint
};LedgerEntryResponse
type LedgerEntryResponse = {
id: string;
vendorId: string;
kind: "sale" | "refund" | "manual" | "commission_adjustment";
status: "pending" | "available" | "paid_out" | "cancelled";
grossAmount: number;
commissionRate: number; // basis points
commissionAmount: number;
netAmount: number;
orderId: string | null;
orderVendorId: string | null;
orderReturnId: string | null;
payoutId: string | null;
pendingUntil: string | null;
availableAt: string | null;
paidOutAt: string | null;
cancelledAt: string | null;
description: string | null;
createdAt: string;
};VendorBalanceResponse
type VendorBalanceResponse = {
vendorId: string;
pending: number; // net subunits in 'pending' status
available: number; // net subunits in 'available' status not yet on a draft payout
lifetimeEarned: number;
lifetimeRefunded: number;
lifetimePaidOut: number;
payoutHold: boolean; // vendor.payouts.payout_hold flag
commissionRate: number; // basis points
};Orders
Base path: /admin/orders.
GET /admin/orders — List orders
Required permission: order: view. Newest first across every customer and vendor.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | (paginated default) | — |
status | OrderStatus? | — | Parent-order lifecycle filter |
vendorId | string? | — | Narrows to orders containing a sub-order for this vendor |
Response 200 — paginated envelope of OrderResponse[].
GET /admin/orders/:id — Order detail
Required permission: order: view. Admin sees all fields (including provider payload). Inlines the most recent ~50 events; use /events for the full audit trail. Each vendorBreakdowns entry includes the live vendor summary.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/orders/:id/cancel — Cancel on behalf of customer
Required permission: order: cancel. Allowed only when no sub-order has been delivered. Cascades sub-orders, releases per-vendor reservations, writes audit rows.
Body
{ "reason": "Customer asked via support chat" }| Field | Type | Constraints |
|---|---|---|
reason | string? | Trimmed, 1..500 |
Response 200 — cancelled OrderResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 409 | CONFLICT | A sub-order is already delivered |
POST /admin/orders/:id/mark-paid — Manually mark paid
Required permission: order: update. For bank-transfer settlements, COD edge cases (vendor confirmed delivery offline), or support overrides. Rejects orders already paid or cancelled. If the order was at pending_payment, transitions it to confirmed and commits the inventory reservation.
Body
{
"externalReference": "NEFT-UTR-12345", // optional, 1..200
"reason": "Customer wired funds directly" // optional, 1..500
}Response 200 — updated OrderResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 409 | CONFLICT | Already paid or cancelled |
POST /admin/orders/:id/mark-refunded — Mark refunded
Required permission: order: update. v1 refund flow is admin-driven — the admin issues the refund out-of-band in the gateway's dashboard, then calls this to flip payment_status to refunded. Order lifecycle status (confirmed/delivered) stays as-is — refund is a money-only operation.
Body
{
"amount": 100000, // optional; defaults to remaining unpaid amount
"returnId": "01J9...", // optional; links event to an order_return row
"externalReference": "RZP-RFND-abc", // optional, 1..200
"reason": "Goodwill credit for late delivery"
}| Field | Type | Constraints |
|---|---|---|
amount | int? | >= 1 subunits. Defaults to the remaining unpaid amount |
returnId | string? | 1..100; links per-return refund counter |
externalReference | string? | 1..200 |
reason | string? | 1..500 |
Response 200 — refunded OrderResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 409 | CONFLICT | Order not in a refundable state |
POST /admin/orders/:id/refunds — Refund through the payment gateway
Required permission: order: refund. Provider-agnostic: dispatches to whichever gateway placed the order, for any provider implementing the payment port's refund capability. Prefer this over mark-refunded when it is available — the money actually moves, rather than the platform recording that someone moved it by hand.
Gateway refunds are asynchronous. The response describes the refund that was accepted, not one that has settled: state is normally PENDING, and the order's payment_status moves only when the provider confirms (its webhook, or the sync endpoint below). Some gateways settle synchronously, in which case the row comes back COMPLETED and the order is already updated.
gatewayRefundSupported on the order response says whether this endpoint applies. Orders on COD, or on a gateway without the capability, use mark-refunded instead.
Body
{
"amount": 50000, // optional; defaults to the remaining unrefunded balance
"returnId": "01J9...", // optional; links to an order_return row
"reason": "Damaged on arrival" // optional, 1..500
}| Field | Type | Constraints |
|---|---|---|
amount | int? | Positive subunits. Defaults to the remaining balance. Already-settled and in-flight refunds both count against the ceiling, so two quick calls cannot over-refund |
returnId | string? | Advances that return's refund counter and reverses the vendor's ledger entry |
reason | string? | 1..500; recorded on the order timeline |
Response 200 — the PaymentRefund row:
{
"id": "01J9...",
"orderId": "01J9...",
"returnId": null,
"provider": "phonepe",
"merchantRefundId": "RFND-ORD-2026-00000123-a1b2c3d4e5f6",
"providerRefundId": "OMR7878098045517540996",
"amountSubunits": 50000,
"state": "PENDING",
"reason": "Damaged on arrival",
"initiatedBy": "01J9...",
"createdAt": "2026-07-30T11:22:33.456Z",
"updatedAt": "2026-07-30T11:22:33.456Z"
}initiatedBy matters beyond audit: when the provider's webhook lands, that user is replayed as the actor on the resulting order event, so the refund is always attributable to a real person.
Errors
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | The order's provider has no gateway-refund capability — use mark-refunded |
| 400 | BAD_REQUEST | Amount is not positive, or exceeds the remaining balance |
| 404 | NOT_FOUND | Unknown id |
| 409 | CONFLICT | Order is not paid / partially_refunded |
GET /admin/orders/:id/refunds — Gateway refund history
Required permission: order: refund. Newest first. Includes PENDING rows, which are not yet reflected in the order's refunded amount — surface them so an operator does not double-refund while one is in flight.
Response 200 — PaymentRefund[].
POST /admin/orders/:id/refunds/:merchantRefundId/sync — Re-read a pending refund
Required permission: order: refund. Recovery path for a refund whose completion webhook never arrived: polls the provider and applies the result. A no-op when the refund has already settled.
Response 200 — the PaymentRefund row after syncing.
Errors
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Provider cannot report refund state |
| 404 | NOT_FOUND | Unknown refund, or it belongs to a different order |
GET /admin/orders/:id/events — Paginated audit log
Required permission: order: view. Order detail inlines only the most recent ~50 events; this endpoint pages through the full history.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | — | — |
eventType | string? | — | Trimmed, 1..128. Narrow to one event class |
Response 200 — paginated envelope of OrderEventResponse[].
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown order id |
POST /admin/orders/cleanup-stale-pending — Stale pending-payment sweep
Required permission: order: cancel. Out-of-band escape hatch matching the hourly BullMQ cron. Cancels orders stuck in pending_payment longer than timeoutHours (defaults to admin.payment.pending_timeout_hours). Caps blast radius at limit orders per call.
Body
{ "timeoutHours": 24, "limit": 200 }| Field | Type | Constraints |
|---|---|---|
timeoutHours | int? | 0..720. Defaults to admin.payment.pending_timeout_hours |
limit | int? | 1..1000. Defaults to the constant PENDING_PAYMENT_SWEEP_BATCH |
Response 200
{ "data": { "scanned": 312, "cancelled": 14, "errors": 0 }, "message": "Success", "statusCode": 200 }Returns
GET /admin/returns — List returns
Required permission: order: view. Cross-vendor list.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | — |
limit | int | — | — |
status | string? | — | Trimmed, 1..32. Filter by return status |
Response 200 — paginated envelope of ReturnResponse[].
GET /admin/returns/:id — Return detail
Required permission: order: view.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/orders/:id/returns — Force-create a return
Required permission: order: update. Support / dispute-resolution path: admin creates a return without the customer's own request. Optionally pre-approves the return and overrides the computed refund_amount.
Body
{
"orderVendorId": "01J9...",
"reasonCode": "damaged_in_transit",
"reasonNotes": "Customer photos in support ticket #ABC-123",
"lines": [
{ "orderLineId": "01J9...", "quantity": 1, "reasonCode": "damaged_in_transit" }
],
"preApprove": true,
"refundAmountOverride": 90000
}| Field | Type | Constraints |
|---|---|---|
orderVendorId | string | Required; the sub-order being returned |
reasonCode | string | 1..64 |
reasonNotes | string? | max 2000 |
lines[] | array | 1..100 entries |
preApprove | boolean? | Skip vendor approval step |
refundAmountOverride | int? | >= 0 subunits |
Response 200 — created ReturnResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 404 | NOT_FOUND | Order or sub-order not found |
POST /admin/returns/:id/override — Admin override transition
Required permission: order: update. Escape hatch transitions on a wedged return.
action | Effect |
|---|---|
"force_refund" | Clears qc_failure and moves to qc_passed so /admin/orders/:id/mark-refunded accepts the returnId |
"approve" | Reverses a vendor rejection — back to approved |
"cancel" | Cancels the return |
Body
{ "action": "force_refund", "reason": "QC dispute escalation; refunding as goodwill" }| Field | Type | Constraints |
|---|---|---|
action | enum | One of force_refund / approve / cancel |
reason | string | Required; 1..500 |
Response 200 — overridden ReturnResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 404 | NOT_FOUND | Unknown id |
Vendor payouts
GET /admin/vendors/:id/balance — Vendor balance summary
Required permission: payout: view.
Response 200 — VendorBalanceResponse.
GET /admin/vendors/:id/ledger — Vendor ledger
Required permission: payout: view.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | — |
limit | int | — | — |
kind | "sale" | "refund" | "manual" | "commission_adjustment"? | — | — |
status | "pending" | "available" | "paid_out" | "cancelled"? | — | — |
Response 200 — paginated envelope of LedgerEntryResponse[].
GET /admin/vendors/:id/payouts — Vendor payouts
Required permission: payout: view.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | — |
limit | int | — | — |
status | "pending" | "paid" | "cancelled" | "failed"? | — | — |
Response 200 — paginated envelope of PayoutResponse[] (without entries).
GET /admin/payouts — Cross-vendor payout list
Required permission: payout: view. Same query as the per-vendor list.
Response 200 — paginated envelope of PayoutResponse[] (without entries).
GET /admin/payouts/:id — Payout detail
Required permission: payout: view. Returns the payout with its linked ledger entries inlined.
Response 200 — PayoutResponse with entries[] populated.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
GET /admin/vendors/:id/payout-config — Per-vendor payout policy
Required permission: payout: view. Returns the vendor's payout-policy override, the resolved effective values, and the platform defaults each null override inherits from. Backed by the dedicated vendor_payout_config table (previously the admin.payouts per-vendor settings group).
Response 200
{
"override": {
"commissionRate": 250, // basis points, or null to inherit
"minPayoutAmountSubunit": null, // subunits, or null to inherit
"payoutHold": false,
"notes": ""
},
"resolved": { // what the payout engine uses
"commissionRate": 250,
"minPayoutAmountSubunit": 5000,
"payoutHold": false,
"notes": ""
},
"platformDefaults": { // admin.payouts.* fallbacks
"commissionRate": 1000,
"minPayoutAmountSubunit": 5000
}
}PUT /admin/vendors/:id/payout-config — Override a vendor's payout policy
Required permission: payout: configure. Full-representation upsert. commissionRate (basis points) and minPayoutAmountSubunit (subunits) are null to inherit the platform default; payoutHold freezes disbursements. The change is audit-logged via the vendor.payout_config_updated domain event. Vendors cannot see or edit these values.
Body
| Name | Type | Notes |
|---|---|---|
commissionRate | int (0–10000) | null | Basis points (1000 = 10%). null inherits admin.payouts.default_commission_rate. |
minPayoutAmountSubunit | int (≥0) | null | null inherits admin.payouts.min_payout_amount_subunit. |
payoutHold | boolean | Blocks createDraftPayout with 403 when true. |
notes | string (≤5000) | Private admin notes. |
Response 200 — same shape as GET, reflecting the new values.
GET /admin/vendors/:id/bank-account — Vendor payout destination (masked)
Required permission: payout: view. Read-only admin view of the vendor's payout bank account with the account number masked to its last 4 digits. There is no admin write — vendors manage their own account via PUT /vendor/bank-account.
Response 200 — { bankName, accountHolderName, accountNumberMasked, accountNumberLast4, routingNumber, swiftCode }, or null when none is on record.
POST /admin/vendors/:id/payouts — Create a draft payout
Required permission: payout: create. Picks all unattached available ledger entries (optionally within the supplied period), snapshots the sums, and creates a pending payout row. Rejects when the vendor is on hold (vendor_payout_config.payout_hold = true) or when no available entries exist.
Body
{
"periodStart": "2026-04-01T00:00:00.000Z",
"periodEnd": "2026-04-30T23:59:59.999Z",
"notes": "April settlement"
}| Field | Type | Notes |
|---|---|---|
periodStart | ISO datetime? | Inclusive lower bound. Default: no lower bound |
periodEnd | ISO datetime? | Inclusive upper bound. Default: now |
notes | string? | Trimmed, max 2000 |
Response 201 — draft PayoutResponse.
Errors
| Status | Code | When |
|---|---|---|
| 403 | FORBIDDEN | Vendor on payout hold |
| 400 | VALIDATION_ERROR | No available entries match the period |
POST /admin/payouts/:id/mark-paid — Mark a draft as paid
Required permission: payout: mark_paid. Admin pastes the bank reference (NEFT UTR, IMPS ref, etc.) from the offline transfer. Flips the payout to paid and every linked ledger entry to paid_out.
Body
{ "bankReference": "NEFT-UTR-12345", "notes": "Sent via HDFC NEFT" }| Field | Type | Constraints |
|---|---|---|
bankReference | string | Required, trimmed, 1..200 |
notes | string? | Trimmed, max 2000 |
Response 200 — paid PayoutResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 409 | CONFLICT | Payout not in pending |
POST /admin/payouts/:id/cancel — Cancel a draft
Required permission: payout: cancel. Releases linked ledger entries back to the pool (available).
Body
{ "reason": "Wrong period selected; redrafting" }| Field | Type | Constraints |
|---|---|---|
reason | string | Required, trimmed, 1..500 |
Response 200 — cancelled PayoutResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/vendors/:id/ledger/adjust — Manual ledger adjustment
Required permission: payout: adjust. Use for chargebacks, goodwill credits, off-platform reconciliation. Lands as a manual or commission_adjustment entry in available status — picked up by the next payout.
Body
{
"amount": 50000, // signed subunits; positive credits, negative debits
"kind": "manual", // or "commission_adjustment"
"description": "Q1 goodwill credit"
}| Field | Type | Constraints |
|---|---|---|
amount | signed int | No min — manual adjustments bypass commission math |
kind | enum | "manual" or "commission_adjustment" |
description | string | Required, trimmed, 1..500 |
Response 200 — updated VendorBalanceResponse.
POST /admin/payouts/promote — Manually promote pending → available
Required permission: payout: create. Ops escape hatch when the BullMQ cron has not run or has been disabled. Runs PayoutService.promotePendingEntries() once.
Response 200
{ "data": { "promoted": 42 }, "message": "Success", "statusCode": 200 }Related modules
admin-rbac— gates every endpoint viaorder:*andpayout:*. Seeadmin-rbac.md.cart— converted carts produce orders. Seecart.md.inventory— order placement reserves;mark-paidcommits; cancel releases.payment— emitspayment.capturedevents that drive the automark-paidpath (manual override goes through this admin endpoint).shipping— vendors transition sub-orders through fulfilment states.vendor—vendor.payouts.payout_holdsetting gatesPOST /admin/vendors/:id/payouts.settings—admin.payment.pending_timeout_hoursdrives the stale-pending sweep.notifications— order events emit notification triggers; seenotifications.md.
Notifications Module — Admin
HTTP surface for the admin notification operations: composing and sending broadcasts (one-shot email and/or push to a targeted audience) and inspecting the notification log…
Payment Gateways — Admin
Read-only option feeds describing which payment gateways a deployment registered, where each is enabled, and which checkout options exist — consumed by the settings UI's toggle-matrix and checkout-appearance pickers.