Skip to main content

Storefront POS

Storefront POS is the Terminal-only cashier flow for walk-in sales, cart management, payment, printing, cash drawer, and optional customer display/second screen.

Change the current warehouse​

Tap the branch/warehouse chip at the upper left and confirm ต้องการเลือกคลังใหม่ใช่หรือไม่. This opens เลือกคลัง directly for the saved branch. The branch stays fixed, and warehouse choices keep the existing user permissions, including main-warehouse access. Refresh and retry keep this warehouse-only view. Selecting a warehouse saves it under the same branch and returns to Storefront POS; closing returns without saving. Home and login settings retain the full branch/warehouse selection flow.

Sales-order payment methods​

OrderPaymentStep shows เงินสด → โอน → แบ่งชำระ → QRcode in a single horizontally scrollable row. Each button keeps a readable width; transfer is now visible and no longer resets the selection to cash.

  • แบ่งชำระ uses paymentChannel: OTHER, with separate cash/transfer inputs and the existing transfer-slip picker. Both inputs are required, allow zero and up to two decimal places, and must sum to the net payable totalDue before submitting POST /api/payment.
  • Cash/transfer amount inputs display thousands separators while typing, for example 1,000 and 1,434,900. Decimal text such as 1,000. and 1,000.50 is preserved while editing. Grouping is for display; calculations, QR amounts, and payment payloads use the original numeric values.
  • The displayed ยอดแบ่งชำระ is the net payable total after benefits and discounts, including shipping/charges and canceled invoices, calculated by the shared Mobile helper. Editing either input fills the remaining amount in the other field. An edit above the net total is rejected, preserving both previous inputs. See Sales Order: Payment Flow for the exact formula and JSON example.
  • Cash, transfer, and ordinary QR requests do not gain split fields. The ordinary QR tab follows environment gating; its check button reads the order status. The separate transfer/split-payment QR flow described below is allowed in UAT.
  • The cashier owns the form. The native Customer Display shows a read-only amount/customer/order summary and labels the method เงินโอน or แบ่งชำระ เงินสดและเงินโอน as appropriate. Split input keystrokes and slip editing remain local to the cashier screen.

SCB QR for transfer and split payments​

The สร้าง QR Code button is available in โอน for the entire positive net payable total, and in แบ่งชำระ after entering a positive transfer amount and passing the existing payment-form validation. The order must still be WAITING_PAYMENT, with no duplicate invoices. Before requesting QR generation, the cashier reloads the order and invoices; changes to the order number, status, or payment totals require a refresh and another attempt.

  • โอน creates a QR for the full totalDue after benefits and discounts, including the existing shipping/charge adjustments. แบ่งชำระ creates a QR only for the entered transfer component. Both use the same fresh-reference gateway service, preserving satang. The amount must be positive and have at most two decimal places.
  • ref1 is the API-issued order number with surrounding whitespace and hyphens removed, followed by the current HHmmss. For a normal 12–13-character order number, the reference is 18–19 characters. Branch prefixes may contain letters; the earlier numeric-only validation was removed.
  • ref2 is always OTHER. Reusing the same reference within the same second is rejected in the running app; wait until the next second before generating again.
  • These transfer/split-payment flows can generate QR in UAT. The ordinary QRcode payment method continues to follow its existing environment gate. The transfer flows use their own references and do not reuse the ordinary order QR cache.
  • The native Android Customer Display shows the generated QR, amount, and reference for either method. โอน shows the full payable amount with the heading โอนเงินด้วย QR Code; แบ่งชำระ also shows the separate cash/transfer amounts. The cashier owns amount editing; the second screen displays the summary.

Example: for order 101-261002-001, a transfer of 75.25 baht, and generation time 12:34:56, the SCB QR request payload is:

{
"amount": 75.25,
"ref1": "101261002001123456",
"ref2": "OTHER"
}

The QR service obtains an SCB access token through /getTokenScb/{branchCode}, then signs and sends the payload to /getQrPayment/{branchCode}. These are payment-gateway routes, separate from the POS payment API.

Scanned slip verification for transfer QR​

Scan the transfer slip on the cashier screen. The value read from the scanner is submitted to POST /api/payment/verify-slip as { "qrCode": "<scanned value>" }. For product-payment QR in either โอน or แบ่งชำระ, the response must satisfy all of these checks:

Response fieldRequired value
validtrue
alreadyUsedfalse
status.code1000
slip.amountMatches the latest generated QR transfer amount, compared in satang
slip.ref1Exactly matches the latest generated QR ref1
slip.ref2Exactly matches OTHER
slip.transRefNonempty transaction reference

info.amount is descriptive metadata; validation uses slip.amount. When every check passes, the global alert displays สลิปเงินโอนถูกต้อง, and the scanned proof permits payment confirmation subject to the normal payment guards. Merely uploading a slip image does not authorize a payment that has requested a QR in either transfer flow.

Editing payment amounts, switching methods, or generating another QR invalidates the previous proof. A verification response received after the form or QR changes is discarded, so the current QR requires verification again. Payment submission keeps the selected method: โอน sends paymentChannel: TRANSFER without split fields; แบ่งชำระ sends paymentChannel: OTHER with cash/transfer components. QR ref2 stays OTHER in both cases. Before requesting a QR, the existing manual-transfer image-upload flow remains available. Once QR creation is requested, including a failed attempt, confirmation requires verified proof for the latest QR.

Test overrides removed on 2 October 2026​

The app now starts at Home and opens the order selected by the cashier. Automatic entry into order 2392, the indefinite rating hold for that order, and the temporary QR payload { "amount": 1, "ref1": "SSK260928001190427", "ref2": "OTHER" } have been removed. QR generation and slip comparison use the current form and generated references.

The original paid SCB request/response remains as a regression fixture in apps/pos-terminal/src/services/__tests__/fixtures/scb-split-payment-2026-10-02.json; it is not used by the running app. The removal was verified with 75 tests, Terminal TypeScript checking, Kotlin compilation, and an Android rebuild/install (8126987d).

App Entry Points​

AppTarget
Mobilenot available
POS TerminalStorefrontPosStack

Workflow Overview​

  • StorefrontPosStack: POS navigation, cart, checkout, printing, and second-screen state
  • orders.ts / invoices.ts: order, invoice, payment, and retry behavior when POS creates accounting documents
  • products.ts: product list, stock, price, and step-price context
  • borrow.ts: borrow list/detail, status processing, conversion, and converted-order lookup

Permissions And Guards​

TopicBehavior
Terminal menu keystorefrontPos
deptCode menu set-2, 1, 2, 3, 4, 7, 29, 21, 8, 9, 12, 20, 22, 23, 27, 28
Branch ruleTerminal-only screen; customer-display/native Android changes live under apps/pos-terminal/android

Main Workflow​

  • This menu exists only in POS Terminal, not Mobile.
  • Second-screen/customer-display work must follow the project AGENTS instruction: verify, commit/push, then run yarn terminal:android:8082.
  • Cart totals must remain consistent with payment and printed documents.
  • Do not mix this fulfillment-independent POS flow with Storefront Picking.

Continuous Product Queue​

The product workspace uses one FIFO queue for new sales-order and borrow-item requests, including the first item before a document identity exists.

  • The selected lines appear in the cart immediately with an item-level Adding state; product selection remains available and no full-screen loading overlay is used.
  • The first queued request creates the order/borrow header and stores the returned orderId/borrowId; later queued requests use that same identity in sequence. Document numbers are display values and are never used as item-create identities.
  • A successful line changes to Added. If the detail read is temporarily behind an item-create response, the local line is retained and merged with the server detail so a rapidly added line does not disappear.
  • Order-detail item identities accept the API's items[].id field (as well as legacy item-id field names). When FIFO work is complete, the persisted detail is authoritative; only lines still saving, deleting, or failed are retained locally. This prevents completed optimistic lines from appearing a second time after refresh.
  • While the queue or the final order-total recheck is active, customer review/confirmation is disabled. After the final sales-order item finishes, the header amount and weight are recalculated from persisted items and repaired only when stale. Borrow flows do not use the sales-order total recheck.
  • Deleting a queued line cancels it before it starts; deleting an in-flight line waits for its returned item identity and then sends the normal item DELETE request.

Plus Sales: Cross-Sell Recommendations​

Plus Sales in StorefrontPosStack is driven by crossSell from the existing POST /api/orders/upsell request. The same response continues to supply stepNudge for quantity/step-price hints on cart lines. The former five-product visual preview is no longer used.

When it appears​

  1. The upsell flow runs when opening a persisted sales order with items and after successful item additions, edits (including quantity changes), or persisted-item deletions. It requires an order identity (orderNo, orderId, or orderStorageId) and a nonempty item list. Each queued addition reads the persisted order before requesting recommendations; the FIFO queue can save the next item while the upsell response is pending.
  2. Match each crossSell[].productCode to the branch's Firebase/Firestore inventory itemCode. Names, prices, stock, and the original product object come from Firebase, not from the upsell response. Main catalog search and Product Hub category filters do not narrow the recommendations.
  3. Normalize codes by trimming whitespace and ignoring case. Keep API order, remove duplicate/invalid codes, and skip products absent from Firebase. Like the main catalog, only products with a positive price and positive available stock (onHand - booking) are displayed.
  4. Exclude codes present in either productCode or itemCode on persisted order items, and exclude current cart items, including optimistic additions waiting to save. This removes only the recommendation from Plus Sales; it never removes a product from the order/cart.
  5. Hide the entire panel on both screens when there are no eligible results. It is also hidden before a response arrives, while refreshing recommendations, on request failure, in an empty cart, and in borrow mode. There is no fallback product list or five-card limit.

Older requests cannot overwrite a newer response. Changing the opened order/document mode clears the previous suggestions; the refresh guard covers the order-detail fetch as well as the upsell request.

Cashier and customer interaction​

SurfaceBehavior
Main screen: React Native cashier UICompact cards with name and price, without images or arrow buttons. Swipe horizontally to browse. Tap an individual card to open the same add-product modal as the product list on the left; tapping alone does not save an item.
Second screen: native Android Customer DisplayThe same filtered recommendations and prices, with horizontal scrolling only. Cards cannot add or edit products or open a modal.

The cashier continues to own product editing. Plus Sales uses the existing product-selection handler, so permissions, duplicate-item handling, booked-price restrictions, stock, weight, step price, payloads, and API error handling follow the normal add-item flow. Cancelling the modal leaves the order unchanged; submitting uses the existing save queue.

API shape used by the client​

The existing request sends branchId and items[] with productCode, qty, uomCode, and whGrpCode. The following response is illustrative; RECOMMENDED-001 must exist in Firebase to produce a card. Additional recommendation fields are not used for display or price calculation.

{
"crossSell": [{ "productCode": "RECOMMENDED-001" }],
"stepNudge": [
{
"productCode": "i2-dy-10-gkh",
"current": 9,
"nextFrom": 10,
"nextPrice": 660,
"needMore": 1
}
]
}

crossSell: [] hides Plus Sales while a valid stepNudge can still appear on its matching cart line. A recommendation card is not itself a step-price hint.

Regression checks​

  • No upsell response, empty/invalid crossSell, unmatched Firebase codes, or all products excluded: no Plus Sales header or empty panel on either screen.
  • Mixed-case/whitespace codes, repeated codes, and more than five recommendations: match correctly, deduplicate, and preserve API order without a preview cap.
  • Products already in the order by either code field, including a just-added pending cart line: absent from Plus Sales; original order lines remain intact.
  • Swipe over the name, price, and card whitespace in both directions without opening a modal. Tap a cashier card to open the normal editor and cancel it without saving. Customer cards remain read-only.
  • Preserve the cashier card's pointerEvents="box-only" touch target and the Gesture Handler ScrollView. Disabling touch on the whole card previously prevented reliable scrolling; a normal React Native Pressable provides the existing tap behavior.
  • Verify that refresh failures and late responses do not restore stale recommendations, and that stepNudge still renders independently.

On the customer-entry panel of the second screen, the phone lookup control stays as a search icon until exactly 10 digits have been entered. At 10 digits it changes to Confirm with a light-green background. Removing a digit immediately restores the normal search-icon styling.

Member registration requires customer consent to the mock PDPA notice before any registration form opens.

  • If staff starts Register member from the cashier screen, the cashier screen shows a waiting state while the second screen displays the PDPA notice. Accept opens the existing staff-owned registration form; Decline returns both screens to customer entry.
  • If the customer starts Register member from the second screen, that screen shows the same PDPA notice first. Accept continues to the existing customer-owned registration form; Decline cancels the flow.
  • The current PDPA text is a placeholder. It explicitly allows for a future customer-signature step.
  • The second screen is only the consent surface while staff owns the form. It never mirrors staff keystrokes or edits the form concurrently.

Customer Display: Order Review, Advertising, And Rating​

During normal order entry, the customer panel no longer shows the benefits section. Its lower, edge-to-edge advertising area is measured from the remaining panel height; on a 1920x1080 display the prepared image size is approximately 538x812 px. The customer name is limited to one line and truncates with an ellipsis.

  • When the cashier explicitly sends the settled order for customer review, the advertisement is replaced by the customer-confirmation button. After the customer confirms, the advertisement returns immediately.
  • When the current order status is CONFIRMED, the second screen displays five labeled emoticons: very good (5), good (4), fair (3), bad (2), and very bad (1) from left to right. The numbers document the API mapping; they are not displayed on the buttons. The screen has no star prompt, home button, or confirmation modal.
  • Choosing a score posts POST /api/orders/{id}/rating using the current order identity and the authenticated cashier session. remark is always an empty string. For a score of 5, the payload is:
{
"rating": 5,
"remark": ""
}
  • Buttons are disabled while saving, and duplicate requests are suppressed. The thank-you screen appears only after API success; an API failure displays its error and allows another attempt. Events for another order and late responses after leaving the screen are ignored.
  • The rating screen normally waits up to 60 seconds before returning to customer entry. Selecting a score cancels that countdown while the request runs. Successful submission shows thanks for 5 seconds before returning. No fixed order or indefinite test hold remains.
  • Rating submission was added in 11408089; the temporary test overrides were removed in 8126987d.

Borrow List And Converted Orders​

The cashier entry screen includes Convert sample queue / today's fish customers. It opens the terminal borrow list using the same paginated getBorrows({ page, search }) API as Mobile. Each row shows document number and Thai borrow type, status with creation time, customer, requester, assignee, and the return/stock-cut user.

  • Selecting a row loads getBorrowDetail(borrowId) and opens the existing product workspace in borrow mode, including the persisted items and status actions.
  • Back from a borrow opened this way returns to the borrow list and reloads it, rather than returning to customer entry.
  • Conversion follows Mobile's main-branch SPEC, acceptance, and user-permission guards. It opens the shared item-selection modal with product name, code, quantity, visible checkboxes, and เลือกทั้งหมด. Pressing ยืนยัน posts only the selected borrowItemIds to /api/borrow/{id}/convert.
  • PARTIAL_CONVERTED displays แปลงและรอคืน. Detail rows with empty/missing lots have a light red background because those items have already been converted; rows with lots retain their normal background.
  • Confirming return for PARTIAL_CONVERTED posts all remaining item IDs (items with nonempty lots) to /api/borrow/{id}/return-items, even when hasFailedSubBorrow is true. Lots are already attached to the items; there is no lot picker. Other eligible documents keep the existing /approve or /retry routing.
  • After a successful convertBorrow, the refreshed borrow supplies convertedOrderId. Go to sales order loads getOrderDetail(convertedOrderId) and opens the same workspace in order mode.
  • The converted-order handoff uses the persisted order for items, pickup/shipping, addresses, route, coordinates, and document flags; it then loads the customer profile by customerCode for customer-specific data such as tier, points, benefits, and fallback addresses. If that lookup fails, the persisted order customer snapshot remains usable.

See Borrow Fish for the full eligibility rules, payload examples, remaining-item validation, and shared implementation map.

Cash Drawer​

After a successful payment, Terminal opens the C9D cash drawer through the installed CPOS Helper. A server/QR update that confirms the current waiting order also triggers it. Refreshing the same paid order or opening an already-confirmed order does not open it again. Drawer connection failures are reported separately from payment success. See Telpo C9D Cash Drawer for the verified helper interface and setup.

Product Hub Product Filters​

Storefront product cards, stock, quantity inputs, totals in the item editor, and recommendation prices use the inventory's sales unit instead of a fixed crate label. Quantity labels prefer salUomName, then the available unit name/code fields; missing units omit the suffix. Weight-based product prices (including old prices) are per กิโลกรัม, while quantities keep their sales unit. Step Price labels share Mobile's getStepDisplayUomCode helper, including its kilogram rule and code precedence. These labels do not change quantities, calculations, or API payloads.

After a cashier selects a customer and opens Create sales order, the Terminal product picker requests the Product Hub category tree first with company=BFPDB, then with company=NFFDB, both using levels=itemType,itemGroup,sizeRange. It combines both responses and uses the existing filter preparation to merge entries with the same key under the same parent at every level, sum their counts, and retain entries unique to either company. Both requests must succeed before the combined filters are displayed. Tree entries without a key or label are excluded before rendering.

  • The first filter section contains All followed by Product Hub item types. Selecting an item type narrows the second section to that type's item groups.
  • The second section lists item groups. Selecting the same group again clears that selection. Scrolling either filter section does not select a chip.
  • Product Hub requests use GET /pdhub/products with company=BFPDB, page=1, and pageSize=200. itemType and itemGroup are sent only when selected; selecting both sends both keys.
  • The API response is an eligibility list, not the card data source. The picker matches its itemCode values against the branch's Firestore inventory and displays only the intersection, preserving Product Hub ordering while using Firestore for the visible product details, price, and stock.
  • When filters change quickly, only the most recent Product Hub response may update the visible catalog.

Promotion Catalog and End Dates​

The Terminal StorefrontPosStack product workspace loads current promotions for the selected warehouse automatically on entry and whenever the warehouse changes. Normal product cards show a gift icon before the name and promotion end-date details for matching products. Tap โปร to show only promotion matches.

  1. Call GET /pdhub/promotion-master/active/current?branchCode=NF-ACR using PRODUCT_HUB_SERVER for the current BASE_API_ENV: UAT uses https://apiproduct-uat.boonsiri.co.th, and PRD uses https://apiproduct.boonsiri.co.th. The query value is the selected branch.whsGrpCode (warehouse code), even when BranchCode differs. A missing warehouse blocks the request.
  2. Read the response envelope { data: [...], error: null, status: true, time: ... }. Match each data[].itemCode against the current warehouse's Firestore inventory using trimmed, case-insensitive codes. Ignore blank, duplicate, and unmatched codes; the first occurrence of a repeated code is used.
  3. Display Firestore names, prices, stock, and product data. Only items with price > 0 and available stock onHand - booking > 0 appear. promotionPrice and basePrice from the promotion response do not overwrite the inventory price. Existing search and price sorting apply to the matched cards.
  4. Show a gift icon before the name of every matched promotion product, including those without an end date. Append the promotion's valid endDate after the displayed price/unit, for example ฿210/ลัง (ถึงวันที่ 30/09/2026). Format the API's calendar-date portion as DD/MM/YYYY (Gregorian year). 2026-09-30T23:59:59.000Z stays 30/09/2026; no timezone conversion shifts it to October 1. Missing or invalid dates omit the suffix. Both normal and promotion catalogs show the same gift icon and end-date details.

Opening โปร clears category/type/group/size selections and retains the current search. It filters using the promotions already loaded, without repeating a successful request. สินค้า or ล้างตัวกรอง returns to the normal catalog and clears search/filter selections while retaining promotion details on matching cards. Selecting a category filter also leaves the promotion view. An empty promotion result shows no products in โปร, while the normal catalog remains available. Loading and API errors appear in the catalog; tapping โปร after a failed load retries the request. Promotion loading is independent of normal-product requests, so a late result can decorate the normal catalog without switching tabs, and an old warehouse response cannot overwrite the current warehouse's promotions. Firestore stock and price updates retain promotion details.

Tapping a matching product opens the existing add-product flow using the Firestore product. Sales permissions, Step Price, payloads, and stock validation continue through that flow.

Implementation and verification​

ConcernSource
Request, branchCode, response/error handlingapps/pos-terminal/src/services/productHub.ts
Matching, tab state, date formatting, card textapps/pos-terminal/src/screens/StorefrontPosStack/ProductSelectionStep/index.tsx
Card wrapping and date typographyapps/pos-terminal/src/screens/StorefrontPosStack/ProductSelectionStep/styles.ts
API contract testsapps/pos-terminal/src/services/__tests__/productHubPromotions.test.ts
Catalog, end-date and stale-response testsapps/pos-terminal/src/screens/StorefrontPosStack/__tests__/promotions.test.tsx

Run from the repository root:

yarn terminal:typecheck
TZ=Asia/Bangkok yarn workspace @bsr/pos-terminal jest --runInBand --watchman=false src/screens/StorefrontPosStack/__tests__/promotions.test.tsx src/services/__tests__/productHubPromotions.test.ts

The final implementation passed Terminal typecheck and all seven tests above. Physical-device visual verification remains outstanding.

Sales Order Item Weight​

Existing sales-order items use the shared total-weight editor: the แก้ไขน้ำหนัก button checks item-edit permission and fillWeight, opens the weight modal, and requires a global confirmation before PATCH /api/orders/{orderId}/items/{id}/weight with only { "weight": 50.5 }.

  • This action is for persisted sales-order items, not the borrow-document flow or a product being added for the first time.
  • saveOrderItemWeight blocks submission while item saves or the final queued-order recheck are running, showing กรุณารอให้บันทึกรายการสินค้าเสร็จก่อน.
  • Before PATCH, invalidate the previous customer confirmation (customerConfirmationRequested and customerConfirmed). The cashier owns the weight form; the native Customer Display does not edit its intermediate value.
  • After PATCH, reload the order and map its persisted items back into the cart. Preserve local rows whose persistence is still pending or failed. Prices and totals come from the server; this weight action does not run the normal item/header recalculation path.
  • Successful save/reload closes the editor. The customer must review and confirm again through the existing explicit confirmation flow before continuing.

Mobile vs POS Terminal​

TopicMobilePOS Terminal
Screen shapenot availableStorefrontPosStack
List behaviordedicated stack/list screensplit-pane list card in HomeScreen
Detail behaviorfull-screen detailright-side detail panel

Business-rule parity​

StorefrontPosStack has a terminal-specific cashier UI, not a separate sales domain. Its business behavior must stay aligned with Mobile sales orders and the Terminal order-detail form.

  • UI/UX may differ for the larger screen, keyboard, split panes, and customer display.
  • Item selection, step price, weight calculation, duplicate-item handling, booked-price restrictions (currentStepPrice), stock checks, permissions, payload fields, and API error messages must remain equivalent.
  • Order-level bookingPriceId is not an item lock; follow Mobile and use it only for booking-source fallback data.
  • Reuse stable shared helpers such as src/utils/orderPayload.ts instead of maintaining separate payload rules.

Developer Handoff Map​

AreaCode
POS stackapps/pos-terminal/src/screens/StorefrontPosStack
Native Android displayapps/pos-terminal/android
Shared order/payment servicesapps/pos-terminal/src/services/orders.ts
Plus Sales cards and tap/swipe behaviorapps/pos-terminal/src/screens/StorefrontPosStack/ProductSelectionStep/PlusSalesPanel.tsx
Cross-sell matching and exclusionpackages/utils/src/orderUpsell.ts
Cross-sell regression testsapps/pos-terminal/src/screens/StorefrontPosStack/__tests__/crossSellProducts.test.ts
Sales-order payment UIapps/pos-terminal/src/screens/StorefrontPosStack/OrderPaymentStep/index.tsx
Split-payment amount and validationpackages/utils/src/orderSplitPayment.ts, packages/utils/src/orderSalesFlow.ts
Storefront borrow entry/listapps/pos-terminal/src/screens/StorefrontPosStack/index.tsx
Product workspace / borrow and converted-order loadingapps/pos-terminal/src/screens/StorefrontPosStack/ProductSelectionStep/index.tsx
Storefront fulfillmentHomeScreen/StorefrontDetailForm.tsx