Skip to main content

BF-004 — POS Order → Folio Journey (LAUNCH BLOCKER)

Business flow (cross-EPIC user journey). Authored 2026-05-25 as Wave 2 of the RTM Scale-Out, Property #1 launch-blocker alongside BF-003 (check-in) and BF-005 (check-out + e-invoice).

Flow-7 (flow-7-pos-to-folio.spec.ts) was 0/10 PASS on the 2026-05-16 demo-readiness snapshot. Root cause is a stack of cross-service handoff defects between hmp-pos-service, hmp-frontdesk-service (folio), and hmp-reservation-service (room-occupied check). This BF narrates the intended journey so the SME can see exactly where each defect lives, and tags every Property #1 launch-blocking drift as P0.

Each step names (a) the user story that owns the requirement, (b) the primary code path that runs it, (c) the closest automated test that exercises it, and (d) pre/post-conditions and notes. Where the spec contains no matching catalog story, the step is tagged [SME-PROPOSED].

Span: EP-007 (POS & F&B, 24 stories) ↔ EP-005B (Folios & Billing, 13 stories) ↔ small touchpoints into EP-002 (property GSTIN + state for GST split), EP-003 (reservation status for room-occupied check), and EP-005A (check-in produces the OPEN folio).

Personas​

PersonaRoleSource
Server / StewardFront-of-house POS terminal user01-epic-catalog.md EP-007 — F_AND_B role
Kitchen line cookKDS user (KitchenGateway client)01-epic-catalog.md EP-007 (KDS)
CashierPOS payment + bill close + folio handoff01-epic-catalog.md EP-007 — F_AND_B role
Front-desk agentReceives the POS room-charge on the folio01-epic-catalog.md EP-005A — FRONT_DESK role
ManagerVoids, discount overrides, shift close01-epic-catalog.md EP-007 — MANAGER role
GuestCharge-to-Bed / Room Service consumern/a — implicit

Entry points​

EntryURLBacked by
POS terminal (staff dashboard)https://dashboard.<env>.keynova.ai/poshmp-web-dashboard/src/app/(dashboard)/pos/page.tsx
POS orders page (in-progress orders)https://dashboard.<env>.keynova.ai/pos/ordershmp-web-dashboard/src/app/(dashboard)/pos/orders/
Kitchen Display System (KDS)Electron app (hmp-kds-kiosk/packages/kds/)wss://<host>/ws/kitchen/socket.io/ → KitchenGateway
Room-service in-room QR / phonen/a (operator-pushed)POST /api/orders orderType=ROOM_SERVICE
Canteen kiosk (Vishramgruh product profile)Mobile kioskPOST /api/orders orderType=CANTEEN
Folio page (front-desk receives charge)https://dashboard.<env>.keynova.ai/front-desk/folio/[id]hmp-web-dashboard/src/app/(dashboard)/front-desk/folio/[id]/page.tsx

The 10-step journey​

Step 1: POS order created (DINE_IN / TAKEAWAY / ROOM_SERVICE / CANTEEN)​

  • Story: US-007-004 — Create Order (Dine-In, Room Service). (Catalog also implicitly covers TAKEAWAY in US-007-014 and CANTEEN via the Vishramgruh product profile; both flow through the same POST /orders path with orderType discriminator.)
  • Code: hmp-pos-service/src/orders/orders.controller.ts — POST /orders (@RequiresFeature('pos.basic') per the POS-1 feature-flag gate; tenant plan must be PROFESSIONAL or ENTERPRISE — see Failure F8 + the POS-1 memory note). Body: outletId, orderType: DINE_IN | TAKEAWAY | ROOM_SERVICE | CANTEEN | DELIVERY, tableId? (DINE_IN), roomId? (ROOM_SERVICE), covers?, items[], createdBy (UUID). Service: orders.service.ts::createOrder — generates order number via Date.now().toString(36) + 6-char random (Gotcha #4, collision-tolerant); tenantWhere scoping per Gotcha #3.
  • Test: hmp-pos-service/src/orders/orders.service.spec.ts + orders.service.p6.spec.ts — create + add-items critical paths. DTO coercion: orders/dto/order.dto.spec.ts 18 cases (POS-unitprice-coercion Gotcha #18 — @Type(() => Number) required because Prisma Decimal round-trips as a string). Cross-tenant: __tests__/cross-tenant-denial.spec.ts 9 cases (the canonical platform template). Flow: pos-kot.flow.spec.ts S1 (boots), S2 (create) — TBD pass count; flow-7-pos-to-folio.spec.ts — 0/10 at 2026-05-16 snapshot. Real-backend smoke: smoke-pos-order.real.spec.ts.
  • Pre-conditions: Tenant on pos.basic feature (PROFESSIONAL or ENTERPRISE plan — POS-1 config gate); user has F_AND_B, MANAGER, ADMIN, or SUPER_ADMIN role; valid outletId belongs to the property on the JWT; for DINE_IN the tableId must be VACANT (state-machine guard via PATCH /tables/:id/status); for ROOM_SERVICE the roomId should belong to an OCCUPIED reservation (today this is NOT pre-checked here — the safeguard fires at room-charge time; see Step 7b + F3).
  • Post-conditions: POSOrder row with status=OPEN, opaque-base36 orderNumber, tenantId/propertyId/outletId populated, soft-FK createdBy. Order is empty (items added in Step 2 via POST /orders body OR POST /orders/:id/items).
  • Notes: [SME-PROPOSED] add kiosk-self-serve orderType for the Vishramgruh canteen self-serve flow (today shoe-horned into CANTEEN). Order numbers are opaque base36, NOT sequential despite many docs saying "sequential" — flag in spec to prevent future engineers wiring a numeric sort.

Step 2: Items added with HSN/SAC + per-item GST slabs (5/12/18/28)​

  • Story: US-007-003 — Menu Management (Categories, Items, Modifiers) ↔ US-007-004 — Create Order. The per-item HSN/SAC + gstRate columns are the contract between menu and bill.
  • Code: hmp-pos-service/src/orders/orders.controller.ts — POST /orders/:id/items. DTO: orders/dto/order.dto.ts — OrderItemDto, AddItemDto (Gotcha #18 — @Type(() => Number) on unitPrice, quantity). Menu surface: menu/dto/menu.dto.ts:29-91 — hsnCode (4-8 char string nullable) + gstRate (0..28, India slabs 5/12/18/28; AP-T07-003 DTO-→service-→DB wiring per Gotcha #11). Item snapshot: orders.service.ts::addItems — reads MenuItem.price + gstRate AT THE TIME OF ADD; today the line price is "live menu price at bill time" not "snapshot at add time" — see EP-007 D-006 (P1 happy-hour dispute risk).
  • Test: menu-hsn-gst.spec.ts 12 cases (AP-T07-003 wiring — runs green per Gotcha #11). orders.service.spec.ts covers add-items. Flow: flow-7-pos-to-folio.spec.ts Scenario 1 (/orders/:id/items POST) — 0/10.
  • Pre-conditions: Step 1 completed; order is OPEN; menu items exist with hsnCode, gstRate (or null → falls back to outlet taxRate — see EP-007 D-004 for the silent-fallback risk on sin-goods); items are isAvailable=true.
  • Post-conditions: POSOrderItem rows with unitPrice, quantity, notes?, modifiers?. POSOrder.subtotal, taxAmount recomputed by orders.service.ts:140-170 (W1.A.9 single source of truth for the bill composition: subtotal - discount + tax + serviceCharge + serviceChargeGst).
  • Notes: [SME-PROPOSED] add AC: HSN MUST match ^[0-9]{4,8}$; SAC codes (for spa-cafe, banquet) share the same column with a codeType discriminator. Items in categories LIQUOR / BEER / WINE / CIGARETTE MUST reject gstRate: null with code=GST_RATE_REQUIRED_FOR_SIN_GOODS — 28% slab is mandatory per Indian sin-goods notification (EP-007 D-004). Service-charge GST handling (W1.A.9) treats service-charge itself as a taxable supply at 5% slab — distinct from gratuity (untaxed, voluntary).

Step 3: KOT generated → kitchen (dual-emit Kafka + WebSocket)​

  • Story: US-007-005 — KOT Generation by Kitchen Station.
  • Code: hmp-pos-service/src/orders/orders.controller.ts — POST /orders/:id/kot. orders.service.ts:~623 — generateKOT MUST call KitchenGateway.emitKotCreated(outletId, kot) per AP-T07-002 (Gotcha #1 — the dual-emit invariant). Publishes canonical Kafka topic pos.kot.created AND emits Socket.IO event kot:created on the /kitchen namespace. Legacy alias pos.kot.generated also publishes for one release cycle (EP-007 D-007 P0 — schedule retirement). Items with kitchenStation ∈ {HOT_KITCHEN, COLD_KITCHEN, TANDOOR, BAR, PASTRY, PANTRY} are routed via the station tag; today items with kitchenStation=null are silently dropped (EP-007 D-007 — propose code=ITEM_MISSING_KITCHEN_STATION 400).
  • Test: kitchen/kitchen.service.spec.ts + kitchen.gateway.spec.ts (auth + scope + emit assertions). Flow: pos-kot.flow.spec.ts "generates a KOT" — TBD; flow-7-pos-to-folio.spec.ts Scenario 2 — 0/10 at snapshot; flow-7 "fires a KOT" step → POST /orders/:id/kot succeeds but the downstream room-charge blocks (see Q4-FINDING-001 in the flow file).
  • Pre-conditions: Step 2 completed; order has ≥1 item; outlet is OPEN (within shift); kitchen station(s) for the items are known.
  • Post-conditions: POSKitchenOrderTicket row(s) with status=PENDING, kotNumber (per-outlet sequence), printedAt=null. KDS clients connected to /kitchen for this outletId receive the kot:created event on the wire AND the next Kafka consumer poll picks up pos.kot.created for analytics MV ingestion.
  • Notes: JWT_PUBLIC_KEY is required in production (AP-CC-3 boot guard per EP-007 US-007-018 SME notes — KitchenGateway throws at onModuleInit() without it). The Socket.IO connection MUST forward the user's JWT via auth.token; KitchenGateway verifies on handleConnection and disconnects invalid tokens (platform CLAUDE.md WebSocket Gateway Authentication section). See Failure F1 for the WebSocket disconnect cascade.

Step 4: Kitchen acknowledges → prepares → ready​

  • Story: [SME-PROPOSED] US-007-005 covers the kitchen-side KOT state machine implicitly; explicit AC for the state machine is missing. Closest: US-007-018 (KDS Integration) for the UI surface.
  • Code: hmp-pos-service/src/kitchen/kitchen.controller.ts — GET /kitchen/orders (list active KOTs by outletId), PATCH /kitchen/orders/:kotId/status (kitchen-wide transition), PATCH /kitchen/items/:itemId/status (per-item granularity for partial ready scenarios). kitchen/kitchen.service.ts — state machine: PENDING → ACCEPTED → PREPARING → READY → COMPLETED → SERVED. On READY publishes pos.kot.ready (legacy) + pos.kot.completed (canonical) per EP-007 D-007 dual-emit story.
  • Test: kitchen/kitchen.service.spec.ts covers transition guards (cannot skip states; READY requires all items ready). Flow: flow-7-pos-to-folio.spec.ts Scenario 2 "ready KOT" — 0/10 at snapshot.
  • Pre-conditions: Step 3 completed; KOT exists; kitchen station user is authenticated on /kitchen namespace with matching outletId scope (per joinOutlet re-verification in WebSocket Gateway Authentication section).
  • Post-conditions: POSKitchenOrderTicket.status advances; per-item POSKotItem.status tracked. On terminal READY/COMPLETED, the canonical pos.kot.completed event fires → triggers (a) optional inventory-deduction worker (US-007-017 Stub — Not Started today, see EP-007 D-013), (b) analytics MV ingestion (kitchen-throughput dashboards), (c) server-notification "ready for pickup" WebSocket back to the POS terminal.
  • Notes: [SME-PROPOSED] add AC: "READY → COMPLETED transition SHALL require server acknowledgement (POST /kitchen/orders/:kotId/served) so the SLA clock for kitchen-prep-time stops at READY, not at served-to-guest."

Step 5: Server delivers (KOT served → guest)​

  • Story: [SME-PROPOSED] US-007-NEW-KOT-SERVED — "Server marks KOT items SERVED at the table." Today's code path treats READY → SERVED as a single transition (no audit row for who served when); story is silent.
  • Code: hmp-pos-service/src/kitchen/kitchen.controller.ts — PATCH /kitchen/orders/:kotId/status with body {status: 'SERVED'}. kitchen/kitchen.service.ts updates servedAt, servedByUserId.
  • Test: Indirect coverage via kitchen.service.spec.ts state-machine cases. No dedicated served-by-server.spec.ts today.
  • Pre-conditions: Step 4 reached READY; server user (F_AND_B role) scans the KOT QR or selects from the POS terminal "ready" tray.
  • Post-conditions: POSKitchenOrderTicket.status = SERVED, servedAt = now(), servedByUserId = JWT user. Analytics pos.kot.served event fires (today: NOT published — opportunity for food-and-beverage service-time KPI).
  • Notes: This step exists in the user journey but is the thinnest audit surface in the whole flow. A guest complaint "I waited 45 minutes" cannot be reconstructed today because READY-to-SERVED latency is not recorded as an event. Tag as [SME-PROPOSED] follow-up story.

Step 6: Bill generated (CGST+SGST intra-state vs IGST inter-state)​

  • Story: US-007-006 — Bill Generation & Payment (Cash, Card, UPI).
  • Code: hmp-pos-service/src/orders/orders.controller.ts — POST /orders/:id/bill. orders.service.ts:140-170 — W1.A.9 single source of truth for bill total: `subtotal - discount + tax + serviceCharge
    • serviceChargeGst. orders.service.ts:~160— Indian state-aware GST split (POS-016 Gotcha #5): readsproperty.stateviaPropertyServiceClient.getProperty; if property.state === buyer.state→ intra-state → split tax as CGST + SGST (each half the slab); else inter-state → IGST (full slab). Mock fixtures MUST includemockPropertyClient.getProperty.mockResolvedValue({ state: 'MH' })or the wrong branch silently passes. Receipt:hmp-shared-packages/packages/common/src/printing/receipt-templates.ts→generatePosReceipt(order, options)— 48-char ESC/POS with HSN/SAC, CGST/SGST/IGST line items, IRN placeholder, IST date, Indian number grouping. 47 byte-level tests inreceipt-templates.spec.ts`.
  • Test: orders.service.spec.ts covers bill composition; AP-T07-001 intra/inter-state split coverage; receipt-templates.spec.ts 47 byte cases. Flow: pos-kot.flow.spec.ts "generates a bill and processes payment" — TBD; flow-7-pos-to-folio.spec.ts Scenario 2 "bill order" — 0/10.
  • Pre-conditions: Step 3 KOT fired (today's gate: cannot bill a zero-KOT order); discounts (US-007-010) and service charge (US-007-011) applied as desired BEFORE the bill — opening the bill freezes the composition. Property has state populated (EP-002 onboarding US-002-001).
  • Post-conditions: POSOrder.status = BILLED, billedAt = now(), totals frozen (subtotal, discount, tax, serviceCharge, serviceChargeGst, total). Receipt rendered via generatePosReceipt with full GST breakdown. For Charge-to-Bed orders (ROOM_SERVICE, CANTEEN) the receipt is informational only — folio receipt at checkout is the legal invoice (via IRN — see BF-005).
  • Notes: Indian SME: a single bill can mix multiple GST slabs (food 5%, liquor 28%, service-charge 5%) — the receipt MUST show the per-slab breakdown so the guest (and the GST auditor) can verify. Mixed-slab bills with service-charge GST at a separate rate from food/liquor is the most common source of "guest disputes bill total" — render the slab-segregated table on EVERY printed bill, not just on request. D-008 (EP-007) — PCI scope: 16-digit PAN must be rejected at the cardLast4 field (^[0-9]{4}$ regex); UPI VPA must validate ^[a-zA-Z0-9.\-]{2,256}@[a-zA-Z]{2,64}$ — both gaps today.

Step 7a: Payment captured at POS (cash / card / UPI) — terminal path​

  • Story: US-007-006 — Bill Generation & Payment (Cash, Card, UPI).
  • Code: hmp-pos-service/src/orders/orders.controller.ts — POST /orders/:id/payment. DTO requires paymentMode: 'CASH' | 'CARD' | 'UPI' | 'NEFT' | 'GIFT_CARD' | 'COMP', amount, closedBy (UUID), optional cardLast4, cardApprovalCode, upiVpa, paymentRef? (external transaction ID). Split payments via POST /orders/:id/split and per-line POST /orders/:id/payment calls, each line summing to bill.total ±0.01 INR (EP-007 D-008 [SME-PROPOSED] AC). Mixed-payment overpay returns CASH change automatically.
  • Test: orders.service.spec.ts covers payment scenarios; split scenarios; over/under-pay reject. Flow: pos-kot.flow.spec.ts payment path — TBD.
  • Pre-conditions: Step 6 BILLED; payment method known; for CARD the terminal authorization is captured at the EDC machine (Pine Labs / MSwipe / Razorpay terminal — see BF-008); for UPI the QR is rendered and guest scans (UPI VPA captured back from PSP webhook); for CASH the cashier counts the float.
  • Post-conditions: POSPayment row(s) (1 per payment line on split); POSOrder.status = CLOSED, closedAt = now(), closedByUserId set. Shift POSShift.totalRevenue increments (US-007-012). Charge-to-Bed branch (7b) is mutually exclusive — an order is either settled at POS OR pushed to folio, never both.
  • Notes: Today's terminal flow does NOT post to the guest folio — POS revenue and folio revenue are two distinct ledgers. The folio receives POS revenue only via the room-charge path (Step 7b). For tax filing (GSTR-1) both ledgers are aggregated; the property-level GSTR-1 prep job (US-005-11 area, e-invoice IRN generation) sees the POS bill as a B2C-small line (URP).

Step 7b: Charge-to-Bed → folio.charge.posted (room-service / canteen)​

  • Story: US-007-007 — Room Charge Posting to Guest Folio (catalog marks Partial; EPIC body US-007-014 marks Partial — see EP-007 D-001 numbering drift). Verified status: Partial / Stub — endpoint ships and emits folio.charge.posted but NO credit-limit check, NO checked-out-room guard, NO guest-identity verification (EP-007 D-009 P0).
  • Code: hmp-pos-service/src/orders/orders.controller.ts — POST /orders/:id/room-charge. Body: roomId (required), reservationId? (for explicit binding; reservation-service is queried if omitted). orders.service.ts::postRoomCharge builds FolioChargePostedPayloadV2 per AP-T07-004 + AP-T07-006 (Gotchas #9
    • #10): full GST breakdown CGST/SGST/IGST + customerState + placeOfSupply. events/folio-charge-posted.event.ts — mapOrderTypeToTransactionCode(): ROOM_SERVICE → POS_ROOM_SERVICE, DINE_IN → POS_DINE_IN, TAKEAWAY → POS_TAKEAWAY, others → POS_OTHER. CANTEEN currently maps to POS_OTHER — [SME-PROPOSED] add POS_CANTEEN per EP-007 D-009.
  • Test: orders.service.spec.ts covers postRoomCharge with orderType-mapping fixtures (mock MUST include orderType per Gotcha #10). Flow: flow-7-pos-to-folio.spec.ts Scenario 1 "posts the order as a room charge to the guest folio" — BACKEND-BUG Q4-FINDING-001: reservation-service rejects with 403 SAFEGUARD_BLOCKED because frontdesk's local check-in does NOT propagate roomId back to reservation-service (reservationClient.updateRoomId is not implemented). This is the headline root cause of flow-7 0/10. Plus pos-folio-charge.flow.spec.ts (older soft-skip version) Scenario 5.
  • Pre-conditions: Step 6 BILLED; order is OPEN (not yet CLOSED); orderType ∈ {ROOM_SERVICE, CANTEEN, DINE_IN} (TAKEAWAY → no folio exists, would 400); reservation referenced by roomId is CHECKED_IN status in reservation-service (the safeguard the flow-7 test trips on); reservation's folio is OPEN (not SETTLED); guest credit-limit not exceeded [SME-PROPOSED].
  • Post-conditions: POSOrder.status = CLOSED, POSOrder.folioPosted = true, POSOrder.paymentMode = 'ROOM_CHARGE'. Kafka event folio.charge.posted (canonical) published with FolioChargePostedPayloadV2 (full GST breakdown). Order is NOT also settled at POS (mutually exclusive with 7a).
  • Notes: D-009 (P0) — story explicitly flags 3 gaps. Risk: retroactive room-charges accumulate on a folio after checkout, with NO recovery path. [SME-PROPOSED] add precondition call to ReservationServiceClient.assertRoomOccupiedAndWithinCreditLimit({...}) BEFORE publishing the event; reject with 409 ROOM_NOT_OCCUPIED or 409 CREDIT_LIMIT_EXCEEDED. Cashier MUST verbally confirm guest name + 2-char masked reservation number before submit (audit log confirmedByUserId, confirmationTimestamp).

Step 8: Frontdesk folio consumer receives the charge​

  • Story: US-005-03 — Folio Creation & Charges (catalog: US-005-003 Folio Management). Consumer side: implicit; no dedicated story for the consumer wiring — [SME-PROPOSED] US-005B-NEW-POS-CHARGE-CONSUMER.
  • Code: hmp-frontdesk-service/src/folios/consumers/pos-folio-charge.consumer.ts — subscribes to folio.charge.posted Redis Streams topic. Resolves folio via Folio.reservationId lookup (with tenantId match — defensive check). Calls FoliosService.postCharge with the typed payload (GST split preserved). Dedup via FolioTransaction.reference shape folio:{folioId}:pos-charge:{orderId} to survive consumer restart (matches the PaymentCapturedConsumer convention).
  • Test: pos-folio-charge.consumer.spec.ts — 29 cases incl. AP-T07-002 tenant-defense (folio.reservation.property.tenantId is the source of truth, NOT event.tenantId); AP-T07-004 GST derivation (consumer does NOT recalculate from taxAmount; reads CGST/SGST/IGST fields from the V2 payload). FU-55 dual-emit spec fu-55-folio-event-dual-emit.spec.ts 8 cases (canonical+legacy event shapes). Flow: flow-7-pos-to-folio.spec.ts Scenario 1 "folio balance increases by the order total after room charge" — 0/10 at snapshot (chains on Step 7b safeguard block).
  • Pre-conditions: Step 7b published the event; Redis Streams consumer group is active (governed by SKIP_EVENT_SUBSCRIPTIONS=false per the Analytics Event Dispatcher pattern in platform CLAUDE.md); folio still OPEN; tenant of resolved folio matches event's tenantId (defense).
  • Post-conditions: FolioTransaction row with transactionType=CHARGE, transactionCode ∈ {POS_ROOM_SERVICE, POS_DINE_IN, POS_TAKEAWAY, POS_OTHER}, category ∈ {FNB, FOOD, BEVERAGE, OTHER} (frontdesk derives), amount positive (CHARGE is positive; PAYMENT is negative), CGST/SGST/IGST amounts persisted (W2.A.1 — schema columns cgstAmount/sgstAmount/igstAmount/customerState/placeOfSupply populated end-to-end after migration 20260523120000_add_folio_transaction_gst_split). Folio.balance increments. FolioLock released.
  • Notes: Tenant-mismatch defense is the second-most-important invariant in the whole flow (after the e-invoice IRN B2B include). Without it, a misrouted Redis Stream message could post a POS charge to the wrong tenant's folio (PII leak + tax mis-filing). The 29-spec coverage on this consumer is intentional — every refactor MUST keep all 29 specs green.

Step 9: Folio reflects new charge with full GST breakdown​

  • Story: US-005-03 — Folio Creation & Charges (display side); UI surface implicit in folio page render.
  • Code: hmp-web-dashboard/src/app/(dashboard)/front-desk/folio/[id]/page.tsx — folio page rerenders on poll or WebSocket push; FolioTransaction rows show as ledger lines with CGST/SGST/IGST columns visible (W2.A.1 end-to-end). _components/EInvoiceStatusCard.tsx shows IRN status (will fire at checkout — see BF-005). _components/RefundModal.tsx available if charge needs to be voided.
  • Test: folios.service.spec.ts — postCharge cases (GST 12% / 18%, quantity multiplier, tax-exempt / RCM force-zero per P0-5/P0-6). folios-phase-b.service.spec.ts covers operations. Real-backend smoke: smoke-folio.real.spec.ts. E2E: hmp-web-dashboard/e2e/tests/front-desk-folio.spec.ts.
  • Pre-conditions: Step 8 wrote the row; folio is OPEN; staff authenticated with FRONT_DESK, MANAGER, ACCOUNTS, ADMIN, or SUPER_ADMIN role; folio page loaded for folioId.
  • Post-conditions: Ledger shows the new line with amount, taxAmount (total), CGST + SGST OR IGST sub-lines, customerState, placeOfSupply, chargeCategory=FNB. Running balance updated. Tax-exempt (SEZ / govt / diplomatic) and RCM charges show taxAmount=0 even if the source order had GST (P0-5 / P0-6 force-zero per Indian-GST rules).
  • Notes: Indian SME: corporate guests with TDS deductions (§194I / §194J / §194Q etc.) post a separate ADJUSTMENT row via POST /folios/:id/tds — NOT applicable to per-charge level, only to final invoice. See BF-005 for TDS handling at checkout. The guest-visible bill shown here is intermediate; legal invoice (with IRN) is generated at checkout (BF-005). For demo tenants (Tenant.isDemoTenant=true), NO IRN call is made — see EP-005B D-007.

Step 10: Order closed (status = CLOSED)​

  • Story: US-007-004 — Create Order (close side) ↔ US-007-007 (room-charge close) OR US-007-006 (POS-settled close).
  • Code: For Step 7a path: POSPayment write triggers orders.service.ts::closeOrder → status=CLOSED, closedAt=now(), closedByUserId set, paymentMode recorded on the order. For Step 7b path: postRoomCharge itself sets status=CLOSED, folioPosted=true, paymentMode='ROOM_CHARGE'. Either path emits pos.order.closed Kafka event (consumed by analytics MVs + reporting-feeds).
  • Test: __tests__/close-business-day.spec.ts covers close-at-shift; revenue.service.spec.ts covers revenue aggregation after close (US-007-020).
  • Pre-conditions: All items priced; bill generated (Step 6); payment captured (7a) OR room-charge posted (7b); shift is still OPEN (US-007-012 — POSShift.status='OPEN' blocks if close-of-day fired).
  • Post-conditions: POSOrder.status = CLOSED, POSOrder.closedAt set, POSOrder.paymentMode recorded (one of CASH/CARD/UPI/NEFT/GIFT_CARD/COMP/ROOM_CHARGE). Order surfaces in revenue.service.ts aggregations and Plan 2.2 reporting-feeds endpoints (/reporting-feeds/pos/kot-register, /void-register, /discount-register). Order is NO LONGER editable (further mutations return 409 code=ORDER_CLOSED); only voids (US-007-008) with manager approval can reverse charges.
  • Notes: Reopening a closed order is not supported by design (per US-007-008 design — reversals must be voids + new orders). This is the primary reason for the "void with manager authorization" surface — a cashier who mis-keyed cannot just "unbill" an order.

Failure modes worth testing​

#ScenarioExpected behaviourCoverage todayOwning story
F1KOT WebSocket disconnect mid-shiftKDS client reconnects via Socket.IO auto-retry; missed kot:created events backfilled via GET /kitchen/orders?since=... on reconnectkitchen.gateway.spec.ts reconnect cases — PARTIAL (no backfill spec)US-007-005 + US-007-018
F2Charge-to-folio fails (folio.charge.posted Redis Streams unavailable)POS order rolls back via outbox-style retry; POSOrder.status reverts to BILLED; cashier sees inline error "Charge-to-folio temporarily unavailable, settle at POS or retry"NO TEST — outbox-style retry NOT IMPLEMENTED (today: order CLOSE happens before event publish; partial-failure leaves order CLOSED but folio without charge — P0 data inconsistency)[SME-PROPOSED] US-007-NEW-OUTBOX-FOLIO-CHARGE
F3Room not OCCUPIED at room-charge time409 code=ROOM_NOT_OCCUPIED; cashier prompted to use POS-settle (7a) insteadNOT IMPLEMENTED today — safeguard returns 403 SAFEGUARD_BLOCKED via reservation-service indirection (Q4-FINDING-001 in flow-7-pos-to-folio.spec.ts); headline launch-blocker for flow-7US-007-007 (EP-007 D-009)
F4Payment failed at POS (card declined)Cashier retries with alternate method; partial CASH+CARD allowed via POST /orders/:id/split; order stays BILLED until full payment capturedorders.service.spec.ts split scenarios — PARTIAL coverageUS-007-006 + US-007-009
F5KOT generation race (two cashiers fire KOT for same order simultaneously)Second call returns 409 code=KOT_ALREADY_GENERATED; only one KOT row created (DB unique constraint on (orderId, kotNumber))kitchen.service.spec.ts race spec — TBD verifyUS-007-005
F6Void after KOT sent (kitchen has started cooking)Manager-approval flow (POST /orders/:id/items/:itemId/void-request → void-approve); inventory auto-deduction is NOT reversed (US-007-017 currently Stub so vacuous today; when US-007-017 ships, void-after-cook needs explicit handling — EP-007 [SME-PROPOSED] for US-007-008)order-void.service.spec.ts covers approval surface; post-cook deduction-reversal NOT TESTEDUS-007-008
F7Cross-tenant order fetch (server in tenant A asks for order in tenant B)404 code=TENANT_MISMATCH (NOT 403, to avoid resource-existence leak)__tests__/cross-tenant-denial.spec.ts 9 cases — PASS (canonical platform template)US-007-004
F8POS-1 feature flag disabled (tenant on TRIAL or BASIC plan)403 code=FEATURE_NOT_ENABLED, requiredFeature=pos.basic; cashier sees "POS module not enabled for this property"@RequiresFeature('pos.basic') decorator covered — but NOT in flow-7 (config-not-code per memory 2026-05-21-qa-remediation-shipped.md); a tenant on PROFESSIONAL/ENTERPRISE has the gate openUS-007-001 (POS module gating)
F9Service-charge applied without explicit guest consentCCPA-2022 violation risk; today's UX auto-applies with opt-out — borderline non-compliant per the District Consumer Disputes Redressal Commission interpretationNO TEST asserts the consent capture; EP-007 D-010 P1US-007-011 (CCPA)
F10Sin-goods (liquor) item with gstRate=null falls back to outlet 5% taxRate instead of mandatory 28%Today: silent miscalculation, 23% under-charge on every liquor lineNO TEST; EP-007 D-004 P0 [SME-PROPOSED] reject with code=GST_RATE_REQUIRED_FOR_SIN_GOODSUS-007-003
F11Demo tenant POS room-charge → folio (Tenant.isDemoTenant=true)Charge posts to folio (normal flow); only e-invoice IRN at checkout is skipped (BF-005 — EP-005B D-007 demo guard)demo-tenant guard spec — covers e-invoice only; POS-charge path unaffectedEP-005B D-007

Drift summary​

BF-004 surfaces / inherits 8 launch-blocking drifts. P0 = launch blocker; P1 = ships next sprint; P2 = backlog. Cross-references to per-EPIC RTM findings use the EP-NNN D-NNN namespace.

IDTypeSeverityDescriptionSurfaced by step
BF-004-D-001 (inherits EP-007 D-005)Test-vs-code + cross-serviceP0 (LAUNCH BLOCKER)flow-7-pos-to-folio.spec.ts 0/10 PASS at 2026-05-16. Root cause Q4-FINDING-001: frontdesk check-in does NOT propagate assigned roomId back to reservation-service (reservationClient.updateRoomId not implemented). The POS postRoomCharge safeguard then rejects with 403 SAFEGUARD_BLOCKED because reservation-service shows roomId=null. Pipeline 29940 (hmp-frontdesk-service aa6bfaf) is awaiting deploy.Step 7b + Step 8
BF-004-D-002 (inherits EP-007 D-009)Stub-shipped-as-Built + folio-integrityP0Room charge posts with NO credit-limit check, NO checked-out-room guard, NO guest-identity verification. Risk: retroactive charges on settled folios, no recovery. EPIC body flags this; catalog still inconsistent.Step 7b
BF-004-D-003 (inherits EP-007 D-007)Real-time-handoff + dual-emitP0AP-T07-002 dual-emit is the foundation of KDS first-paint AND analytics MV ingestion. The legacy pos.kot.generated + pos.kot.ready aliases are still published "for one release cycle" but no retirement ticket exists — permanent dual-topic load + double-count risk on analytics. Items with kitchenStation=null are silently dropped from KOT (should reject 400).Step 3 + Step 4
BF-004-D-004 (inherits EP-007 D-004)Story-incomplete + India-GST-criticalP0Item-level gstRate=null falls back to outlet taxRate — sin-goods (LIQUOR / WINE / BEER / CIGARETTE) at outlet 5% instead of mandatory 28% slab. Reject with code=GST_RATE_REQUIRED_FOR_SIN_GOODS. HSN format ^[0-9]{4,8}$ not enforced.Step 2 + Step 6
BF-004-D-005 (inherits EP-007 D-008)Compliance + India-payment-criticalP0Card payment path stores last-4 + approval code but no test asserts a 16-digit PAN is rejected at the field (PCI scope reduction depends on this). UPI VPA is unvalidated free text. Mixed-payment sum-mismatch not enforced.Step 6 + Step 7a
BF-004-D-006Producer-side outbox missingP0The folio.charge.posted publish happens AFTER POSOrder.status = CLOSED is committed. If Redis Streams is briefly unavailable, the order is CLOSED but no charge lands on the folio — silent revenue loss + reconciliation nightmare. Need a transactional outbox: write OrderClosed + FolioChargePostedEvent rows in same DB transaction; a separate publisher worker drains the outbox to Redis Streams. (Mirrors the payment-service payment.captured outbox pattern shipped 2026-05-19.)Step 7b → Step 8
BF-004-D-007 (inherits EP-007 D-010)CCPA-2022 + Indian regulatoryP1Service charge auto-applied with passive opt-out is borderline non-compliant. Real-world: guest complaint to NCH, refund order from District Consumer Disputes Redressal Commission. [SME-PROPOSED] opt-IN cashier checkbox, default UNCHECKED.Step 6
BF-004-D-008UX-gap + India-SMEP1The mixed-slab GST breakdown (food 5% + liquor 28% + service-charge 5%) is the most common source of guest disputes. Today's printed bill shows totals; the per-slab segregation is rendered only in some receipt-template variants. [SME-PROPOSED] mandate slab-segregated table on every printed bill + KDS-visible mock for KDS-bound staff to pre-empt disputes at delivery.Step 6 + Step 9

Cross-EPIC narrative summary​

StepSpansWhy cross-EPIC
1 → 6EP-007 (POS) + EP-002 (property GSTIN+state for GST split)Pure POS spine, but GST split at Step 6 depends on property state owned by EP-002.
3 → 4EP-007 (POS) + EP-007 (KDS, US-007-018)Cross-WebSocket-namespace handoff with JWT auth (AP-CC-3 + AP-T07-002 dual-emit).
7b → 8EP-007 (POS) → EP-005B (Folios) via Redis Streams folio.charge.postedThe launch-blocking seam. Tenant-defense at consumer + GST split preservation are the two non-negotiable invariants.
7b preconditionEP-007 (POS) → EP-003 (Reservation) for room-occupied assertionToday's broken cross-service call (Q4-FINDING-001) is the headline flow-7 failure mode.
8 → 9 → 10EP-005B (Folios) + EP-007 (POS close)Folio reflects the charge → POS closes → both ledgers reconciled at end-of-shift (US-007-012).
10 → BF-005EP-007 → EP-005B → EP-005A (checkout) → EP-005B (e-invoice IRN)Folio with POS charges becomes the legal invoice at guest checkout. The IRN generation is BF-005 territory.

Day-2 follow-up (NOT in BF-004 scope but worth flagging)​

  • Inventory auto-deduction (US-007-017 Stub) — when KOT completes, raw-material stock should atomically decrement per recipe. Today: stub. Food-cost % KPI uncalculable without this.
  • Happy-hour scheduler (US-007-016 Partial) — manual toggle is forgotten 30% of the time per F&B Manager feedback. [SME-PROPOSED] migrate to pricing-rules engine with cron tick.
  • Combo / set-menu (US-007-021 Not Started) — Indian-thali model needs parent-child item with per-component HSN/SAC for IRN line builder.
  • F&B mobile app (US-007-024 Not Started) — server-handheld order entry to reduce table-to-POS-terminal latency.
  • External-delivery integration (US-007-014 scope drift) — pos.order.received consumer ingests UrbanPiper / Swiggy / Zomato but EPIC says "out of scope". Resolve scope.
  • POS thermal printer offline queue (US-007-019 Partial) — Redis-backed PrintQueueService for offline printer recovery.
  • Recipe-cost analytics — once inventory auto-deduction ships, the 28-32% food-cost target becomes visible in US-007-020 reports.
  • Vishramgruh canteen POS_CANTEEN transactionCode — today maps to POS_OTHER; report breakouts impossible.

See also​

  • ../reconciliation/EP-007-rtm.md — POS & F&B per-EPIC RTM (24 stories, 13 drifts)
  • ../reconciliation/EP-005B-rtm.md — Folios & Billing per-EPIC RTM (13 stories, 9 drifts)
  • ../reconciliation/PLAYBOOK.md — RTM authoring playbook (this BF follows BF-001 template)
  • ./BF-001-tenant-property-onboarding.md — pilot business flow (template)
  • ../../platform-registry/2026-05-16-13-flow-demo-readiness.md — source of truth for flow-7 0/10 PASS measurement
  • hmp-infrastructure/testing/flow-tests/src/flows/flow-7-pos-to-folio.spec.ts — 4-scenario flow-test (LAUNCH BLOCKER)
  • hmp-infrastructure/testing/flow-tests/src/flows/pos-folio-charge.flow.spec.ts — older 6-scenario variant (soft-skip pattern)
  • hmp-infrastructure/testing/flow-tests/src/flows/pos-kot.flow.spec.ts — F-6 POS+KOT lifecycle (order → KOT → bill → payment)
  • hmp-pos-service/CLAUDE.md — POS service Gotchas #1-#18 (esp. #1 dual-emit, #9-#10 FolioChargePostedPayloadV2, #18 unit-price coercion)
  • hmp-frontdesk-service/CLAUDE.md — Folio service Gotchas (esp. tenant-defense at consumer, FU-55 dual-emit, payment-captured consumer)