Orders (Vendor)
Executive summary
Section titled “Executive summary”Vendor orders covers list (tabs, sort, pagination), detail (lines, refunds/returns), and manual create at /dashboard/orders/create. Create matches POS RBAC: order module + write.
Goals and non-goals
Section titled “Goals and non-goals”| Goals | Non-goals |
|---|---|
| Operate omnichannel orders from dashboard | Replace POS for in-lane speed |
| Document create-order parity with POS permissions | Document carrier tracking integrations |
Current platform behavior
Section titled “Current platform behavior”/dashboard/orders— list search supports typing, camera scan (same QR/barcode modal as POS), and USB keyboard-wedge scanners; the query matches order ID, receipt barcode (EAN-13), and customer-oriented fields per the vendor orders list API./dashboard/orders/create—ProtectedRouteorder+requireModuleWrite.- See POS vs dashboard “create order” below.
Sequence: happy path
Section titled “Sequence: happy path”- Open Orders → filter tab → open order.
- Actions (refund/return) per UI enablement.
- Create (if allowed) → lines → submit.
Returns and refunds (vendor dashboard)
Section titled “Returns and refunds (vendor dashboard)”There are two different flows:
| Flow | Where | Purpose |
|---|---|---|
| Web order return request | Orders → order detail (/dashboard/orders/:id) | Record a return/refund request for an order placed through the dashboard or other channels. Sets order_status to return requested, marks selected lines, creates/updates a refunds row with status requested / pending. |
| POS-originated orders | Same order detail path when the order appears in the vendor list | The Nest POS module exposes place order + payments + catalog, not a separate return API. Treat returns/refunds through the web flow above once the order is visible under Orders. See Sales, POS & receivables (procedures) §3. |
Web flow (high level)
Section titled “Web flow (high level)”- Eligible — Order delivered or completed (including walk-in / guest orders with no linked customer). Staff with
ordermodule access opens detail and uses Request return / refund (pick lines) or Order actions → Return entire order to mark every eligible line in one step. - Pending review — Refund row exists; someone with
financemodule write approves or rejects (PATCH /vendor/finance/refunds/:idwithaction:approve|reject). Can be done from Finance → Refunds or from the same order detail page. Approve finalizes settlement immediately (status refunded in one step for most methods). - Settlement — Wallet credit, loyalty rollback, and optional terminal card RETURN run on approve/refund. Inventory: lines marked
is_return = 1are restocked into the store’s default warehouse (stocks.available_qty+ aggregateditems.stock). This does not create a purchase lot or changeavg_purchase_price. Audit metadata may includereturn_restock(warehouse id, line count). If the store has no warehouse, settlement still completes but stock is skipped (skipped_no_warehouse). - Two-step refund — When a refund row is already approved, Mark as refunded (
action:refund) runs the same settlement (including restock once per refund; not repeated if status was already refunded). - Cancel request — While the refund is still requested / pending (not approved or refunded), staff can Cancel return request (
POST /vendor/orders/cancel-return), which clears line return flags and sets the order back to delivered (no stock change).
Audit trail
Section titled “Audit trail”GET /vendor/orders/:id includes audit_logs (newest first): refund actions (return_requested, return_cancelled, approved, rejected, refunded), order patches (order_updated), payment changes, etc. The context field records whether the action came from order_detail, finance_refunds, or vendor_orders so support can see which screen was used. Actor identity is stored as the vendor JWT sub on both actor_sub and actor_id (varchar public ids — not legacy numeric ints).
The same response also exposes debt and delivery context when present on the order row: is_debt, force_customer_debt, delivery_date (date-only string), delivery_partner (id, name, code, scoped to the vendor), and a receivable object when a receivables row exists for that order_id (amounts, status / status_key, deadlines). The vendor order detail screen surfaces this next to delivery details and links to Finance → Receivables for follow-up.
| Action | Module |
|---|---|
| Request / cancel return on order detail | order (write where enforced by route) |
| Approve / reject / mark refunded | finance with write |
Security and operations
Section titled “Security and operations”- Refunds are financially sensitive—verify caller role and store context.
- Treat audit_logs as the source of truth for who changed refund state and from which surface.
Test scenarios (UAT / QA)
Section titled “Test scenarios (UAT / QA)”| ID | Scenario | Expected |
|---|---|---|
| VO1 | List/detail | Data matches API |
| VO2 | Employee without order write | Cannot open create |
| VO3 | Refund action | Policy-compliant result |
| VO4 | Order detail activity log | Shows refund + finance actions with correct context |
| VO5 | Create → Save draft | Order created with order_status pending |
| VO6 | Create → Khách nợ + CRM / linked retail customer | is_debt / receivable path; not available for guest (requires customer_id and/or user_id) |
| VO7 | Create wholesale → on-account | wholesale-credit-status banners; deny/warn when policy wholesale_max_open_debt_rounds_rule applies; overdue → warning only |
Routes
Section titled “Routes”| Route | Purpose |
|---|---|
/dashboard/orders | Paginated list with tabs (all / paid / pending / cancelled / returns) and sort |
/dashboard/orders/wholesale | Same list UX scoped to wholesale channel (orders.type = 1) |
/dashboard/orders/:id | Order detail, line items, refund/cancel-return actions where enabled |
/dashboard/orders/create | Manual retail order creation (same RBAC as POS: order module + write) |
/dashboard/orders/create-wholesale | Manual wholesale order creation (wholesale pricing / sale type wholesale; same module + write) |
Access: /dashboard/orders/create and /dashboard/orders/create-wholesale use ProtectedRoute requiredModule="order" requireModuleWrite (see app-router.tsx). The wholesale list tab uses requiredModule="order" without forcing write.
POS vs dashboard “create order”
Section titled “POS vs dashboard “create order””/posand/point-of-sale/salesubmit the cart withPOST /vendor/point-of-sale/orders(placePosOrderinpos.api.ts).store_idis required on the body (and on related GETs such asitems/grouped,customers,payment-status). This delegates toPosService.placeOrderviaPointOfSaleStorePosController(apps/backend/src/modules/point-of-sale/). LegacyPOST /pos/ordersstill exists onPosControllerfor unified admin/vendor JWT clients that omit explicit store resolution —vendor-webcheckout does not use it./dashboard/orders/createreuses the same cart/product UX patterns (features/orders/create-order-page.tsx) but submits withpostVendorPlaceOrder→POST /vendor/orders/place. The payload shape matchesPlacePosOrderPayload(same cart line structure as POS).
Both POS surfaces and manual create can show terminal / pending payment handling via the shared PlacePosOrderResponse helpers imported from pos.api.ts (payment status polling and payment-intent actions are aligned with /vendor/point-of-sale/orders/:id/… helpers).
Manual create: delivery, draft vs submit, and customer debt
Section titled “Manual create: delivery, draft vs submit, and customer debt”- Fulfillment mode — Pickup or Delivery. Delivery opens a two-column modal (recipient + full delivery address (street line + optional floor/road/house), delivery date, customer shipping fee and “customer pays”, deposit + method, COD; package weight/dimensions; delivery partner, handoff method, pickup address, waybill, driver notes). Extra structured fields may still be sent on the API payload when set elsewhere. Values are merged into the place-order payload (
delivery,delivery_charge,delivery_instruction, etc.) perPlacePosOrderPayload/ManualDeliveryPayload. - Customer pays shipping — when enabled on a delivery order, the entered shipping fee is not added to
order_amount. The amount is instead kept in delivery metadata/instruction so the delivery partner can collect it from the customer. - Delivery modal defaults — Delivery date defaults to today (
date-fns→yyyy-MM-dd) when empty, withminset to today (delivery-form-dates.ts). Recipient name / phone / email are applied when the modal opens (and whenever the customer changes while the modal is closed). Pickup address is pre-filled from the header-selected store (store-pickup-address.ts) when the field is empty. submit_as—draftkeeps the order as a hold (order_status:pending).submit(default) moves new orders toawaiting_confirmationso employees, the linked customer, and delivery partners can see the order. Implemented inapps/backend/src/modules/pos/pos.service.ts(initialOrderStatus). On/dashboard/orders/create(retail and wholesale), Save draft calls the same place API withsubmit_as: draftbut does not open the checkout payment modal or payment-success panel — only a success toast and cart reset; use Checkout → Pay now for the modal flow.- Customer debt (“Khách nợ”) — Optional when checkout has a non-guest customer: retail
user_idand/or CRMcustomer_idon the payload (CRM-only profiles without an app login still qualify whencustomer_idis set). The UI sendsforce_customer_debt: trueandpaid_amount: "0.00"so the sale is recorded on account (is_debton the order). Guest checkout cannot use this flag (API validation). Cash shortcut paths that would auto-close payment now follow fulfillment type:pos/take_away→completed,delivery→delivered; all of them skip auto-close whenforce_customer_debtis true so debt sales are not treated as fully settled cash. - Wholesale (B2B) debt policy — Applies when the order is wholesale (
OrderSaleType.Wholesale/orders.type = 1) and checkout uses the debt payment method (payment_method: debt/ on-account chip). The backend resolves term mode, due-day fields, max open debt rounds, and the “at cap” rule from customer → customer category → store (Customers (Vendor) § Wholesale (B2B) credit policy). Overdue wholesale receivables surface as warnings; hitting max open debt rounds may warn or block place-order depending on the resolvedwholesale_max_open_debt_rounds_rule— only for debt checkout, not for cash/card/wallet on the same wholesale cart. Retail debt limits on the customer profile (e.g.limit_amount_debt) are evaluated separately and are not a substitute for this wholesale policy. Dashboard create wholesale loadsGET /vendor/customers/:id/wholesale-credit-status?store_id=&placing_debt_order=1when the cashier selects debt payment, to preview placement before submit. - Persistence (Nest) — On place, the backend also writes
indochina_delivery_partner_id,indochina_delivery_date,indochina_place_submit_as,indochina_force_customer_debt, andindochina_pos_place_snapshot(JSON: submit/debt/paid summary + structured delivery subset) onorders, in addition to the mergeddelivery_instructiontext.POST /vendor/orders/placeappendsaudit_logswith debt/delivery metadata; adminPOST /pos/orderswrites a comparableaudit_logsrow (context:pos_place_order) when the actor is the admin panel.
Order status lifecycle (reference)
Section titled “Order status lifecycle (reference)”Statuses are stored on orders.order_status (varchar). Typical progression for dashboard-created delivery orders:
| Status | Meaning |
|---|---|
pending | Hold / quote — e.g. submit_as: draft or not yet submitted to fulfillment. |
awaiting_confirmation | Submitted; waiting for an actor (staff, customer, partner) to confirm. |
confirmed | At least one party has confirmed. |
processing | Fulfillment in progress. |
out_for_delivery | Handed off for delivery (e.g. no partner yet, retry after failure, or manual “ship again”). |
delivered | Physically delivered; payment may still be open (COD, partial, or debt). |
completed | Closed from delivery through collection / settlement. |
returned | Return path (see refunds section). |
failed | Terminal failure state for the order. |
cancelled / canceled | Vendor-initiated cancel when allowed (see below). |
Legacy values such as pre_order may still appear on older rows. payment_failed is treated like a failure/cancel window for vendor cancel rules.
Vendor cancel policy
Section titled “Vendor cancel policy”POST /vendor/orders/cancel (see vendor-orders.service.ts) only allows cancel when the current status is in ORDER_STATUSES_VENDOR_CANCELLABLE: pending, awaiting_confirmation, pre_order, failed, payment_failed. After confirmation into active fulfillment, cancel is blocked (can_not_cancel_after_confirm).
POS server-held drafts (place_submit_as=draft) can be removed from lists without going through cancel: POST /vendor/orders/draft/delete sets indochina_deleted_at (soft delete). Lists, overview, and POS draft reuse by order_id exclude rows with that timestamp.
List and detail APIs
Section titled “List and detail APIs”Implemented in apps/vendor-web/src/services/vendor-orders.api.ts (and used by the orders pages):
| Action | Endpoint |
|---|---|
| KPI strip | GET /vendor/orders/overview — optional store_id, date_from / date_to, search, order_type, pos_staff_only (1 / true = only orders with an attributed POS cashier). Counts follow the same filter shape as the list when these are set. |
| List | GET /vendor/orders (pagination, filters, sort — see VendorOrderListSort). Query search matches numeric order id, invoice_barcode (receipt EAN-13, substring), and linked customer name / email / phone. Additional exact filters: order_type (delivery / parcel / pos / take_away), order_status (raw status key), and pos_staff_only (same semantics as overview). List rows include both created_at and updated_at timestamps plus order_type, place_submit_as (e.g. draft for POS server-held drafts — aligns with tab=draft, which also excludes cancelled / failed order_status rows even if place_submit_as is still draft), and derived delivery_progress_status (waiting / in_delivery / delivered / failed) for table display/filtering; POS rows may include vendor_employee (cashier snapshot). |
| Detail | GET /vendor/orders/:id |
| Receipt HTML | GET /vendor/orders/:id/receipt-html — default JSON { html, document_title, order_id }; ?format=html returns raw HTML. Optional ?tip_signature=1 (or true / yes / merchant) appends the merchant copy block: suggested tips (15% / 18% / 20% on net excl. tax), blank lines for custom tip/total, signature, and Merchant Copy footer instead of the customer copy. |
| Update | PATCH /vendor/orders/:id (delivery details fields delivery_instruction / delivery_date / delivery_partner_id are accepted only for order_type=delivery, and only when progress is waiting or failed) |
| Refund reasons meta | GET /vendor/orders/meta/refund-reasons |
| Refund request | POST /vendor/orders/refund-request — optional auto_approve approves in one step and skips the pending “Return requested” vendor inbox notification |
| POS return (complete refund) | POST /vendor/orders/pos-return-submit — same body as refund-request (no auto_approve); approves then marks refunded when the row would otherwise stay approved (e.g. wallet/manual); original paid-card flows may already reach refunded during approve; skips that inbox notification |
| Cancel return | POST /vendor/orders/cancel-return |
| Soft-delete POS draft | POST /vendor/orders/draft/delete — body { order_id }; draft only, not cancelled rows |
Related
Section titled “Related”- Sales, POS & receivables (procedures) — POS vs create-order vs debt vs returns.
- POS — in-store checkout and
/posAPIs. - Vendor panel overview — module
orderfor route access.