Skip to content

POS

Cashier user guide (step-by-step): Cashier help centerVendor 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.


GoalsNon-goals
Enable staff to sell with correct RBAC and store contextDocument acquirer-specific terminal firmware
Link payment polling and webhooks to technical docsReplace POS payments & webhooks detail

  • ProtectedRoute on /pos requires order module with write; /point-of-sale/* requires pos (read/write per route) via layout / canAccessVendorPath (vendor-module-access.ts). Backend accepts pos OR the legacy module on shared APIs (devices, shifts, finance reports, orders) via VENDOR_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/:deviceId with 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 in pos.api.ts under the same POS_VENDOR_CHECKOUT_PREFIX (/vendor/point-of-sale). Every request carries an explicit store_id (mandatory for this controller).
  • Optional order_id on place-order (POST /vendor/point-of-sale/orders from vendor-web, or POST /pos/orders from other clients) updates an existing draft row for the same store (replace line items / totals) instead of allocating a new id. submit_as: draft does not decrement catalog stock; finalizing (non-draft) applies inventory and clears the draft hold.
  • POS / manual create no longer auto-creates invoices rows by default. Invoice creation is opt-in via the place-order payload block invoice (customer billing fields).
  • Store context from useVendorStore passes store_id where supported.
  • invoice_barcode on 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).

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:

  1. Open tab in the workspace — posSaleTabMatchesResumeRef matches by Code128 barcode (case-insensitive), numeric orders.id, or the CHAR(13) public id.
  2. Local outbox (Electron only) — loadPosLocalOutboxPlaceOrderForResume matches by outboxId, payload.client_order_id, or payload.invoice_barcode (also normalized via posLocalOutboxRowMatchesOrderRef).
  3. Server fetchfetchVendorOrderIdByResumeRef resolves the resume ref to orders.id and the workspace calls fetchVendorOrder:
    • Numeric orders.id → used directly, no list query.
    • POS-generated EAN-13 (20xxxxxxxxxxxx with valid mod-10 check digit) → decoded back to orders.id via resolvePosServerOrderIdFromGeneratedInvoiceEan13.
    • CHAR(13) public idfetchVendorOrder(id) first; on 404 or wrong store, falls back to barcode search.
    • INV Code128 barcodefetchVendorOrdersList({ search, order_type: "pos" }) with exact invoice_barcode match (server enforces via isPosBarcodeLikeSearch, client picks newest exact hit). Wholesale / parcel orders share the same invoice_barcode column, so the order_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.

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/barcodeBarcode 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 uses GET /vendor/point-of-sale/barcode/validate?store_id=…&value=…&symbology=… so symbology rules stay aligned with the API. Internal weighted WEIGHT items use @indochina/shared helpers (encodeInternalWeightedEan13Lb, isInternalWeightedEan13Digits, same layout as defaultInternalWeightedEan13Rule); 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 on terminal_payment_meta.

  1. Cashier opens /pos ( order write) or /point-of-sale/sale ( pos write, or legacy order write).
  2. Ensures store is selected (store_id on API calls — POS workspace uses the same store selector pattern as the sale column).
  3. Adds lines, optional customer/coupon, submits → placePosOrderPOST /vendor/point-of-sale/orders.
  4. If card / terminal pending → UI polls payment-status until paid or failed; terminal bridge calls webhook (POS terminals & ECR Hub, CodePay ECR Hub).
  • PlaceOrderDto (used by both POST /vendor/point-of-sale/orders and POST /pos/orders) accepts force_customer_debt when a debt subject exists (user_id and/or customer_id) — see computePosOrderDebtFlags in 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 send payment_method: "debt", paid_amount: "0.00", and force_customer_debt: true (guest disables debt in the UI). /dashboard/orders/create uses the same debt semantics via its toggle.
  • Optional invoice payload (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) with user_id / customer_id can still create receivables / is_debt at the API layer; the default POS UI does not expose a full partial-pay editor.

  • 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.

IDScenarioExpected
P1Cash saleOrder completes without terminal pending
P2Card + terminal successWebhook / poll → paid state in UI
P3Employee without pos / order writeCannot 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:

  1. Clears placeOrderStockHighlightLineKey on onMutate (before each attempt).
  2. On onError, runs the screen-specific onError first (when provided), then calls applyPosPlaceOrderStockErrorHighlight (lib/pos-place-order-mutation-handlers.ts) so errors[0].code === "stock" highlights the matching cart line consistently.
SurfaceFilemutationFn
Dashboard POSapp/dashboard/pos/page.tsxDefault placePosOrderPOST /vendor/point-of-sale/orders
POS workspace (pay + server named draft)app/point-of-sale/pages/sale-page.tsxTwo hook instances, both default placePosOrder (checkout + submit_as: draft save)
Manual create orderfeatures/orders/create-order-page.tsxpostVendorPlaceOrderPOST /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 import usePosPlaceOrder and that create-order passes postVendorPlaceOrder; sale page keeps two hook call sites.
  • Playwright (util / contract): e2e/pos-place-order-surfaces.spec.ts — same source checks so npm run test:e2e fails 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:

  1. Calls fetchVendorOrderReceiptDataForPrint with the normalized order id, locale, and extras (invoice_barcode / terminal_payment_meta / trimmed transaction_ref), plus an optional cart fallback snapshot when the API has not materialized receipt fields yet.
  2. Invalidates vendorOrdersQueryKeys.detail(orderId), the ["vendor-orders"] list bucket, and posQueryKeys.all so lists and POS caches refresh.
  3. When the store’s receipt_print_mode is 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 runs attemptPrintReceiptInvoiceJobs (silent / Electron print path). When auto-print is off, the UI can still open PosOrderReceiptDialog or trigger a PDF download from the same payload.

  • /pos is wrapped with ProtectedRoute requiredModule="order" requireModuleWrite in app-router.tsx.
  • /point-of-sale/* lives under PointOfSaleAppShell with requiredModule="pos" (write required for /point-of-sale/sale and wholesale per dashboardPathRequiresModuleWrite). Settings hardware, reports, and device CRUD accept pos read/write on those paths; APIs also accept legacy settings / finance / order when the role was not migrated yet.
  • That means:
    • Owners always pass.
    • Employees need pos (or legacy alias slugs) in profile.modules with the right read / write level (see vendorHasModuleWriteForRoute / vendorHasModuleReadForRoute in vendor-module-access.ts).

The order + write requirement still applies to /pos, /dashboard/orders/create, and dashboard order lists.

  • Unauthenticated users are sent to / (login).
  • Authenticated users without the required module are redirected to /dashboard/access-denied (see ProtectedRoute).

canAccessVendorPath maps /point-of-sale/... to module pos (with legacy read/write aliases for order, product, customer, settings, finance).

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:

ConcernEndpoint / helper
Menu (grouped by category)GET /vendor/point-of-sale/items/groupedfetchPosItemsGrouped
Customers (search)GET /vendor/point-of-sale/customersfetchPosCustomers
Promotions / coupons listGET /vendor/point-of-sale/promotionsfetchPosPromotions
Apply couponPOST /vendor/point-of-sale/apply-couponapplyPosCoupon
Place order (checkout)POST /vendor/point-of-sale/ordersplacePosOrder (via usePosPlaceOrder on dashboard POS + POS workspace; mutationFn: postVendorPlaceOrder on /dashboard/orders/create)
Payment status pollGET /vendor/point-of-sale/orders/:orderId/payment-statusfetchPosOrderPaymentStatus
Terminal-style payment actionsPOST /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.

  • Store context: tied to the vendor store selector (useVendorStore); queries pass store_id where the API supports it.
  • Multi-order tabs and draft persistence via lib/pos-sale-draft-storage.ts on the vendor sale screen (local tab state save/load/clear per store). On lg and wider, the sale top bar shows full order tabs, Draft orders, and +. Below lg, 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 (WorkShiftsListView with layout="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/:shiftId for 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 calls GET /vendor/orders with tab=draft, which includes place_submit_as=draft and excludes orders already in a cancelled / failed order_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 that serverOrderId, 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 reusable order_id (CHAR(13) public orders.id or legacy numeric) so the same draft row is updated — not a duplicate paid order. The legacy path /point-of-sale/draft-orders redirects to the same sale URL with posTab=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) calls POST /vendor/orders/draft/delete to soft-delete the server draft (indochina_deleted_at) so it disappears from lists without flipping order_status to cancelled.
  • POS catalog browse (/point-of-sale/items): table of active products for the selected store via GET /vendor/products (status=active), with columns name, category, unit, stock, and price; URL syncs page, limit, search, and sort. Row click opens a quick-edit modal (name, SKU, barcode, retail/wholesale price, stock per variant) seeded from the list row and saved with PATCH /vendor/products/:id (store_id required); requires product write on POS routes.
  • Barcode: wedge keyboard hooks — useKeyboardBarcodeScanner (focus outside inputs) and useKeyboardWedgeBarcodeInput (focused search field; timing heuristic, not a hardware API), camera PosBarcodeScannerModal, and findProductByScanCode against loaded items / variant SKUs.
  • Variations: when a line needs a SKU/variation, PosVariationModal and helpers in lib/pos-variations.ts (including stock-aware quantity caps).
  • Scan / stock: sale_allow_negative_stock on store configuration (Settings → Sales) allows checkout when on-hand is below the requested qty; otherwise stockAvailForPosScan blocks add-to-cart. Pack/case rows with units_per_pack and base_variant contribute 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=true with custom_name and unit_price; these lines do not decrement catalog stock and are saved on order_details with item_id = null plus snapshot in item_details.
  • Receipt: shared runVendorPosOrderReceiptFinalize (see above), then PosOrderReceiptDialog / PDF when the cashier needs a preview or silent print is off (store logo via resolveVendorStoreLogoUrl).