Skip to main content

Credit-balance checkout — four findings (2026-08-07)

Status: filed, NOT fixed. No production code changed. Found by: KeyNova Sentinel Phase 1, night-audit de-vacuuming (item 1). Verification: every file:line below was read directly in this repo. The SME ruled on domain correctness; the BA re-derived every claim independently and found three faults the SME missed; the coordinator then re-verified the three severity-raising ones. Live figures are from the TEST canary property.


How this was found​

The night-audit certification suite could not run: POST /night-audit/:id/start refused with 400 { overstays: 14 }. overstays is a BLOCK gate at every profile including the default LEAN, so 14 leaked fixture stays had disabled the six money assertions the suite exists to make.

Clearing them required settling folios. 11 settled and departed cleanly (₹159,206.81). Three refused, and the refusals were the finding:

POST /api/check-out/3825b7ee-… → 400
"Outstanding balance of ₹-1483977.80 after late-checkout charge.
Please settle the folio before checkout."

The balance is negative — the hotel owes the guest. The instruction is impossible: a credit cannot be settled by taking more money.


Finding 1 (P0) — a credit balance blocks the guest's departure​

Two gates on the same path disagree about sign:

SitePredicateEffect on a credit
checkout.service.ts:862totalBalance > 0sign-aware — correctly lets it pass
checkout.service.ts:1263Math.abs(finalBalance) > 0.01blocks
checkout.service.ts:1859 (expressCheckOut)Math.abs(totalBalance) > 0.005blocks
night-audit.service.ts:3241totalBalance > 0.005 skipselects credits as eligible

The platform's own sign convention (src/folios/CLAUDE.md, "Balance Tracking") is balance = totalCharges - totalPayments, negative = refund owed.

The night-audit consequence is the sharpest form of it. The audit's express auto-checkout sweep selects credit-balance departures — its comment reads "must be zero or credit (≤ 0)" — and hands each to expressCheckOut, which refuses every one. The audit catches and WARN-logs the failure, so the run reports success while the guest is never checked out, every night, indefinitely.

SME ruling: departure must never be blocked by a credit. The invariant worth keeping is "a folio may not be stamped SETTLED with a non-zero balance in either direction" — the credit routes to a disposition (refund to original tender / transfer / company AR / hold), and the disposition brings the balance to zero, and then the folio closes. Departure and folio closure are two events.

Message defects (independent of the gate). The BA corrected the SME here, and upward: checkout.service.ts:1025-1026 declares const lateFeeTotal = 0; as a literal after AB#5125 deleted the in-transaction late-fee post. So "after late-checkout charge" is not merely 0 on this path — it is unconditionally false on every path, sending the operator hunting for a charge that cannot exist. "Outstanding balance of ₹-1483977.80" is also self-contradictory: an outstanding balance is by definition positive.

Screen disagrees with the server. keynova.tsx:255 computes hasBalance = totalBalance > 0, so a credit renders in the green "clear" style with the panel text "No outstanding balance — folio is clear." (:754), enables the checkout button, and posts forceCheckout:false (:383). The operator is told "clear", then refused. Same gating in check-out-form.tsx:133-134.


Finding 2 (P0) — forceCheckout confiscates the credit as bad debt​

forceCheckout skips the gate, and the AR-routing loop then runs with a negative balance:

checkout.service.ts:1347-1362   transactionType: 'ADJUSTMENT'
transactionCode: 'WRITE_OFF'
amount: -folioCurrentBalance // = +5000 on a -5000 credit
balance: { decrement: -5000 } // -5000 → 0

The guest's money is zeroed and booked as a positive-amount bad-debt write-off. On a company folio the sibling branch does company.currentBalance: { increment: <negative> } — reducing a corporate receivable with no refund instrument and no credit note.

USALI: a guest credit is a liability (guest ledger credit / advance deposits). Bad debt is a contra against receivables — an asset. The journal has no defensible direction.

Three faults the BA found that the SME did not name:

  1. :1326 — a positive-amount PAYMENT row, inverting the convention the code's own comment states at :1318-1319 ("money into the folio is negative").
  2. :1335 — totalPayments is decremented, corrupting a column treated as monotonic inbound.
  3. :2181-2182 — POST /early-checkout passes forceCheckout: true unconditionally, and the route is @Roles(…, 'FRONT_DESK'). This is the reachable production trigger and it is not in the original brief.

Finding 3 (P0) — authority is inverted​

Both the manager role floor (:875) and the value threshold (:893) are nested inside if (totalBalance > 0 && dto.forceCheckout). A negative balance skips both.

BalanceforceCheckoutFRONT_DESK agent
+1.00true403 FORCE_CHECKOUT_REQUIRES_MANAGER_APPROVAL
-1,483,977.80trueallowed — no manager, no approval code, no threshold

The lowest-authority persona holds the highest-consequence financial action, while being correctly blocked from the less damaging one. EP-005A-test-readiness.md:122 records this gate as ✅ RESOLVED (D-005A-010) quoting the > 0 predicate verbatim — a documented-as-closed, actually-half-open case of "a defect class is not closed until you sweep the other call sites".


Finding 4 (P1) — an advance tender has no ceiling (the upstream hole)​

Classified from live data, not inferred. Folio 5777bf44-…:

balance=-1,483,977.80   totalCharges=45,172.90   totalPayments=1,529,150.70
CHARGE/ROOM n=2 16,000 CHARGE/POS_DINE_IN n=17 9,075.20
CHARGE/EARLY_CHECKIN n=1 8,000 CHARGE/FNB n=20 5,750 CHARGE/MINIBAR n=10 1,000
PAYMENT/ADVANCE n=5 -1,500,000.00 ← five advances, ₹15 lakh, on a ₹45k stay

No TRANSFER rows inflate totalPayments, so this is DATA-level over-application at tendering, not a display double-count — different owner, different fix. Seed noise excluded: no seed SQL contains such advances.

Root cause, folios.service.ts:4258-4270:

isPreArrival = !isRefund && (!isInHouse || (folioHasNoInHouseCharges && folioBalanceNonPositive))
isAdvance = !isRefund && (dto.isAdvance === true || (isPreArrival && dto.isAdvance !== false))
// → exempt from the over-tender safeguard at :4339, with NO upper bound

isPreArrival is true for any reservation not CHECKED_IN/CHECKED_OUT. The exemption is deliberate and its rationale is sound (AB#9370 — an advance is taken before its matching charge exists, so it is meant to drive the balance negative). The fix is a ceiling, not a removal — removing it re-opens the false positive it was added to close.

Two BA corrections that lower the build cost and raise the severity:

  • frontDesk.overpaymentAlertThreshold refuses (blocked:true → safeguard.service.ts:165-166 throws), it is not alert-only. Default ₹100. The refuse-with-override mechanism already exists and is production-proven.
  • The Payment uniqueness guard is ineffective. @@unique([reservationId, reference, amount]) (schema.prisma:375) sits over a nullable reference (:346), and the migration is a plain CREATE UNIQUE INDEX with no NULLS NOT DISTINCT. Postgres treats NULLs as distinct, so unlimited null-reference duplicates are accepted — exactly the shape of the five ₹300,000 rows. No provider/gateway-reference column exists on this model at all.

The ceiling must be CUMULATIVE. Each ₹300,000 tender is only 6.6× a ₹45k stay; a per-tender cap loose enough to avoid false positives on resort/banquet advances would still admit all five in sequence.


What landed​

INV-XCO-CREDIT — hmp-frontdesk-service/src/checkout/express-checkout-accepts-what-night-audit-selects.gate.spec.ts

Asserts set containment: every balance the night audit selects is accepted by expressCheckOut. RED on the current build, stranded set [-1483977.8, -5000, -100, -0.006], both controls green — the positive control proves the predicates do agree on positive balances, so the red is the sign disagreement and not a typo in the copies.

It asserts over a range rather than one example, so it catches the next divergence too. It re-implements both predicates because each is an inline if inside a long method — that duplication is the root cause. The durable fix is to export ONE predicate and have both call it; the gate should then import it and the copies be deleted, in the same commit.

NOT registered in catalogue.mjs. The standing rule is run green and non-vacuously before registering. This gate is red by design; registering it now would fail certify.mjs for a reason unrelated to the run. Register it when the fix turns it green.


Decisions taken while the operator was away​

#DecisionBasis
D1Setup-A prepares its room rather than observing ambient stateSME: the "ambient" state is itself manufactured by reset-canary-inventory-job.yaml and CanaryFixtureProvider.ensureHousekeepingClean, so failing there relocates the mutation rather than avoiding it
D2Terminal prepared state is INSPECTED, not CLEANsuperset — satisfies an inspection-mandating property and a non-mandating one, and survives inspectionRequiredBeforeSellable landing later
D3Shared fixture switched to housekeeping-axis-only writesthe composite write bypassed both state machines; no flow test had ever exercised the DIRTY → CLEAN → VACANT_CLEAN derivation
D414 overstays cleared by settle-then-checkoutoperator choice; 11 succeeded, ₹159,206.81 posted, 3 refused and became this document
D5Credit findings filed, not fixedoperator choice — production money-handling stays untouched pending review
D6INV-XCO-CREDIT left red and unregistereda regression-first gate must go red before the fix; registering a red spec would break the certification run

Sequencing (BA recommendation)​

3 → 2 → 1 → 4 → enabling stories. Finding 3 is smallest and the only one that reduces exposure standalone (it re-closes the authority hole while 2 is in flight). 2 removes the destruction. 1 unblocks the guest and consolidates the predicate. 4 closes the upstream hole.

Enabling stories — behaviour that does not exist​

Not acceptance criteria on the four above, because writing an AC for unbuilt behaviour red-gates something with no code:

  • E1 credit disposition at folio closure — no selector, service method or screen exists.
  • E2 refund to original tender from frontdesk — the payment-service endpoint exists (payment.controller.ts:109) but frontdesk has no caller; its only PaymentServiceClient use is getPayment. Required by card-scheme return-to-original-tender.
  • E3 §31(3)(e) refund voucher — absent; CreditNote is the §34 instrument for an already-issued invoice, a different case.
  • E4 transfer a credit to another folio — currently prohibited: TRANSFERABLE_TRANSACTION_TYPES = ['CHARGE','ADJUSTMENT','TAX']; a PAYMENT is not transferable. Depends on E2.
  • E5 duplicate-tender detector + a working uniqueness guard.
  • E6 _checkOut bypasses FOLIO_DOES_NOT_RECONCILE — it writes tx.folio.updateMany({status:'SETTLED'}) directly (:1372), so the F29/AB#9643 guard in FoliosService.settleFolio never runs. Pre-existing on the positive path too — not caused by these four.

Explicitly unverified​

  • Which dashboard surface (if any) calls POST /early-checkout — no caller found in front-desk.ts. The route is live and FRONT_DESK-authorised regardless.
  • hmp-mobile-apps / hmp-kds-kiosk checkout surfaces — not inspected.
  • Whether any leg of the ₹15 lakh was CASH (which would implicate the §269ST guard).
  • Whether Reservation.totalAmount is reliably populated across all booking sources — the proposed ceiling fails closed because this could not be established.
  • On the 3-folio reservation caebff9a-…, a 201 payment to folio a90a35af read back as payments=0 while a sibling folio showed the matching amount. Could not distinguish "landed on the sibling" from "did not land" — needs its own investigation.

Addendum — evidence review (item 3), round 2026-08-06-09-47

18 of 107 evidence screens are a BYTE-IDENTICAL BLANK WHITE PAGE (md5 946d8911…, 18.9 KB against a 214 KB median). Verified two ways: identical hash across unrelated specs, and by opening one — it is blank, not merely sparse.

They span five specs:

specflowstatuslayerin core-PMS scope
company-ar-journey (12 screens)CITY-LEDGER-BALANCEfailL1+L2yes
tape-chart-group-room-countTAPE-GROUP-ROOMSfailL2yes
operations-hub-room-statusUI-HOMEpassL2yes
guest-kiosk-journeyBE-KIOSKfailL4no
guest-public-orderPOS-GUESTQRpassL1+L4no

The one that matters is UI-HOME. It is an L2 UI journey that PASSES while its only evidence screen is blank. L2 exists to prove "real UI → real backend state"; a blank capture means the UI was never observed. Whatever that case proved, it did not prove what its layer claims. This is the AB#11136 class ("blank evidence was a RACE, not a dead page") recurring on a spec that was never covered by that fix — and it is worse here, because AB#11136's instance was a failing test, whereas this one is GREEN.

The three failing specs are less alarming — a blank page is consistent with a page that never rendered, which is presumably why they failed — but their evidence still cannot diagnose the failure, so they are undebuggable from artifacts alone.

Not yet established (do not assume either way): whether the blank is a CAPTURE race (screenshot taken before paint) or a genuinely DEAD page. AB#11136 was the former; that does not make this the former. The discriminator is a capture with a settled-DOM wait plus a positive-data assertion on the same frame — if the page has content the race is confirmed, if not the page is dead.

Duplicate-evidence check, for completeness. The other identical-hash groups are benign: 9× 35 KB is the shared authenticate step (legitimately the same screen), and the 245/228/161/90 KB groups are large, genuinely-rendered repeats within a spec. Blankness, not duplication, is the defect.


Addendum 2 — "Accountant 0/3 is STRUCTURAL" does not survive re-derivation

Recorded as a CLOSED standing unknown: "Accountant 0/3 is STRUCTURAL (unowned flows)", the cited cause being that FD-PROFORMA, FD-TAXINV and GST-GSTR1 each declare a workflow dimension and own no spec.

Re-derived through the shipped adapter against results/sentinel-2026-08-06-09-47.json:

Accountant: 0 GREEN / 3 total — 3 FAIL, 0 INCONCLUSIVE

Not one is INCONCLUSIVE. They are measurable, they ran, and they fail — on five specifically named flows:

journeyfailing flowdims
ac-city-ledger-arCITY-LEDGER-BALANCEapi FAIL, ui FAIL
FD-COMPANY-AR-OPENITEMapi FAIL
ac-proforma-to-tax-invoiceFD-SETTLE-IRNapi FAIL
FD-NOGST-ZEROapi FAIL
ac-gst-filing-periodFD-VOIDapi FAIL

The three flows the closure blamed are all GREEN — FD-PROFORMA workflow GREEN, FD-TAXINV workflow GREEN, GST-GSTR1 workflow GREEN. The stated cause was wrong on its own terms, independently of the unowned-flow retraction.

Why this matters more than a scoreboard correction. "Structural / unowned" means we cannot yet measure this, and it parks the work. "FAIL on five named flows" means Accountant-facing functionality is broken and we have known for some time. The first reading closes a card; the second opens five. Every Accountant journey on this platform — city-ledger AR, pro-forma → tax invoice with IRN, and GST filing-period alignment — is currently failing.

Note CITY-LEDGER-BALANCE also carries 12 blank evidence screens (Addendum 1), so it fails at both api and ui level with no usable visual evidence of either.

Not established here: the root cause behind each of the five API failures. They are five distinct flows and should be read individually rather than assumed to share a cause — that assumption is what produced the original mis-closure.


Addendum 3 — sharer identity (Phase 1 item 4): the gap is real, and it is not the gap we thought

SME ruled; every load-bearing claim below re-verified in code before acting.

My framing was wrong on three counts​

  1. The threshold clause is Rule 46(e) at ₹50,000, not 46(f). 46(b) is the serial number; 46(f) is the below-threshold-on-request clause. I had the letters crossed.
  2. A bare ordinal is never lawful — but that is not the point. Below ₹50,000 with no recipient request, a recipient name is not a required particular at all. So the lawful behaviours are exactly two: record the truth, or omit. Fabricating Share-2 under a field labelled "Billed To" on a document headed TAX INVOICE is neither. The defect is the fabricated particular, not the separate document — two sharers settling separately genuinely are two recipients of two supplies.
  3. The no-name path is the DEFAULT, not an edge case. Verified: paxGuestRefFor returns null for INDIVIDUAL, so the only production UI never sends a sharer name. Source (1) of the resolution order is dead in production; only the companions roster can populate a name.

The bigger finding: Rule 46(e) fails for ALL B2C invoices, not just sharers​

Verified at the invoice projection — the company branch emits state and stateCode; the B2C branch emits { name: folio.guestName } and nothing else. There is no ₹50,000 constant anywhere in src/folios.

So every B2C accommodation invoice at or above ₹50,000 that this platform has ever issued lacks the mandatory address / address-of-delivery / State / State-code particulars — with a real guest name or without one. That is strictly larger than the sharer placeholder and is not sharer-specific. It is the item to fix first.

What certification should assert (SME-specified, not today's behaviour)​

  • S4 (P0) — an unnamed sharer's invoice must not print a fabricated recipient name. Assert on GET /api/folios/:id/invoice → buyer.name fails /^(share|pax|sharer|guest)[\s-]*\d+$/i. Absent/null is lawful; a real name is lawful; an ordinal is not. RED on the current build.
  • S5 (P0) — at/above ₹50,000, either a typed refusal or a buyer block carrying name+address+State+State-code. RED on the current build.
  • S6 (control) — the same construction BELOW the threshold must still settle 200, so a fix cannot pass by blanket-refusing every unnamed split.
  • Non-vacuity guard — assert companions is empty BEFORE the split. AB#10777 forced the capture so S2 could not silently test the no-name path; S4/S5 need the symmetric guard so a leaked fixture companion cannot silently make them test the with-name path.
  • Assert on the invoice projection, not the folio row — the folio row is the "value that never leaves the database" trap.

Two existing cases are not what they claim​

  • S2 asserts a compliance invariant against a DB projection. Below ₹50,000 an unnamed folio row is not per se unlawful — only the printed particular is. Keep the assertion, re-charter it as a binding regression test (a captured name must bind to its sharer child), and fix its error message, which currently claims "the system has no field to record the companion's real name" — falsified by the AB#10777 comment 90 lines above it in the same file, and it will misroute the next failure to BA as a feature request.
  • S3 cannot fail. Verified: buyerLegalName is null by construction for B2C, and the resolver never concatenates two names, so the "two names on one document" defect is architecturally impossible on this path. It is green because the defect is unreachable, not because the invariant is enforced.

Decision​

Item 4 closes as gap confirmed, assertions specified, not yet written — the S4/S5/S6 cases are P0 and depend on the Rule 46(e) fix landing, so writing them now would red-gate unbuilt behaviour. The Rule 46(e) B2C defect is filed as the upstream item.