Order Module — Vendor surface
Vendor-facing HTTP surface for sub-order fulfillment (assign shipping provider, mark fulfilled, mark delivered, cancel), returns processing (approve / reject / pickup / receive /…
Vendor-facing HTTP surface for sub-order fulfillment (assign shipping provider, mark fulfilled, mark delivered, cancel), returns processing (approve / reject / pickup / receive / QC), and payouts (balance, ledger, and payout history). A parent order row can have one or more order_vendor sub-orders; the vendor surface only ever sees its own sub-orders and the customer's shipping address — never other vendors' slices, billing address, or the parent's payment internals.
Source:
api-modules/order/src/controllers/vendor-orders.controller.ts,api-modules/order/src/controllers/vendor-returns.controller.ts,api-modules/order/src/controllers/vendor-payouts.controller.ts.
Conventions
Authentication
All endpoints require a Better-Auth bearer session with an active vendor.
Authorization: Bearer <session-token>The vendor is resolved via resolveActiveVendorId(session) — prefers session.session.activeOrganizationId (better-auth canonical) and falls back to activeVendorId. Sessions missing an active vendor are rejected with 403 Forbidden (FORBIDDEN).
Tenant scoping
:id on order endpoints is order_vendor.id (a sub-order). :id on return endpoints is return_request.id. :id on payout endpoints is vendor_payout.id. Ids that belong to a different vendor return 404 Not Found, not 403 — no leak of foreign ids.
Response envelope
{
"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, INVALID_TRANSITION, SUB_ORDER_NOT_CANCELLABLE |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Money + dates
All monetary fields (subtotals, totals, refund amounts, ledger amounts, etc.) are integer subunits (paise / cents / eurocents — single currency per tenant). Date fields are ISO datetimes.
Audit trail
Every status mutation writes one or more rows to order_events in the same transaction as the change. Each row carries actor_type (user / vendor / admin / system / webhook), actor_id, source, structured changes, and optional metadata. The most recent rows ship inline on detail responses (events[]).
Domain types
Sub-order lifecycle — OrderVendorStatus
type OrderVendorStatus = "pending" | "fulfilled" | "delivered" | "cancelled";| Status | Set by | Reversible | Notes |
|---|---|---|---|
pending | created at place-order | yes | awaiting vendor to assign a shipping provider |
fulfilled | POST /vendor/orders/:id/fulfilled (or bulk-fulfill) | partial | provider/method/tracking persisted; cancel still allowed (courier rejected, RTO) |
delivered | POST /vendor/orders/:id/delivered (or courier webhook) | no | stamps delivered_at. For COD, the last delivered sub-order auto-flips parent payment_status → paid |
cancelled | POST /vendor/orders/:id/cancel, parent cancel cascade | no | releases this vendor's reservation; if all siblings cancelled, parent auto-cancels |
VendorOrderResponse
Vendor-scoped sub-order shape. Strips other vendors' sub-orders and the customer's billing address — vendors only see their slice + shipping address.
type TaxComponent = { type: string; rate: number; amount: number };
type VendorOrderResponse = {
id: string; // order_vendor.id
orderId: string;
orderNumber: string;
parentStatus: "pending_payment" | "confirmed" | "fulfilled" | "delivered" | "cancelled" | "refunded";
fulfillmentStatus: OrderVendorStatus;
subtotal: number; // subunits
discountAllocated: number;
shippingCost: number;
taxAmount: number;
total: number;
shippingProviderId: string | null; // null while pending
shippingMethod: string | null;
trackingCode: string | null;
awbNumber: string | null;
taxBreakdown: TaxComponent[]; // aggregated across lines + shipping
shippingNetAmount: number | null;
shippingTaxBreakdown: TaxComponent[];
shippingAddress: {
firstName: string; lastName: string;
fullAddress: string;
city: string; pincode: string; state: string;
phone: string; country: string;
};
lines: OrderLineResponse[];
events: OrderEventResponse[];
fulfilledAt: string | null; // ISO
deliveredAt: string | null;
cancelledAt: string | null;
cancellationReason: string | null;
placedAt: string;
};OrderLineResponse
type OrderLineResponse = {
id: string;
vendorId: string;
variantId: string | null;
productId: string | null;
sku: string;
productNameAtOrder: string;
variantNameAtOrder: string | null;
imageAtOrder: string | null;
hsnCodeAtOrder: string | null; // snapshotted from variant at place-order
type: "product" | "gift" | "shipping"; // see order.md for full type set
quantity: number;
unitPrice: number;
lineSubtotal: number;
discountAllocated: number;
lineTotal: number;
netAmount: number | null; // pre-tax portion (null when vendor has no taxes)
taxBreakdown: TaxComponent[];
};ReturnResponse
type ReturnResponse = {
id: string;
returnNumber: string;
orderId: string;
orderVendorId: string;
customerId: string | null;
vendorId: string;
type: string; // e.g. "refund", "exchange"
status: string; // requested → approved → picked_up → received → qc_passed/qc_failed → refunded | rejected | 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; // ISO
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[];
};
type ReturnLineResponse = {
id: string;
orderLineId: string;
variantId: string | null;
quantity: number;
unitPrice: number;
taxPortion: number;
lineRefundAmount: number;
reasonCode: string | null;
reasonNotes: string | null;
restocked: boolean;
};
type ReturnPhotoResponse = {
id: string;
storageKey: string;
contentType: string | null;
fileSizeBytes: number | null;
sortOrder: number;
uploadedAt: string;
};Payouts — VendorBalanceResponse, LedgerEntryResponse, PayoutResponse
type VendorBalanceResponse = {
vendorId: string;
pending: number; // 'pending' ledger entries (return window not elapsed)
available: number; // 'available' entries not yet on a draft payout
lifetimeEarned: number;
lifetimeRefunded: number;
lifetimePaidOut: number;
payoutHold: boolean; // vendor.payouts.payout_hold flag
commissionRate: number; // basis points (10000 = 100.00%)
};
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;
};
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 on detail
};Vendor sub-orders
Base path: /vendor/orders. :id is order_vendor.id.
GET /vendor/orders — List my sub-orders
Paginated, newest first.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..100 |
status | OrderVendorStatus? | — | Optional filter |
Response 200 — paginated envelope of VendorOrderResponse.
GET /vendor/orders/:id — Sub-order detail
Returns the active vendor's VendorOrderResponse for one sub-order.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Sub-order does not belong to active vendor |
POST /vendor/orders/:id/cancel — Cancel sub-order
Allowed from pending (declining fulfillment) or fulfilled (courier rejected, package returned, etc.). delivered and cancelled reject. For fulfilled cancels, a non-empty reason is required.
Body
{ "reason": "Out of stock at warehouse" }| Field | Type | Constraints |
|---|---|---|
reason | string? | Trimmed; 1..500 chars. Required when sub-order is fulfilled |
Response 200 — updated VendorOrderResponse with fulfillmentStatus="cancelled" and cancelledAt stamped.
Side effects
- Releases this vendor's inventory reservation (no-op when already committed at place-order).
- Fires the shipping registry's
cancelShipment()hook (best-effort courier void). - Writes an audit row + emits
order.vendor.cancelled. - If every sibling sub-order is now cancelled, the parent order auto-cancels.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Sub-order not owned by vendor |
| 409 | SUB_ORDER_NOT_CANCELLABLE | Status is delivered or already cancelled |
| 400 | VALIDATION_ERROR | Cancelling a fulfilled sub-order without a reason |
POST /vendor/orders/:id/fulfilled — Mark fulfilled
Assigns a shipping provider, dispatches provider.createShipment(), and transitions pending → fulfilled. Only allowed when the sub-order is pending.
Body
{
"providerId": "clickpost",
"method": "express",
"trackingCode": "CP123456", // optional
"awbNumber": "AWB987654" // optional
}| Field | Type | Constraints |
|---|---|---|
providerId | string | Required. Must be enabled for the active vendor |
method | string | Required. Must belong to that provider |
trackingCode | string? | Trimmed; 1..200 chars. Some providers fill this asynchronously via webhook |
awbNumber | string? | Trimmed; 1..200 chars |
Response 200 — updated VendorOrderResponse.
Side effects
- Validates
(providerId, method)against the vendor's enabled providers. - Dispatches
provider.createShipment()— provider failure rolls back the transition (sub-order stayspending). - Writes audit row + emits
order.vendor.fulfilled.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Sub-order not owned by vendor |
| 409 | INVALID_TRANSITION | Sub-order is not pending |
| 400 | VALIDATION_ERROR | Provider/method invalid for this vendor |
POST /vendor/orders/bulk-fulfill — Bulk fulfill
Apply the same provider/method/(tracking)/(awb) across up to 200 sub-orders in one call. Each sub-order runs in its own transaction so one failure does not roll back the rest. Vendors needing per-order tracking codes should call the single-order endpoint.
Body
{
"orderVendorIds": ["01J9...", "01J9..."],
"providerId": "clickpost",
"method": "express",
"trackingCode": "CP123456", // optional, applies to every id
"awbNumber": "AWB987654" // optional, applies to every id
}| Field | Constraints |
|---|---|
orderVendorIds | 1..200 ids |
Response 200
{
"data": {
"successful": ["01J9...", "01J9..."],
"errors": [
{ "orderVendorId": "01J9...", "reason": "Sub-order not in pending status" }
]
},
"message": "Success",
"statusCode": 200
}POST /vendor/orders/:id/delivered — Mark delivered
Allowed only when the sub-order is fulfilled. Stamps deliveredAt and transitions to delivered.
Body — empty.
Response 200 — updated VendorOrderResponse.
Side effects
- Audit row +
order.vendor.deliveredevent. - COD auto-paid: when every non-cancelled sub-order on the parent is delivered, the parent's
payment_statusflips topaidandpaid_atis stamped — the customer has paid every vendor on delivery. Anorder.paidevent fires.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Sub-order not owned by vendor |
| 409 | INVALID_TRANSITION | Sub-order is not fulfilled |
Vendor returns
Base path: /vendor/returns. :id is return_request.id. Return requests are created by the customer (or admin) against a delivered sub-order; the vendor approves/rejects, schedules pickup, receives the parcel, and runs QC. Refunds themselves are admin-actioned.
GET /vendor/returns — List my returns
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..100 |
status | string? | — | Trimmed; 1..32 chars. Filter by return status |
Response 200 — paginated envelope of ReturnResponse.
GET /vendor/returns/:id — Return detail
Response 200 — ReturnResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Return not owned by active vendor |
POST /vendor/returns/:id/approve — Approve a return
Optionally override the computed refund_amount (capped between 0 and the system-computed amount).
Body
{ "refundAmountOverride": 49900 } // optional| Field | Type | Constraints |
|---|---|---|
refundAmountOverride | int? | >= 0 and ≤ computed refundAmount |
Response 200 — updated ReturnResponse with approvedAt stamped.
POST /vendor/returns/:id/reject — Reject a return
Body
{ "reason": "Item shows signs of wear inconsistent with the return reason" }| Field | Type | Constraints |
|---|---|---|
reason | string | Trimmed; 1..500 chars. Required |
Response 200 — updated ReturnResponse with rejectedAt and rejectionReason stamped.
POST /vendor/returns/:id/pickup — Mark courier pickup confirmed
Optionally attach courier identifiers from the pickup attempt.
Body
{
"awbNumber": "AWB12345", // optional
"trackingCode": "TRK67890" // optional
}| Field | Type | Constraints |
|---|---|---|
awbNumber | string? | Trimmed; 1..200 chars |
trackingCode | string? | Trimmed; 1..200 chars |
Response 200 — updated ReturnResponse with pickedUpAt stamped.
POST /vendor/returns/:id/receive — Mark the returned parcel as received
Body — empty.
Response 200 — updated ReturnResponse with receivedAt stamped.
POST /vendor/returns/:id/qc-pass — Pass QC and restock
Restocks each return line via the inventory module. Restock is best-effort per line — each call is idempotent on (return_request, return_line). Refund itself still requires admin action.
Body — empty.
Response 200 — updated ReturnResponse with qcPassedAt stamped and lines[].restocked = true where applicable.
POST /vendor/returns/:id/qc-fail — Fail QC (no restock)
Terminal for the vendor flow. Admin can still force-refund via the admin override endpoint.
Body
{ "reason": "Item arrived damaged beyond resale" }| Field | Type | Constraints |
|---|---|---|
reason | string | Trimmed; 1..500 chars. Required |
Response 200 — updated ReturnResponse with qcFailedAt and qcFailureReason stamped.
Vendor payouts
Base path: /vendor. Read-only — payout creation, marking paid, balance adjustments, and cancellation are all admin actions; the vendor sees the ledger and the payouts admin has cut.
GET /vendor/balance — Vendor balance
Snapshot of the active vendor's funds and commission rate.
Response 200 — VendorBalanceResponse.
{
"data": {
"vendorId": "01J9...",
"pending": 125000,
"available": 240000,
"lifetimeEarned": 5400000,
"lifetimeRefunded": 120000,
"lifetimePaidOut": 5040000,
"payoutHold": false,
"commissionRate": 1500 // 15.00%
}
}GET /vendor/ledger — Ledger entries
Paginated ledger for the active vendor. One row per sale, refund, manual adjustment, or commission adjustment.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..100 |
kind | "sale" | "refund" | "manual" | "commission_adjustment"? | — | Filter by entry kind |
status | "pending" | "available" | "paid_out" | "cancelled"? | — | Filter by entry status |
Response 200 — paginated envelope of LedgerEntryResponse.
GET /vendor/payouts — List my payouts
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..100 |
status | "pending" | "paid" | "cancelled" | "failed"? | — | Filter by payout status |
Response 200 — paginated envelope of PayoutResponse (without entries[]).
GET /vendor/payouts/:id — Payout detail
Returns the payout plus its constituent entries[] (full LedgerEntryResponse[]).
Response 200 — PayoutResponse with entries[] populated.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Payout not owned by active vendor |
Domain events
Emitted via EventEmitter2. The vendor surface only emits a subset; the full catalogue is in order.md.
| Event | Fired when |
|---|---|
order.vendor.fulfilled | Sub-order pending → fulfilled via POST /vendor/orders/:id/fulfilled |
order.vendor.delivered | Sub-order fulfilled → delivered via POST /vendor/orders/:id/delivered |
order.vendor.cancelled | Sub-order * → cancelled via POST /vendor/orders/:id/cancel |
order.cancelled | Auto-cascade when every sibling sub-order is cancelled |
order.paid | COD auto-paid when every non-cancelled sub-order is delivered |
order.return.* | Return lifecycle transitions on the vendor returns endpoints |
vendor.payout.* | Payout lifecycle (admin actions — read-only for vendors) |
Related modules
cart— orders are created by committing a prepared checkout; cart never appears on the vendor surface.inventory— sub-order cancel releases reservations; return QC-pass restocks.shipping+shipping-clickpost—providerId/methodvalidation and thecreateShipment()/cancelShipment()hooks live there. Seeshipping.md,shipping-clickpost.md.tax-flat— line and shippingtaxBreakdownis computed from per-vendor tax config. Seetax.md.notifications— listens toorder.vendor.*events for vendor-targeted email/push.
Notifications Module — Vendor surface
Vendor-facing HTTP surface for mobile device registration so the vendor app can receive push notifications (new-order alerts, payout updates, application status, etc.). The wider…
Product Attribute Module — Vendor surface
Vendor-facing read endpoints for platform-defined product attributes and attribute groups that the vendor product form binds to. Attributes themselves (definitions, codes, types,…