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:
| Site | Predicate | Effect on a credit |
|---|---|---|
checkout.service.ts:862 | totalBalance > 0 | sign-aware — correctly lets it pass |
checkout.service.ts:1263 | Math.abs(finalBalance) > 0.01 | blocks |
checkout.service.ts:1859 (expressCheckOut) | Math.abs(totalBalance) > 0.005 | blocks |
night-audit.service.ts:3241 | totalBalance > 0.005 skip | selects 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:
:1326— a positive-amountPAYMENTrow, inverting the convention the code's own comment states at:1318-1319("money into the folio is negative").:1335—totalPaymentsis decremented, corrupting a column treated as monotonic inbound.:2181-2182—POST /early-checkoutpassesforceCheckout: trueunconditionally, 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.
| Balance | forceCheckout | FRONT_DESK agent |
|---|---|---|
+1.00 | true | 403 FORCE_CHECKOUT_REQUIRES_MANAGER_APPROVAL |
-1,483,977.80 | true | allowed — 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.overpaymentAlertThresholdrefuses (blocked:true→safeguard.service.ts:165-166throws), it is not alert-only. Default ₹100. The refuse-with-override mechanism already exists and is production-proven.- The
Paymentuniqueness guard is ineffective.@@unique([reservationId, reference, amount])(schema.prisma:375) sits over a nullablereference(:346), and the migration is a plainCREATE UNIQUE INDEXwith noNULLS 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
| # | Decision | Basis |
|---|---|---|
| D1 | Setup-A prepares its room rather than observing ambient state | SME: 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 |
| D2 | Terminal prepared state is INSPECTED, not CLEAN | superset — satisfies an inspection-mandating property and a non-mandating one, and survives inspectionRequiredBeforeSellable landing later |
| D3 | Shared fixture switched to housekeeping-axis-only writes | the composite write bypassed both state machines; no flow test had ever exercised the DIRTY → CLEAN → VACANT_CLEAN derivation |
| D4 | 14 overstays cleared by settle-then-checkout | operator choice; 11 succeeded, ₹159,206.81 posted, 3 refused and became this document |
| D5 | Credit findings filed, not fixed | operator choice — production money-handling stays untouched pending review |
| D6 | INV-XCO-CREDIT left red and unregistered | a 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 onlyPaymentServiceClientuse isgetPayment. Required by card-scheme return-to-original-tender. - E3 §31(3)(e) refund voucher — absent;
CreditNoteis 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
_checkOutbypassesFOLIO_DOES_NOT_RECONCILE— it writestx.folio.updateMany({status:'SETTLED'})directly (:1372), so the F29/AB#9643 guard inFoliosService.settleFolionever 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 infront-desk.ts. The route is live andFRONT_DESK-authorised regardless. hmp-mobile-apps/hmp-kds-kioskcheckout surfaces — not inspected.- Whether any leg of the ₹15 lakh was CASH (which would implicate the §269ST guard).
- Whether
Reservation.totalAmountis reliably populated across all booking sources — the proposed ceiling fails closed because this could not be established. - On the 3-folio reservation
caebff9a-…, a201payment to folioa90a35afread back aspayments=0while 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:
| spec | flow | status | layer | in core-PMS scope |
|---|---|---|---|---|
company-ar-journey (12 screens) | CITY-LEDGER-BALANCE | fail | L1+L2 | yes |
tape-chart-group-room-count | TAPE-GROUP-ROOMS | fail | L2 | yes |
operations-hub-room-status | UI-HOME | pass | L2 | yes |
guest-kiosk-journey | BE-KIOSK | fail | L4 | no |
guest-public-order | POS-GUESTQR | pass | L1+L4 | no |
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:
| journey | failing flow | dims |
|---|---|---|
ac-city-ledger-ar | CITY-LEDGER-BALANCE | api FAIL, ui FAIL |
FD-COMPANY-AR-OPENITEM | api FAIL | |
ac-proforma-to-tax-invoice | FD-SETTLE-IRN | api FAIL |
FD-NOGST-ZERO | api FAIL | |
ac-gst-filing-period | FD-VOID | api 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
- 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.
- 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-2under a field labelled "Billed To" on a document headedTAX INVOICEis neither. The defect is the fabricated particular, not the separate document — two sharers settling separately genuinely are two recipients of two supplies. - The no-name path is the DEFAULT, not an edge case. Verified:
paxGuestRefForreturnsnullforINDIVIDUAL, 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.namefails/^(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
companionsis 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:
buyerLegalNameisnullby 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.