Night audit — the closing business date: verified defect + story record
Status: BA-approved for build with three conditions · Date: 2026-08-06 · Core PMS, India-market
The defect (verified twice — my code trace, then re-derived independently by hospitality-ba)
On the WALL_CLOCK_FALLBACK branch of resolveClosingBusinessDate
(hmp-frontdesk-service/src/night-audit/night-audit.service.ts:958-990), one run carries two
calendar labels:
| value | derivation | label (IST, 2026-08-06) | consumed by |
|---|---|---|---|
date | getPropertyLocalMidnight() (:139) → 2026-08-05T18:30:00Z | 05 Aug | auditDate → @db.Date (:1076) |
key | getPropertyLocalDateKey(date, tz) (:149) | 06 Aug | no-show sweep (common/no-show.ts:95) |
Root cause, one line: :977 jumps straight to getPropertyLocalMidnight and never consults the
canonical openBusinessDate ladder (common/open-business-date.ts:190-247), which already implements
currentBusinessDate → lastCompletedAuditDate+1 → goLiveDate → istTodayUtcDate and returns UTC
midnight on every limb. The fallback is not "the platform's answer with no data" — it is a second,
differently-based derivation.
Measured consequence (TEST, deployed build): a second POST /night-audit/:propertyId/start
returns 201, not 400. Run 1 sets currentBusinessDate, so run 2 takes the other branch, computes a
different auditDate, and @@unique([propertyId, auditDate]) never engages.
⚠ VERIFIED ON THE DEPLOYED POD (2026-08-07, frontdesk 09ff585) — and my commit message OVERCLAIMED
Measured on a freshly created property (currentBusinessDate: null at creation, confirmed):
| Observation | Result |
|---|---|
Run 1 auditDate, read back in a SEPARATE request | 2026-08-06T00:00:00.000Z — UTC midnight, ONE label ✅ |
currentBusinessDate after run 1 | 2026-08-07T00:00:00.000Z (advanced by exactly 1) ✅ |
Run 2 POST /start | HTTP 201 — still NOT refused ❌ |
The date fix is real and verified. The duplicate-start refusal is NOT fixed, and I said it would be.
The commit message for 09ff585 states the two-label divergence is "also why a second POST /start was
ACCEPTED". That is only ONE of two mechanisms, and removing it is not sufficient. Run 1 closes B and
advances to B+1; run 2 then legitimately resolves B+1 — a different auditDate, so
@@unique([propertyId, auditDate]) correctly does not collide. Nothing is wrong with the guard.
What is missing is the gate the SME named and I did not build: you may not close a day that has not
ended (currentBusinessDate >= property-local today ⇒ refuse). Catch-up — a business date strictly in
the past — must stay ALLOWED, which is exactly why a bare duplicate check cannot be the answer.
Consequence: pms-j-night-audit.flow.spec.ts::TC-704 stays RED, correctly. It is regression-first for
a fix that has not been made yet. Do not "fix" it by loosening it to accept 201 — the L2 spec
night-audit-run.test-env.spec.ts:124-131 already does that, with a comment calling a 201 acceptable, and
that tolerance is itself on the correction list.
Four corrections to my original framing — all from the BA, all accepted
- "No double-post exposure" is safe ONLY for the folio I watched. That folio was protected by the
AB#11233 per-night reference
room-night-{rr}-{date}. The legacy averaged branch writes no reference (:2370-2385) and dedups on a 24hpostingDatewindow (:1675). Run 1's window[08-05T18:30Z, 08-06T18:30Z)and run 2's[08-06T00:00Z, 08-07T00:00Z)do not overlap, so it would re-post a full room night + GST. CODE-TRACED, NOT MEASURED — must be measured before the severity is settled. If it reproduces this is a P0 MONEY finding, not a contract one. - My proposed invariant was wrong. "Every row a run posts carries
postingDate == auditDate" contradicts AB#11233:nightsOwedForlegitimately posts backfill nights datedB-1, B-2, …. The correct class rule is a SPLIT — a row that PAYS FOR A NIGHT carries that night's date; a row RAISED BY THE CLOSE carries the closing date; there is exactly ONE closing date per run. - "Every freshly onboarded property" understates it.
advanceBusinessDateis best-effort and swallows failures into a WARN (:4455-4462) — one property-service blip and a property that HAS audited falls back to the wall clock on its next run. - Blast radius is three services.
night.audit.completed.businessDateisauditDate.toISOString()(:1268), so analytics keysmv_daily_kpi/mv_daily_occupancyon the wrong@db.Datelabel and the notification GM email names the wrong day.
Why it survived: no surface shows the truth
- Preview + close-day summary read
preview.businessDate= the key → say 06 Aug. - The ledger records
auditDate→ 05 Aug. - The result screen / CSV export reads
businessDateClosed, for which no producer exists anywhere in frontdesk — so it falls back tonew Date()in the browser, a third clock.
There is no surface on which an operator can observe the date the run actually stamped.
The invariant gate — INV-NA-DATE
Asserted over any completed run with resolved key K:
- One label at the seam (pure, no DB): for BOTH branches,
utcDateKey(result.date) === result.key. Fails today on the fallback branch — this is the regression-first red, and the cheapest catching layer. utcDateKey(NightAuditLog.auditDate) === K- Night rows (
ROOM,OVERSTAY_NIGHT): label ∈nightsOwedFor(rr, K), never> K - Close-raised rows (
NO_SHOW, run-scoped fees): label=== Kexactly (today:2750writestoday) Property.currentBusinessDateafter the run=== K + 1 daynight.audit.completed.businessDateand itsauditDatealias both reduce toK- (linked story) rows voided by rollback === rows posted by the run
Harness corrections owed in the same PR
night-audit-closing-business-date.spec.ts::AC3currently asserts the defect — it pins'2026-07-30T18:30:00.000Z'. Rewriting it IS the regression evidence.flow-9-night-audit.spec.tsS4/S6 soft-skip behindFLOW9_FULL_AUDIT— a duplicate-start test that cannot fail in the default run.night-audit-run.test-env.spec.ts:124-131tolerates a 201 on the second start, with a comment calling it acceptable.- L1 owner
pms-j-night-audit.flow.spec.ts::TC-704already exists and is already regression-first (de-vacuumed today, now measures the money as well as the status). Turn it green — do not write a new one.
BA conditions on the build
- Measure the legacy-averaged-branch double-post before fixing severity.
- AC3's refusal must be NARROW — refuse only when no canonical source (column, audit boundary, or
goLiveDate) can answer. Refusing oncurrentBusinessDate IS NULLalone makes the first-ever audit impossible and bricks every seeded property. - Sequence the data remediation (AC21) BEFORE the refusal (AC3), or the first cron after deploy starts refusing on properties that were trading fine yesterday.
Split: AC1–16, AC18 → hmp-engineer in hmp-frontdesk-service. AC19–22 → hmp-property-service +
onboarding + seeds + data remediation (different owner). Rollback (postingDate: audit.auditDate
exact-equality, already incomplete on the canonical branch since AB#11233) → linked sibling story,
because its real fix is stamping nightAuditLogId on every posted row, which the code itself defers.