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 betweenhmp-pos-service,hmp-frontdesk-service(folio), andhmp-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
| Persona | Role | Source |
|---|---|---|
| Server / Steward | Front-of-house POS terminal user | 01-epic-catalog.md EP-007 — F_AND_B role |
| Kitchen line cook | KDS user (KitchenGateway client) | 01-epic-catalog.md EP-007 (KDS) |
| Cashier | POS payment + bill close + folio handoff | 01-epic-catalog.md EP-007 — F_AND_B role |
| Front-desk agent | Receives the POS room-charge on the folio | 01-epic-catalog.md EP-005A — FRONT_DESK role |
| Manager | Voids, discount overrides, shift close | 01-epic-catalog.md EP-007 — MANAGER role |
| Guest | Charge-to-Bed / Room Service consumer | n/a — implicit |
Entry points
| Entry | URL | Backed by |
|---|---|---|
| POS terminal (staff dashboard) | https://dashboard.<env>.keynova.ai/pos | hmp-web-dashboard/src/app/(dashboard)/pos/page.tsx |
| POS orders page (in-progress orders) | https://dashboard.<env>.keynova.ai/pos/orders | hmp-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 / phone | n/a (operator-pushed) | POST /api/orders orderType=ROOM_SERVICE |
| Canteen kiosk (Vishramgruh product profile) | Mobile kiosk | POST /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 /orderspath withorderTypediscriminator.) - 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 viaDate.now().toString(36) + 6-char random(Gotcha #4, collision-tolerant);tenantWherescoping 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.ts18 cases (POS-unitprice-coercion Gotcha #18 —@Type(() => Number)required because PrismaDecimalround-trips as a string). Cross-tenant:__tests__/cross-tenant-denial.spec.ts9 cases (the canonical platform template). Flow:pos-kot.flow.spec.tsS1 (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.basicfeature (PROFESSIONAL or ENTERPRISE plan — POS-1 config gate); user hasF_AND_B,MANAGER,ADMIN, orSUPER_ADMINrole; validoutletIdbelongs to the property on the JWT; for DINE_IN thetableIdmust be VACANT (state-machine guard viaPATCH /tables/:id/status); for ROOM_SERVICE theroomIdshould 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:
POSOrderrow withstatus=OPEN, opaque-base36orderNumber,tenantId/propertyId/outletIdpopulated, soft-FKcreatedBy. Order is empty (items added in Step 2 viaPOST /ordersbody ORPOST /orders/:id/items). - Notes:
[SME-PROPOSED]addkiosk-self-serveorderType for the Vishramgruh canteen self-serve flow (today shoe-horned intoCANTEEN). 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)onunitPrice,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— readsMenuItem.price+gstRateAT 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.ts12 cases (AP-T07-003 wiring — runs green per Gotcha #11).orders.service.spec.tscovers add-items. Flow:flow-7-pos-to-folio.spec.tsScenario 1 (/orders/:id/itemsPOST) — 0/10. - Pre-conditions: Step 1 completed; order is OPEN; menu items exist with
hsnCode,gstRate(or null → falls back to outlettaxRate— see EP-007 D-004 for the silent-fallback risk on sin-goods); items areisAvailable=true. - Post-conditions:
POSOrderItemrows withunitPrice,quantity,notes?,modifiers?.POSOrder.subtotal,taxAmountrecomputed byorders.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 acodeTypediscriminator. Items in categoriesLIQUOR / BEER / WINE / CIGARETTEMUST rejectgstRate: nullwithcode=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—generateKOTMUST callKitchenGateway.emitKotCreated(outletId, kot)per AP-T07-002 (Gotcha #1 — the dual-emit invariant). Publishes canonical Kafka topicpos.kot.createdAND emits Socket.IO eventkot:createdon the/kitchennamespace. Legacy aliaspos.kot.generatedalso publishes for one release cycle (EP-007 D-007 P0 — schedule retirement). Items withkitchenStation∈{HOT_KITCHEN, COLD_KITCHEN, TANDOOR, BAR, PASTRY, PANTRY}are routed via the station tag; today items withkitchenStation=nullare silently dropped (EP-007 D-007 — proposecode=ITEM_MISSING_KITCHEN_STATION400). - 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.tsScenario 2 — 0/10 at snapshot;flow-7"fires a KOT" step →POST /orders/:id/kotsucceeds 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:
POSKitchenOrderTicketrow(s) withstatus=PENDING,kotNumber(per-outlet sequence),printedAt=null. KDS clients connected to/kitchenfor thisoutletIdreceive thekot:createdevent on the wire AND the next Kafka consumer poll picks uppos.kot.createdfor analytics MV ingestion. - Notes:
JWT_PUBLIC_KEYis required in production (AP-CC-3 boot guard per EP-007 US-007-018 SME notes — KitchenGateway throws atonModuleInit()without it). The Socket.IO connection MUST forward the user's JWT viaauth.token;KitchenGatewayverifies onhandleConnectionand 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 byoutletId),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 publishespos.kot.ready(legacy) +pos.kot.completed(canonical) per EP-007 D-007 dual-emit story. - Test:
kitchen/kitchen.service.spec.tscovers transition guards (cannot skip states; READY requires all items ready). Flow:flow-7-pos-to-folio.spec.tsScenario 2 "ready KOT" — 0/10 at snapshot. - Pre-conditions: Step 3 completed; KOT exists; kitchen station user
is authenticated on
/kitchennamespace with matchingoutletIdscope (perjoinOutletre-verification in WebSocket Gateway Authentication section). - Post-conditions:
POSKitchenOrderTicket.statusadvances; per-itemPOSKotItem.statustracked. On terminal READY/COMPLETED, the canonicalpos.kot.completedevent 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/statuswith body{status: 'SERVED'}.kitchen/kitchen.service.tsupdatesservedAt,servedByUserId. - Test: Indirect coverage via
kitchen.service.spec.tsstate-machine cases. No dedicatedserved-by-server.spec.tstoday. - 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. Analyticspos.kot.servedevent 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; ifproperty.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`.
- serviceChargeGst
- Test:
orders.service.spec.tscovers bill composition; AP-T07-001 intra/inter-state split coverage;receipt-templates.spec.ts47 byte cases. Flow:pos-kot.flow.spec.ts"generates a bill and processes payment" — TBD;flow-7-pos-to-folio.spec.tsScenario 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
statepopulated (EP-002 onboarding US-002-001). - Post-conditions:
POSOrder.status = BILLED,billedAt = now(), totals frozen (subtotal,discount,tax,serviceCharge,serviceChargeGst,total). Receipt rendered viageneratePosReceiptwith 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
cardLast4field (^[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 requirespaymentMode: 'CASH' | 'CARD' | 'UPI' | 'NEFT' | 'GIFT_CARD' | 'COMP',amount,closedBy(UUID), optionalcardLast4,cardApprovalCode,upiVpa,paymentRef?(external transaction ID). Split payments viaPOST /orders/:id/splitand per-linePOST /orders/:id/paymentcalls, each line summing tobill.total±0.01 INR (EP-007 D-008 [SME-PROPOSED] AC). Mixed-payment overpay returns CASH change automatically. - Test:
orders.service.spec.tscovers payment scenarios; split scenarios; over/under-pay reject. Flow:pos-kot.flow.spec.tspayment 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:
POSPaymentrow(s) (1 per payment line on split);POSOrder.status = CLOSED,closedAt = now(),closedByUserIdset. ShiftPOSShift.totalRevenueincrements (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 marksPartial— see EP-007 D-001 numbering drift). Verified status: Partial / Stub — endpoint ships and emitsfolio.charge.postedbut 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::postRoomChargebuildsFolioChargePostedPayloadV2per 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.
- #10): full GST breakdown CGST/SGST/IGST +
- Test:
orders.service.spec.tscoverspostRoomChargewith orderType-mapping fixtures (mock MUST includeorderTypeper Gotcha #10). Flow:flow-7-pos-to-folio.spec.tsScenario 1 "posts the order as a room charge to the guest folio" — BACKEND-BUG Q4-FINDING-001: reservation-service rejects with 403SAFEGUARD_BLOCKEDbecause frontdesk's local check-in does NOT propagateroomIdback to reservation-service (reservationClient.updateRoomIdis not implemented). This is the headline root cause of flow-7 0/10. Pluspos-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 byroomIdisCHECKED_INstatus in reservation-service (the safeguard the flow-7 test trips on); reservation's folio isOPEN(not SETTLED); guest credit-limit not exceeded[SME-PROPOSED]. - Post-conditions:
POSOrder.status = CLOSED,POSOrder.folioPosted = true,POSOrder.paymentMode = 'ROOM_CHARGE'. Kafka eventfolio.charge.posted(canonical) published withFolioChargePostedPayloadV2(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 toReservationServiceClient.assertRoomOccupiedAndWithinCreditLimit({...})BEFORE publishing the event; reject with 409ROOM_NOT_OCCUPIEDor 409CREDIT_LIMIT_EXCEEDED. Cashier MUST verbally confirm guest name + 2-char masked reservation number before submit (audit logconfirmedByUserId,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 tofolio.charge.postedRedis Streams topic. Resolves folio viaFolio.reservationIdlookup (with tenantId match — defensive check). CallsFoliosService.postChargewith the typed payload (GST split preserved). Dedup viaFolioTransaction.referenceshapefolio:{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.tenantIdis the source of truth, NOTevent.tenantId); AP-T07-004 GST derivation (consumer does NOT recalculate fromtaxAmount; reads CGST/SGST/IGST fields from the V2 payload). FU-55 dual-emit specfu-55-folio-event-dual-emit.spec.ts8 cases (canonical+legacy event shapes). Flow:flow-7-pos-to-folio.spec.tsScenario 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=falseper the Analytics Event Dispatcher pattern in platform CLAUDE.md); folio still OPEN; tenant of resolved folio matches event's tenantId (defense). - Post-conditions:
FolioTransactionrow withtransactionType=CHARGE,transactionCode∈{POS_ROOM_SERVICE, POS_DINE_IN, POS_TAKEAWAY, POS_OTHER},category∈{FNB, FOOD, BEVERAGE, OTHER}(frontdesk derives),amountpositive (CHARGE is positive; PAYMENT is negative), CGST/SGST/IGST amounts persisted (W2.A.1 — schema columnscgstAmount/sgstAmount/igstAmount/customerState/placeOfSupplypopulated end-to-end after migration20260523120000_add_folio_transaction_gst_split).Folio.balanceincrements.FolioLockreleased. - 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;FolioTransactionrows show as ledger lines with CGST/SGST/IGST columns visible (W2.A.1 end-to-end)._components/EInvoiceStatusCard.tsxshows IRN status (will fire at checkout — see BF-005)._components/RefundModal.tsxavailable if charge needs to be voided. - Test:
folios.service.spec.ts—postChargecases (GST 12% / 18%, quantity multiplier, tax-exempt / RCM force-zero per P0-5/P0-6).folios-phase-b.service.spec.tscovers 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, orSUPER_ADMINrole; folio page loaded forfolioId. - 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 showtaxAmount=0even 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:
POSPaymentwrite triggersorders.service.ts::closeOrder→status=CLOSED,closedAt=now(),closedByUserIdset,paymentModerecorded on the order. For Step 7b path:postRoomChargeitself setsstatus=CLOSED, folioPosted=true, paymentMode='ROOM_CHARGE'. Either path emitspos.order.closedKafka event (consumed by analytics MVs + reporting-feeds). - Test:
__tests__/close-business-day.spec.tscovers close-at-shift;revenue.service.spec.tscovers 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.closedAtset,POSOrder.paymentModerecorded (one of CASH/CARD/UPI/NEFT/GIFT_CARD/COMP/ROOM_CHARGE). Order surfaces inrevenue.service.tsaggregations and Plan 2.2 reporting-feeds endpoints (/reporting-feeds/pos/kot-register,/void-register,/discount-register). Order is NO LONGER editable (further mutations return 409code=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
| # | Scenario | Expected behaviour | Coverage today | Owning story |
|---|---|---|---|---|
| F1 | KOT WebSocket disconnect mid-shift | KDS client reconnects via Socket.IO auto-retry; missed kot:created events backfilled via GET /kitchen/orders?since=... on reconnect | kitchen.gateway.spec.ts reconnect cases — PARTIAL (no backfill spec) | US-007-005 + US-007-018 |
| F2 | Charge-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 |
| F3 | Room not OCCUPIED at room-charge time | 409 code=ROOM_NOT_OCCUPIED; cashier prompted to use POS-settle (7a) instead | NOT 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-7 | US-007-007 (EP-007 D-009) |
| F4 | Payment 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 captured | orders.service.spec.ts split scenarios — PARTIAL coverage | US-007-006 + US-007-009 |
| F5 | KOT 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 verify | US-007-005 |
| F6 | Void 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 TESTED | US-007-008 |
| F7 | Cross-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 |
| F8 | POS-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 open | US-007-001 (POS module gating) |
| F9 | Service-charge applied without explicit guest consent | CCPA-2022 violation risk; today's UX auto-applies with opt-out — borderline non-compliant per the District Consumer Disputes Redressal Commission interpretation | NO TEST asserts the consent capture; EP-007 D-010 P1 | US-007-011 (CCPA) |
| F10 | Sin-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 line | NO TEST; EP-007 D-004 P0 [SME-PROPOSED] reject with code=GST_RATE_REQUIRED_FOR_SIN_GOODS | US-007-003 |
| F11 | Demo 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 unaffected | EP-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.
| ID | Type | Severity | Description | Surfaced by step |
|---|---|---|---|---|
| BF-004-D-001 (inherits EP-007 D-005) | Test-vs-code + cross-service | P0 (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-integrity | P0 | Room 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-emit | P0 | AP-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-critical | P0 | Item-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-critical | P0 | Card 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-006 | Producer-side outbox missing | P0 | The 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 regulatory | P1 | Service 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-008 | UX-gap + India-SME | P1 | The 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
| Step | Spans | Why cross-EPIC |
|---|---|---|
| 1 → 6 | EP-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 → 4 | EP-007 (POS) + EP-007 (KDS, US-007-018) | Cross-WebSocket-namespace handoff with JWT auth (AP-CC-3 + AP-T07-002 dual-emit). |
| 7b → 8 | EP-007 (POS) → EP-005B (Folios) via Redis Streams folio.charge.posted | The launch-blocking seam. Tenant-defense at consumer + GST split preservation are the two non-negotiable invariants. |
| 7b precondition | EP-007 (POS) → EP-003 (Reservation) for room-occupied assertion | Today's broken cross-service call (Q4-FINDING-001) is the headline flow-7 failure mode. |
| 8 → 9 → 10 | EP-005B (Folios) + EP-007 (POS close) | Folio reflects the charge → POS closes → both ledgers reconciled at end-of-shift (US-007-012). |
| 10 → BF-005 | EP-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.receivedconsumer 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 measurementhmp-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)