POS
Executive summary
Section titled “Executive summary”Cashier user guide (step-by-step): Cashier help center — Vendor Desktop lane selling, hardware, FAQ (browser POS is optional). Vietnamese: Trung tâm trợ giúp Thu ngân.
Vendor checkout ships as two full-screen sale routes that share the same pos.api.ts client:
/pos— classic fullscreen POS (dashboard-linked entry)./point-of-sale/sale— POS workspace “Sale” tab (PointOfSaleAppShell), including mobile bottom nav and embedded draft orders.
Both load catalog grouping, cart, coupons, customers, barcode scan, optional custom lines, and card payment polling / terminal flows. This page documents access rules, vendor checkout HTTP endpoints (prefixed /vendor/point-of-sale), and SPA behaviors.
For step-by-step ops spanning POS + manual orders + returns + receivables, see Sales, POS & receivables (procedures).
UI colors, typography, stats card surfaces, and logo vs store mark: Lion POS design system.
Goals and non-goals
Section titled “Goals and non-goals”| Goals | Non-goals |
|---|---|
| Enable staff to sell with correct RBAC and store context | Document acquirer-specific terminal firmware |
| Link payment polling and webhooks to technical docs | Replace POS payments & webhooks detail |
Current platform behavior
Section titled “Current platform behavior”ProtectedRouteon/posrequiresordermodule with write;/point-of-sale/*requirespos(read/write per route) via layout /canAccessVendorPath(vendor-module-access.ts). Backend acceptsposOR the legacy module on shared APIs (devices, shifts, finance reports, orders) viaVENDOR_POS_MODULE_ALIASES.- When Settings → Devices includes at least one payment terminal, the vendor dashboard sidebar shows Terminals (requires settings read). Each terminal opens
/dashboard/terminals/:deviceIdwith Details (saved device fields) and Bridge activity (same log UI as POS bridge agent, desktop/Electron only; a banner explains when logs match that terminal’s LAN target). - Orders submit via
POST /vendor/point-of-sale/orders(placePosOrder); payment-status polling and payment-intent / cancel / mark-success / terminal-post-capture helpers live inpos.api.tsunder the samePOS_VENDOR_CHECKOUT_PREFIX(/vendor/point-of-sale). Every request carries an explicitstore_id(mandatory for this controller). - Optional
order_idon place-order (POST /vendor/point-of-sale/ordersfromvendor-web, orPOST /pos/ordersfrom other clients) updates an existing draft row for the same store (replace line items / totals) instead of allocating a new id.submit_as: draftdoes not decrement catalog stock; finalizing (non-draft) applies inventory and clears the draft hold. - POS / manual create no longer auto-creates
invoicesrows by default. Invoice creation is opt-in via the place-order payload blockinvoice(customer billing fields). - Store context from
useVendorStorepassesstore_idwhere supported. invoice_barcodeon POS-placed orders is a 13-digit EAN-13 (digits only, GS1 mod-10 check digit) for handheld scanners; receipts render it as a real EAN-13 barcode (legacy numeric values fall back to Code 128).
Resume order from the orders list
Section titled “Resume order from the orders list”When the cashier clicks a row in POS orders (/point-of-sale/orders) the resume flow walks three layers in order, stopping at the first hit:
- Open tab in the workspace —
posSaleTabMatchesResumeRefmatches by Code128 barcode (case-insensitive), numericorders.id, or the CHAR(13) public id. - Local outbox (Electron only) —
loadPosLocalOutboxPlaceOrderForResumematches byoutboxId,payload.client_order_id, orpayload.invoice_barcode(also normalized viaposLocalOutboxRowMatchesOrderRef). - Server fetch —
fetchVendorOrderIdByResumeRefresolves the resume ref toorders.idand the workspace callsfetchVendorOrder:- Numeric
orders.id→ used directly, no list query. - POS-generated EAN-13 (
20xxxxxxxxxxxxwith valid mod-10 check digit) → decoded back toorders.idviaresolvePosServerOrderIdFromGeneratedInvoiceEan13. - CHAR(13) public id →
fetchVendorOrder(id)first; on 404 or wrong store, falls back to barcode search. INVCode128 barcode →fetchVendorOrdersList({ search, order_type: "pos" })with exact invoice_barcode match (server enforces viaisPosBarcodeLikeSearch, client picks newest exact hit). Wholesale / parcel orders share the sameinvoice_barcodecolumn, so theorder_type: "pos"filter is mandatory to avoid wrong-store collisions.- Barcode / client-submit-id refs must not fall back to fuzzy list search — returning a wrong order is worse than no result.
- Numeric
Electron only — named drafts storage: controlled by vendor_pos_named_draft_local_storage (vendor-pos-local-first.ts). Default on Electron is true so draft panel and orders-page share the same browser-style localStorage bucket as the web (recommended for parity). Set to 0 to force SQLite outbox only.
/point-of-sale/barcode— Barcode labels workbench: preview and print shelf labels (browser print dialog). Labels are laid out in a wrapping grid (several per row on wide paper, auto line breaks). Server validation usesGET /vendor/point-of-sale/barcode/validate?store_id=…&value=…&symbology=…so symbology rules stay aligned with the API. Internal weighted WEIGHT items use@indochina/sharedhelpers (encodeInternalWeightedEan13Lb,isInternalWeightedEan13Digits, same layout asdefaultInternalWeightedEan13Rule); see Grocery / supermarket POS.- The receipt transaction record block lists INVOICE #, TERMINAL # (masked), ACCT / CARD when the bridge sends card metadata, DATE/TIME, REF # (payment reference), AUTH #, then register / cashier / channel lines. Optional MERCHANT # appears when
merchant_mid(or similar) is present onterminal_payment_meta.
Sequence: happy path
Section titled “Sequence: happy path”- Cashier opens
/pos(orderwrite) or/point-of-sale/sale(poswrite, or legacyorderwrite). - Ensures store is selected (
store_idon API calls — POS workspace uses the same store selector pattern as the sale column). - Adds lines, optional customer/coupon, submits →
placePosOrder→POST /vendor/point-of-sale/orders. - If card / terminal pending → UI polls
payment-statusuntil paid or failed; terminal bridge calls webhook (POS terminals & ECR Hub, CodePay ECR Hub).
Customer debt from POS (API vs vendor UI)
Section titled “Customer debt from POS (API vs vendor UI)”PlaceOrderDto(used by bothPOST /vendor/point-of-sale/ordersandPOST /pos/orders) acceptsforce_customer_debtwhen a debt subject exists (user_idand/orcustomer_id) — seecomputePosOrderDebtFlagsin the backend.- The vendor POS screens (
/pos,/point-of-sale/sale) expose payment mode debt (“Khách nợ”) when a non-guest customer is attached: they sendpayment_method: "debt",paid_amount: "0.00", andforce_customer_debt: true(guest disables debt in the UI)./dashboard/orders/createuses the same debt semantics via its toggle. - Optional
invoicepayload (customer_name,customer_phone,customer_email,customer_address,company_name,tax_code) creates an invoice row only when at least one field is provided. - Partial payment (
paid_amount< total) withuser_id/customer_idcan still createreceivables/is_debtat the API layer; the default POS UI does not expose a full partial-pay editor.
Security and operations
Section titled “Security and operations”- Physical card data stays on the terminal / PSP path; browser only drives status via APIs.
- Terminal API key for webhooks is per store device—see technical payment docs.
- Drafts persist locally (
pos-draft-storage); clear on shared devices if policy requires.
Test scenarios (UAT / QA)
Section titled “Test scenarios (UAT / QA)”| ID | Scenario | Expected |
|---|---|---|
| P1 | Cash sale | Order completes without terminal pending |
| P2 | Card + terminal success | Webhook / poll → paid state in UI |
| P3 | Employee without pos / order write | Cannot open /point-of-sale/sale |
Place order — shared client hook (Vitest + Playwright contract)
Section titled “Place order — shared client hook (Vitest + Playwright contract)”All vendor surfaces that submit a cart checkout reuse usePosPlaceOrder (apps/vendor-web/src/hooks/use-pos-place-order.ts) on top of TanStack useMutation. The hook always:
- Clears
placeOrderStockHighlightLineKeyononMutate(before each attempt). - On
onError, runs the screen-specificonErrorfirst (when provided), then callsapplyPosPlaceOrderStockErrorHighlight(lib/pos-place-order-mutation-handlers.ts) soerrors[0].code === "stock"highlights the matching cart line consistently.
| Surface | File | mutationFn |
|---|---|---|
| Dashboard POS | app/dashboard/pos/page.tsx | Default placePosOrder → POST /vendor/point-of-sale/orders |
| POS workspace (pay + server named draft) | app/point-of-sale/pages/sale-page.tsx | Two hook instances, both default placePosOrder (checkout + submit_as: draft save) |
| Manual create order | features/orders/create-order-page.tsx | postVendorPlaceOrder → POST /vendor/orders/place (same payload shape) |
Automated coverage
- Unit:
lib/pos-place-order-mutation-handlers.spec.ts— stock vs non-stock error handling for the highlight helper. - Unit:
lib/pos-order-receipt-finalize.spec.ts— post-checkout receipt finalize (fetch, cache invalidation, fallback snapshot, optional silent print). - Contract (Vitest):
hooks/use-pos-place-order.contract.spec.ts— asserts the three files above still importusePosPlaceOrderand that create-order passespostVendorPlaceOrder; sale page keeps two hook call sites. - Playwright (util / contract):
e2e/pos-place-order-surfaces.spec.ts— same source checks sonpm run test:e2efails if a screen drops the hook.
Receipt after checkout — shared finalize helper
Section titled “Receipt after checkout — shared finalize helper”After a successful place order, Dashboard POS (/pos), POS workspace sale (/point-of-sale/sale), and manual create order (/dashboard/orders/create) share runVendorPosOrderReceiptFinalize (apps/vendor-web/src/lib/pos-order-receipt-finalize.ts). In order, it:
- Calls
fetchVendorOrderReceiptDataForPrintwith the normalized order id, locale, and extras (invoice_barcode/terminal_payment_meta/ trimmedtransaction_ref), plus an optional cart fallback snapshot when the API has not materialized receipt fields yet. - Invalidates
vendorOrdersQueryKeys.detail(orderId), the["vendor-orders"]list bucket, andposQueryKeys.allso lists and POS caches refresh. - When the store’s
receipt_print_modeis non-zero (see Employees & stores → POS receipt prefs) and there is receipt payload data, builds per-device print jobs from enabled receipt printers + prefs, then runsattemptPrintReceiptInvoiceJobs(silent / Electron print path). When auto-print is off, the UI can still openPosOrderReceiptDialogor trigger a PDF download from the same payload.
Route and access
Section titled “Route and access”/posis wrapped withProtectedRoute requiredModule="order" requireModuleWriteinapp-router.tsx./point-of-sale/*lives underPointOfSaleAppShellwithrequiredModule="pos"(write required for/point-of-sale/saleand wholesale perdashboardPathRequiresModuleWrite). Settings hardware, reports, and device CRUD acceptposread/write on those paths; APIs also accept legacysettings/finance/orderwhen the role was not migrated yet.- That means:
- Owners always pass.
- Employees need
pos(or legacy alias slugs) inprofile.moduleswith the right read / write level (seevendorHasModuleWriteForRoute/vendorHasModuleReadForRouteinvendor-module-access.ts).
The order + write requirement still applies to /pos, /dashboard/orders/create, and dashboard order lists.
Behavior (frontend)
Section titled “Behavior (frontend)”- Unauthenticated users are sent to
/(login). - Authenticated users without the required module are redirected to
/dashboard/access-denied(seeProtectedRoute).
canAccessVendorPath maps /point-of-sale/... to module pos (with legacy read/write aliases for order, product, customer, settings, finance).
Data and APIs (code-backed)
Section titled “Data and APIs (code-backed)”The classic POS screen (apps/vendor-web/src/app/dashboard/pos/page.tsx) and the POS workspace sale page (apps/vendor-web/src/app/point-of-sale/pages/sale-page.tsx) load catalog and checkout through @/services/pos.api.ts:
| Concern | Endpoint / helper |
|---|---|
| Menu (grouped by category) | GET /vendor/point-of-sale/items/grouped — fetchPosItemsGrouped |
| Customers (search) | GET /vendor/point-of-sale/customers — fetchPosCustomers |
| Promotions / coupons list | GET /vendor/point-of-sale/promotions — fetchPosPromotions |
| Apply coupon | POST /vendor/point-of-sale/apply-coupon — applyPosCoupon |
| Place order (checkout) | POST /vendor/point-of-sale/orders — placePosOrder (via usePosPlaceOrder on dashboard POS + POS workspace; mutationFn: postVendorPlaceOrder on /dashboard/orders/create) |
| Payment status poll | GET /vendor/point-of-sale/orders/:orderId/payment-status — fetchPosOrderPaymentStatus |
| Terminal-style payment actions | POST /vendor/point-of-sale/orders/:orderId/payment-intent, mark-payment-success, cancel-payment, terminal-post-capture |
Responses can include payment.required and pending_terminal_payment; the UI can poll payment-status and drive success/cancel flows. For platform details and webhooks, see POS payments & webhooks.
UX features implemented in the SPA
Section titled “UX features implemented in the SPA”- Store context: tied to the vendor store selector (
useVendorStore); queries passstore_idwhere the API supports it. - Multi-order tabs and draft persistence via
lib/pos-sale-draft-storage.tson the vendor sale screen (local tab state save/load/clear per store). Onlgand wider, the sale top bar shows full order tabs, Draft orders, and +. Belowlg, the top bar omits the order / draft header row (draft workspace shows Back to sale only); Draft orders and a native order select (when multiple tabs) appear in the sale page search toolbar instead. Cart lines use stacked cards on small screens with a two-column label grid (equal-width label column) for Qty, Line total, Unit, and Price; the cart list area scrolls vertically within the sale column. Desktop keeps the table layout. - Non-sale POS routes (items, orders, returns, reports, settings hub): below
lg, the left title “tab” pill in the top bar is hidden so the bar is mainly store selector + actions; the bottom nav indicates the active section. Barcode labels (/point-of-sale/barcode) and work shifts (/point-of-sale/work-shift) are opened from POS → Settings (hub cards under “Barcode labels & work shifts”), not as primary-rail tabs; the Settings nav item stays highlighted on those URLs. POS → Settings → hardware subpages still show the back control on small screens. - POS work shifts (
/point-of-sale/work-shift): same work-shift list, filters, open shift, and close shift as Dashboard → Orders → Work shifts (WorkShiftsListViewwithlayout="pos"). Clicking a row or the view action opens an in-place summary modal (no redirect). The full shift detail screen remains at/dashboard/orders/shifts/:shiftIdfor dashboard workflows. - POS workspace (
/point-of-sale/sale): server-side draft orders use the same sale route with?posTab=drafts(opened from Draft orders on desktop tabs or from the mobile sale toolbar under the search field, not a second tab row). The embedded list callsGET /vendor/orderswithtab=draft, which includesplace_submit_as=draftand excludes orders already in a cancelled / failedorder_status(so a cancelled server draft no longer appears in that panel). Resuming a server draft uses?resumeOrder=<orderId>(then stripped after load): opens a new sale tab if no tab already holds thatserverOrderId, otherwise focuses and refreshes that tab. Tabs bound to a server draft show the order id in the tab strip; other tabs keep the “Order n” label. Pay / finalize must send reusableorder_id(CHAR(13) publicorders.idor legacy numeric) so the same draft row is updated — not a duplicate paid order. The legacy path/point-of-sale/draft-ordersredirects to the same sale URL withposTab=drafts. After a successful sale with multiple order tabs open, the completed tab is closed so the next + tab can reuse the lowest free “Order n” number. While online, Save draft (and F8) saves straight to the server without a name dialog; offline, the name dialog still applies for device-local named drafts. On the embedded Draft orders table, Resume order loads the draft; Delete (icon-only) callsPOST /vendor/orders/draft/deleteto soft-delete the server draft (indochina_deleted_at) so it disappears from lists without flippingorder_statusto cancelled. - POS catalog browse (
/point-of-sale/items): table of active products for the selected store viaGET /vendor/products(status=active), with columns name, category, unit, stock, and price; URL syncspage,limit,search, andsort. Row click opens a quick-edit modal (name, SKU, barcode, retail/wholesale price, stock per variant) seeded from the list row and saved withPATCH /vendor/products/:id(store_idrequired); requires product write on POS routes. - Barcode: wedge keyboard hooks —
useKeyboardBarcodeScanner(focus outside inputs) anduseKeyboardWedgeBarcodeInput(focused search field; timing heuristic, not a hardware API), cameraPosBarcodeScannerModal, andfindProductByScanCodeagainst loaded items / variant SKUs. - Variations: when a line needs a SKU/variation,
PosVariationModaland helpers inlib/pos-variations.ts(including stock-aware quantity caps). - Scan / stock:
sale_allow_negative_stockon store configuration (Settings → Sales) allows checkout when on-hand is below the requested qty; otherwisestockAvailForPosScanblocks add-to-cart. Pack/case rows withunits_per_packandbase_variantcontribute to the linked catalog SKU pool (e.g. 5 cases × 24 → 120 sellable eaches on Default); logic lives in@indochina/shared/lib/pos-sellable-stock.util(backend mirror:pos-sellable-stock.util.ts). - Coupons:
PosCouponModal+applyPosCoupon. - Customer attach:
VendorAddCustomerModal+ customer list from/pos/customers. - Custom item (outside catalog): cashiers can add an ad-hoc line with name + unit price + qty (no catalog product required). Backend accepts
cart[].is_custom_item=truewithcustom_nameandunit_price; these lines do not decrement catalog stock and are saved onorder_detailswithitem_id = nullplus snapshot initem_details. - Receipt: shared
runVendorPosOrderReceiptFinalize(see above), thenPosOrderReceiptDialog/ PDF when the cashier needs a preview or silent print is off (store logo viaresolveVendorStoreLogoUrl).
Related
Section titled “Related”- Cashier help center — user-facing Vendor Desktop POS guide (screenshots, FAQ).
- Sales, POS & receivables (procedures) — POS + debt + returns playbook.
- Orders (Vendor) — dashboard list/detail and
/dashboard/orders/create(same cart UX patterns; different submit endpoint). - Vendor panel overview — RBAC matrix.