> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Ledger payable dates

> LedgerItem.date is a civil business day. Invitation and crew-offer PAYABLEs follow Event.eventDate; reschedule edits open lines in place.

`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](https://docs.soundchecklive.io/features/gigs/payments.md). Domain rules: `.agents/skills/finance-domain/SKILL.md`.

## Field meanings

| Field | Meaning |
| - | - |
| `LedgerItem.date` | Civil business day of the line |
| `ledger_items.created_at` / `updated_at` / `settled_at` | Real UTC Instants |
| `Transaction.date` | Payment event |
| Invitation / crew-offer PAYABLE intended date | `Event.eventDate` (UTC civil day) |
| Manual lines | User-owned date (forms default to the gig day) |

Do not derive `date` at read time from `Event.eventDate`. Stored `date` is the source of truth.

### Field → Temporal type map (ENG-582)

| Field | Kind | Temporal type | Parse / format |
| - | - | - | - |
| `LedgerItem.date` | Civil day | `Temporal.PlainDate` | `parseCivilDay('YYYY-MM-DD')` / `formatCivilDay` |
| Invitation / crew-offer PAYABLE intended date (`Event.eventDate`) | Civil day | `Temporal.PlainDate` | same |
| `ledger_items.created_at` / `updated_at` / `settled_at` | Instant | `Temporal.Instant` | `instantFromIso` / `nowInstant` |
| `Transaction.date` | Instant (payment event) | `Temporal.Instant` | same |
| Call times (`Event.startTime` / `endTime`, schedule items) | Zoned wall clock | `Temporal.ZonedDateTime` | `instantFromZonedWallClock` (zone from ENG-580 decision) |

Rules:

* Parse a bare `YYYY-MM-DD` with `parseCivilDay` (in each package's `temporalDate` module) — never `new Date('YYYY-MM-DD')`, which parses as UTC midnight and shifts a day west of UTC.
* Prisma still hydrates `@db.Date` as a JS `Date` (UTC midnight); convert at the mapping edge with the `bare-date:interop` markers, never re-parse the wire string with `new Date`.
* The `check:bare-date` gate (in api-events `npm run checks`) keeps new bare `Date` out 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

When `updateEventWithDateCascade` 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 = PAYABLE` and `status = UNPAID`
* `invitation_id IS NOT NULL` **or** `notes LIKE 'source=crew\_offer;%' ESCAPE '\'`
* **no** `transactions` row exists (any status: `PENDING`, `SUCCEEDED`, `FAILED`, `REVERSED`, `REFUNDED`)
* stored UTC day is distinct from the new event day

Leave these on their stored day:

* `PAID`, `PROCESSING`, `FAILED`, `REFUNDED`, `VOIDED`
* any item with payment activity
* manual lines (no invitation id and no crew-offer notes)

Same-day timestamp-only `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_offset` already count from `eventDate`, so they are not changed.
* Growing or shrinking the range (`extend`, `reduce` in `classifyEventRangeChange`) 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_offset` still count from the new `eventDate`, so they point one day off. That needs its own fix.
* An import that skips a user-claimed schedule (`importedScheduleSkipped`) leaves `eventDate` alone, 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)

Prisma `migrate 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.

| Migration | What ran | When |
| - | - | - |
| `20260228000000_ledger_model` | Backfilled invitation PAYABLE `date` from `Invitation.createdAt` | Ledger model introduce |
| `20260307140000_destination_charge_payouts` | Rewrote rows whose `date` **exactly equaled** midnight UTC of `createdAt` to `eventDate` | Applied qa 2026-03-09, prod 2026-03-16 |
| Later edit of `20260307` | Day-equality wrap (`timezone('UTC', … AT TIME ZONE 'UTC')::date`) | **Never applied** — file drifted from deployed SQL; restored to midnight equality |
| `20260316000000_fix_ledger_tz_backfill` | UTC **day** equality vs `createdAt`, set `date = eventDate` | Applied qa/prod 2026-03-17 |
| `20260817160000_ledger_item_dates_to_midnight` | `date_trunc('day', …)` on non-midnight `ledger_items.date`, then `ALTER … TYPE DATE` | New — ledger 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 not `Event.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.

```sql theme={null}
SELECT
  CASE
    WHEN li.date
         IS NOT DISTINCT FROM (i."respondedAt" AT TIME ZONE 'UTC')::date
      THEN 'matches_respondedAt_day'
    WHEN li.date
         IS NOT DISTINCT FROM (i."createdAt" AT TIME ZONE 'UTC')::date
      THEN 'matches_invitation_createdAt_day'
    WHEN li.date
         IS NOT DISTINCT FROM (li.created_at AT TIME ZONE 'UTC')::date
      THEN 'matches_ledger_created_at_day'
    ELSE 'other'
  END AS leftover_class,
  COUNT(*) AS n
FROM ledger_items li
JOIN "Invitation" i ON i.id = li.invitation_id
JOIN "Event" e ON e.id = i."eventId"
WHERE li.type = 'PAYABLE'
  AND li.date
      IS DISTINCT FROM (e."eventDate" AT TIME ZONE 'UTC')::date
GROUP BY 1
ORDER BY n DESC;
```

A `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:

```sql theme={null}
SELECT
  (li.date
    - (e."eventDate" AT TIME ZONE 'UTC')::date) AS days_from_event,
  (e."updatedAt" AT TIME ZONE 'UTC')::date
    > li.date AS event_updated_after_ledger_day,
  COUNT(*) AS n
FROM ledger_items li
JOIN "Invitation" i ON i.id = li.invitation_id
JOIN "Event" e ON e.id = i."eventId"
WHERE li.type = 'PAYABLE'
  AND li.date
      IS DISTINCT FROM (e."eventDate" AT TIME ZONE 'UTC')::date
  AND li.date
      IS DISTINCT FROM (i."respondedAt" AT TIME ZONE 'UTC')::date
  AND li.date
      IS DISTINCT FROM (i."createdAt" AT TIME ZONE 'UTC')::date
  AND li.date
      IS DISTINCT FROM (li.created_at AT TIME ZONE 'UTC')::date
GROUP BY 1, 2
ORDER BY n DESC;
```

## Related

* [Database](https://docs.soundchecklive.io/engineering/backend/database.md) — never edit applied migration SQL
* [Recurring pitfalls](https://docs.soundchecklive.io/engineering/reference/pitfalls.md) — applied Prisma migrations are checksummed
* [Gig payments](https://docs.soundchecklive.io/features/gigs/payments.md) — user-facing reschedule note


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.