Skip to main content

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:

valuederivationlabel (IST, 2026-08-06)consumed by
dategetPropertyLocalMidnight() (:139) → 2026-08-05T18:30:00Z05 AugauditDate → @db.Date (:1076)
keygetPropertyLocalDateKey(date, tz) (:149)06 Augno-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):

ObservationResult
Run 1 auditDate, read back in a SEPARATE request2026-08-06T00:00:00.000Z — UTC midnight, ONE label ✅
currentBusinessDate after run 12026-08-07T00:00:00.000Z (advanced by exactly 1) ✅
Run 2 POST /startHTTP 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​

  1. "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 24h postingDate window (: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.
  2. My proposed invariant was wrong. "Every row a run posts carries postingDate == auditDate" contradicts AB#11233: nightsOwedFor legitimately posts backfill nights dated B-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.
  3. "Every freshly onboarded property" understates it. advanceBusinessDate is 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.
  4. Blast radius is three services. night.audit.completed.businessDate is auditDate.toISOString() (:1268), so analytics keys mv_daily_kpi/mv_daily_occupancy on the wrong @db.Date label 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 to new 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:

  1. 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.
  2. utcDateKey(NightAuditLog.auditDate) === K
  3. Night rows (ROOM, OVERSTAY_NIGHT): label ∈ nightsOwedFor(rr, K), never > K
  4. Close-raised rows (NO_SHOW, run-scoped fees): label === K exactly (today :2750 writes today)
  5. Property.currentBusinessDate after the run === K + 1 day
  6. night.audit.completed.businessDate and its auditDate alias both reduce to K
  7. (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::AC3 currently asserts the defect — it pins '2026-07-30T18:30:00.000Z'. Rewriting it IS the regression evidence.
  • flow-9-night-audit.spec.ts S4/S6 soft-skip behind FLOW9_FULL_AUDIT — a duplicate-start test that cannot fail in the default run.
  • night-audit-run.test-env.spec.ts:124-131 tolerates a 201 on the second start, with a comment calling it acceptable.
  • L1 owner pms-j-night-audit.flow.spec.ts::TC-704 already 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​

  1. Measure the legacy-averaged-branch double-post before fixing severity.
  2. AC3's refusal must be NARROW — refuse only when no canonical source (column, audit boundary, or goLiveDate) can answer. Refusing on currentBusinessDate IS NULL alone makes the first-ever audit impossible and bricks every seeded property.
  3. 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.