LedgerItem.date is the effective civil business day of a ledger line. Postgres stores it as DATE (DateTime @db.Date). The API accepts YYYY-MM-DD; inserts use ${date}::date. It is not an insert Instant, not a call clock (Event.startTime / endTime), and not created_at.
Product behavior: Gig payments. Domain rules: .agents/skills/finance-domain/SKILL.md.
Field meanings
Do not derive
date at read time from Event.eventDate. Stored date is the source of truth.
Field → Temporal type map (ENG-582)
Rules:
- Parse a bare
YYYY-MM-DDwithparseCivilDay(in each package’stemporalDatemodule) — nevernew Date('YYYY-MM-DD'), which parses as UTC midnight and shifts a day west of UTC. - Prisma still hydrates
@db.Dateas a JSDate(UTC midnight); convert at the mapping edge with thebare-date:interopmarkers, never re-parse the wire string withnew Date. - The
check:bare-dategate (in api-eventsnpm run checks) keeps new bareDateout of production code.
Writers
Invitation-linked PAYABLEs (invitation_id set) and crew-offer PAYABLEs (notes starting source=crew_offer;) are stamped with Event.eventDate at create time. createPayableLedgerItem takes the same event-date: advisory lock as the reschedule snap, re-reads Event.eventDate, and inserts that civil day. A concurrent date change cannot insert on the old day.
Recompute / amount upserts do not rewrite stored date on existing payables. New supplement creates go through createPayableLedgerItem and use the current gig day.
Manual PAYABLE / INCOME / EXPENSE / RECEIVABLE lines keep the date the caller sent (createLedgerItem is unlocked).
Gig reschedule
WhenupdateEventWithDateCascade changes Event.eventDate’s UTC civil day, it takes the event-date: advisory lock and edits matching PAYABLE date in place in the same transaction (snapOpenGigPayableDatesWithTx in api-events/src/db/eventCrud.ts). Invitation and crew-offer payable creates take that same lock before they insert.
The UPDATE moves a row only when all of these hold:
type = PAYABLEandstatus = UNPAIDinvitation_id IS NOT NULLornotes LIKE 'source=crew\_offer;%' ESCAPE '\'- no
transactionsrow exists (any status:PENDING,SUCCEEDED,FAILED,REVERSED,REFUNDED) - stored UTC day is distinct from the new event day
PAID,PROCESSING,FAILED,REFUNDED,VOIDED- any item with payment activity
- manual lines (no invitation id and no crew-offer notes)
eventDate edits and endDate-only edits do not snap. Unlocked multi-day start-date changes still snap open payables; staffing cascade stays single-day-only.
Do not void-and-copy on reschedule. invitation_id is unique on ledger items, and void means cancellation, not a moved gig.
Schedule and Run of Show follow the start day (ENG-1721)
EventScheduleItem, run_of_show_items and run_of_show_sections store absolute instants. When the same update slides the gig (both ends move the same way, which includes moving a single-day gig), shiftGigScheduleWithTx moves every one of their start and end times by the eventDate day delta, in the same transaction and under lockRunOfShowTx. Each row keeps its wall clock in the gig’s zone (the one the update sends, else the stored one, as the staffing cascade does), so a move across a DST change keeps 7:00 PM at 7:00 PM. A shift that touches Run of Show rows bumps event_run_of_show_meta.version.
- A multi-day slide moves every day by the same amount. Section and key time
day_offsetalready count fromeventDate, so they are not changed. - Growing or shrinking the range (
extend,reduceinclassifyEventRangeChange) shifts nothing: the remaining days keep their schedule on their calendar day.endDate-only and same-day edits shift nothing either. - Known gap: when the front day is added or removed, section and key time
day_offsetstill count from the neweventDate, so they point one day off. That needs its own fix. - An import that skips a user-claimed schedule (
importedScheduleSkipped) leaveseventDatealone, so it shifts nothing either. - Gigs moved before this change keep their old-day rows. Repairing them is a separate, approved data change.
Applied backfills (do not re-edit)
Prismamigrate deploy records a checksum and does not re-run an applied migration. Editing the SQL file after qa/prod applied it changes the repo only.
20260316 is the rewrite that moved the original createdAt backfill cohort. Do not edit 20260307 again to “fix” leftovers. If a classified leftover rewrite is needed, add a new migration.
Studio historically stored gig civil days as UTC noon so a DateTime column would not flip the calendar day in US timezones, and ledger backfills copied that Instant onto ledger_items.date. This branch midnight-normalizes ledger dates (::time <> 00:00:00, including the one prod leftover that is neither noon nor midnight), then converts the column to DATE. Event.eventDate encoding stays with ENG-580. Transaction.date stays TIMESTAMP(3).
Leftover inventory (read-only)
Invitation PAYABLEs whose UTC civil day is notEvent.eventDate can remain after 20260316. Causes include later reschedules (before the runtime snap), Flex eventDate Instant encoding (see ENG-580), and non-midnight createdAt that midnight-equality missed before 20260316.
Do not blindly SET date = eventDate for every leftover. Classify first. A future backfill, if any, should be UNPAID-only, skip items with transactions, and treat Flex ±1-day non-midnight eventDate as a separate Instant-encoding question.
Session TZ is assumed UTC; the queries still name UTC explicitly.
matches_invitation_createdAt_day hit after 20260316 means date and eventDate differ as civil days but share the invite-create UTC day — unexpected if that rewrite copied eventDate. More likely leftovers are matches_respondedAt_day, matches_ledger_created_at_day, or other.
For other rows, drill into day offset and whether the event was edited after the ledger day:
Related
- Database — never edit applied migration SQL
- Recurring pitfalls — applied Prisma migrations are checksummed
- Gig payments — user-facing reschedule note