# Time Entry Locking — Implementation Plan

**Spec:** docs/specs/2026-05-31-time-entry-locking.md  ·  **Slug:** time-entry-locking  ·  **Wave:** 8
**Depends on:** foundation-auth-rbac, invoices-core, time-management, time-to-invoice

## Goal
Make billed, period-closed, and payout-approved `time_entries` immutable. Add `locked_at` / `locked_reason` columns; enforce a 409 `entry_locked` guard on every time-entry mutation; auto-lock entries when an invoice bills them (spec 77) and when a contractor payout finalizes them (spec 52); provide OWNER-driven bulk period locking and per-entry unlock (with payout approval-reset semantics); surface lock indicators + tooltips across the time UI. All lock/unlock actions are audit-logged.

## Architecture
This is a delta on the existing `time_entries` table owned by `time-management`. It adds two nullable columns and a lock-enforcement layer in `@zync/time`'s entry service. The single source of truth for "is locked" is `locked_at IS NOT NULL`.

Data flow / integration points:
- **Lock on invoice generation:** `time-to-invoice` already sets `time_entries.invoice_id = newInvoiceId, billed_at = now()` inside the `POST /api/invoices` transaction. This plan extends that same write to also set `locked_at = now(), locked_reason = 'invoiced'`. We expose `lockEntriesForInvoice(db, tenantId, entryIds, tx)` from `@zync/time` for `invoices-core` / `time-to-invoice` to call.
- **Lock on payout approval:** `contractor-payouts` (spec 52), when finalizing a `payout_bills` row, sets `approval_status = 'locked'` + `locked_at = now(), locked_reason = 'approved'`. We expose `lockEntriesForPayout(db, tenantId, entryIds, tx)`. The `approval_status` column (enum `auto_approved|pending|approved|rejected|locked`) is a base column owned by `time-management` (wave 5); this plan only reads/writes it, never re-declares it.
- **Lock on period close:** `POST /api/time/lock-period` bulk-sets `locked_reason = 'period_closed'` on unlocked entries before a cutoff. A cron `time-entry-period-lock` is NOT auto-scheduled here (the manifest references it but the spec defines only the manual bulk action; cron wiring is out of scope unless the manifest entry exists — this plan implements the manual endpoint and the reusable `lockPeriod` service the cron would call).
- **Unlock:** `POST /api/time/:id/unlock` clears `locked_at`/`locked_reason`. When `locked_reason = 'approved'`, it additionally resets `approval_status 'locked' → 'approved'` — but ONLY if the originating payout bill has been voided (`payout_bills.voided_at IS NOT NULL`); otherwise 409 `payout_active`. Live-bill detection reads `payout_bill_lines` joined to `payout_bills`.
- **Audit:** every lock-period and unlock action calls `logAuditEvent(ctx, event)` from `@zync/audit` (tenant-audit-log, spec 28). Per the cross-cutting `require-audit-in-transaction` rule, the audit enqueue happens after the mutation commits.

Upstream tables consumed: `time_entries`, `payout_bills`, `payout_bill_lines`, `invoices`, `tenants`, `users`. Upstream exports consumed: `logAuditEvent`, `AuditEvent`, `requirePermission`, `tenantQuery`, `authMiddleware`, `buildPaginated`, `Db`/`createDb`, `Env`, `Session`.

## Tech Stack
- **packages/time** (`@zync/time`): Drizzle schema delta, lock/unlock service functions, lock-enforcement guard.
- **apps/zync-api** (Hono on Cloudflare Workers): route handlers for `POST /api/time/lock-period`, `POST /api/time/:id/unlock`, and the `locked` filter on `GET /api/time`; guard injection into existing `PATCH /api/time/:id`, `DELETE /api/time/:id`, `POST /api/time/:id/stop`, `POST /api/time/beacon`.
- **apps/zync-app** (Vite + React): lock indicator + tooltip in Time Reports (`/reports/time`) and `/time`; "Period Lock" section in `/settings/time-tracking`; unlock dialog.
- **packages/db** (`@zync/db`): Drizzle migration file.
- **packages/ui** (`@zync/ui`): reuses `Dialog`, `Button`, `Tooltip`, `Alert`, `Input`, `Form`, `toast`.
- Bindings: Hyperdrive (Postgres), `audit-log-queue` (via `logAuditEvent`). No new bindings.
- Validation: Zod in routes (`require-zod-validation-in-routes`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 8a | 1, 2 | packages/db migration, packages/time schema | No (schema first) |
| 8b | 3, 4, 5 | packages/time services (lock/unlock/guard/query) | Yes (independent service fns, same package) |
| 8c | 6, 7, 8 | apps/zync-api routes + guard wiring | 6,7 parallel; 8 after 3-5 |
| 8d | 9, 10, 11 | apps/zync-app UI (settings, reports, time page) | Yes (separate pages) |
| 8e | 12 | integration verification | No (last) |

## Tasks

### Task 1: DB migration — lock columns on `time_entries`
**Blocks:** 2, 3, 4, 5, 6, 7, 8  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/00XX_time_entry_locking.sql`
**Steps:**
- [ ] `locked_at`/`locked_reason` are base columns owned by `time-management` (wave 5); do NOT re-add them. Add only the lock-consistency CHECK constraint (`locked_at IS NOT NULL` means locked) over the existing columns.
- [ ] Add a partial index to make "list locked / unlocked" and "lock period" scans cheap.
- [ ] Number the migration after the `time-to-invoice` migration (which added `invoice_id`, `billed_at`) so the column order constraint in the index holds.
**Schema / Interfaces:**
```sql
-- time_entries.locked_at and locked_reason are base columns owned by time-management (wave 5)
-- and already exist at this wave (8). This plan does NOT re-add them; it only adds the
-- lock-consistency constraint and the period-scan index, and owns the lock/unlock endpoints.

-- Enforce the invariant that reason is present iff the entry is locked.
ALTER TABLE time_entries
  ADD CONSTRAINT time_entries_lock_consistency
  CHECK (
    (locked_at IS NULL AND locked_reason IS NULL)
    OR (locked_at IS NOT NULL AND locked_reason IS NOT NULL)
  );

-- Fast lookup for period-lock scans and locked/unlocked filtering, scoped per tenant.
CREATE INDEX idx_time_entries_tenant_locked
  ON time_entries (tenant_id, locked_at);
```
**Acceptance:**
- [ ] Migration applies cleanly on a branch that already has `invoice_id`, `billed_at`, and `approval_status` on `time_entries`.
- [ ] Inserting a row with `locked_at` set but `locked_reason` NULL (or vice-versa) is rejected by `time_entries_lock_consistency`.
- [ ] `locked_reason` outside the three-value set is rejected.

### Task 2: Drizzle schema delta + types in `@zync/time`
**Blocks:** 3, 4, 5  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/time/src/schema.ts`
- Modify: `packages/time/src/types.ts`
**Steps:**
- [ ] `lockedAt`/`lockedReason` are already on the `timeEntries` Drizzle definition (owned by `time-management`); do NOT redeclare them. This task only adds the lock/unlock query helpers and types.
- [ ] Export a `LockReason` union type and a `LOCK_REASONS` const tuple used by both schema CHECK mirroring and Zod.
- [ ] Re-export the new types from the package barrel so `invoices-core` / `contractor-payouts` consume them by name.
**Schema / Interfaces:**
```ts
// packages/time/src/schema.ts — additions to the existing timeEntries table
lockedAt: timestamp('locked_at', { withTimezone: true, mode: 'date' }),
lockedReason: text('locked_reason'), // CHECK enforced at DB level

// packages/time/src/types.ts
export const LOCK_REASONS = ['invoiced', 'period_closed', 'approved'] as const
export type LockReason = (typeof LOCK_REASONS)[number]

export interface TimeEntryLockState {
  lockedAt: Date | null
  lockedReason: LockReason | null
}
```
**Acceptance:**
- [ ] `pnpm --filter @zync/time typecheck` passes.
- [ ] `LockReason` and `LOCK_REASONS` are exported from the package entrypoint.

### Task 3: Lock-enforcement guard
**Blocks:** 8  ·  **Blocked by:** 2
**Files:**
- Create: `packages/time/src/lock-guard.ts`
- Modify: `packages/time/src/index.ts`
**Steps:**
- [ ] Implement `assertEntryUnlocked(entry)` that throws a typed `EntryLockedError` carrying `reason` when `entry.lockedAt !== null`.
- [ ] Export `EntryLockedError` (with `reason: LockReason` and an HTTP-friendly `status = 409`) and a route helper `lockedJsonResponse(c, reason)` returning `{ error: 'entry_locked', reason }` with 409.
- [ ] Cover edit, delete, stop, beacon, reassign, relink, duration-change — all funnel through `assertEntryUnlocked`.
**Schema / Interfaces:**
```ts
// packages/time/src/lock-guard.ts
import type { LockReason } from './types'

export class EntryLockedError extends Error {
  readonly status = 409 as const
  constructor(public readonly reason: LockReason) {
    super('entry_locked')
    this.name = 'EntryLockedError'
  }
}

// Throws if the entry is locked. Call before any mutation of a time_entries row.
export function assertEntryUnlocked(entry: { lockedAt: Date | null; lockedReason: LockReason | null }): void {
  if (entry.lockedAt !== null) {
    throw new EntryLockedError(entry.lockedReason ?? 'invoiced')
  }
}
```
**Acceptance:**
- [ ] `assertEntryUnlocked` throws `EntryLockedError` with the correct `reason` for a locked entry and returns void for an unlocked one.
- [ ] Unit test: locked-edit / locked-delete / locked-stop all raise the error.

### Task 4: Lock service functions (invoice, payout, period)
**Blocks:** 6, 8  ·  **Blocked by:** 2
**Files:**
- Create: `packages/time/src/locking.ts`
- Modify: `packages/time/src/index.ts`
**Steps:**
- [ ] Implement `lockEntriesForInvoice(db, tenantId, entryIds)` — sets `locked_at = now(), locked_reason = 'invoiced'` for the given tenant-scoped entry ids that are currently unlocked. Accepts an optional transaction handle so `invoices-core` can call it inside the invoice-save transaction.
- [ ] Implement `lockEntriesForPayout(db, tenantId, entryIds)` — sets `locked_at = now(), locked_reason = 'approved'` AND `approval_status = 'locked'` together (both per spec). Accepts an optional transaction handle for `contractor-payouts`.
- [ ] Implement `lockPeriod(db, tenantId, cutoffDate)` — sets `locked_at = now(), locked_reason = 'period_closed'` on all unlocked entries whose `date < cutoffDate` for the tenant; returns `lockedCount`. (Entry day derived from `started_at::date`; spec uses "date < cutoffDate".)
- [ ] All three are tenant-scoped (`WHERE tenant_id = $tenantId`) and only touch currently-unlocked rows (`AND locked_at IS NULL`) so re-runs are idempotent and never overwrite an existing lock reason.
**Schema / Interfaces:**
```ts
// packages/time/src/locking.ts
import { sql } from 'drizzle-orm'
import type { Db } from '@zync/db'

export async function lockEntriesForInvoice(
  db: Db, tenantId: string, entryIds: string[],
): Promise<number> {
  if (entryIds.length === 0) return 0
  const res = await db.execute(sql`
    UPDATE time_entries
       SET locked_at = now(), locked_reason = 'invoiced'
     WHERE tenant_id = ${tenantId}
       AND id = ANY(${entryIds})
       AND locked_at IS NULL
  `)
  return res.rowCount ?? 0
}

export async function lockEntriesForPayout(
  db: Db, tenantId: string, entryIds: string[],
): Promise<number> {
  if (entryIds.length === 0) return 0
  const res = await db.execute(sql`
    UPDATE time_entries
       SET locked_at = now(),
           locked_reason = 'approved',
           approval_status = 'locked'
     WHERE tenant_id = ${tenantId}
       AND id = ANY(${entryIds})
       AND locked_at IS NULL
  `)
  return res.rowCount ?? 0
}

export async function lockPeriod(
  db: Db, tenantId: string, cutoffDate: string, // ISO 'YYYY-MM-DD'
): Promise<number> {
  const res = await db.execute(sql`
    UPDATE time_entries
       SET locked_at = now(), locked_reason = 'period_closed'
     WHERE tenant_id = ${tenantId}
       AND locked_at IS NULL
       AND started_at::date < ${cutoffDate}::date
  `)
  return res.rowCount ?? 0
}
```
**Acceptance:**
- [ ] `lockEntriesForInvoice` / `lockEntriesForPayout` only affect rows of the given tenant and skip already-locked rows.
- [ ] `lockEntriesForPayout` sets BOTH `locked_at`/`locked_reason='approved'` AND `approval_status='locked'` atomically.
- [ ] `lockPeriod` returns the count of rows it locked and never relocks already-locked rows.

### Task 5: Unlock service + live-payout-bill detection
**Blocks:** 7  ·  **Blocked by:** 2
**Files:**
- Create: `packages/time/src/unlock.ts`
- Modify: `packages/time/src/index.ts`
**Steps:**
- [ ] Implement `hasLivePayoutBill(db, tenantId, entryId)` — returns true if any non-voided `payout_bills` row references the entry through `payout_bill_lines.time_entry_id` (i.e. `payout_bills.voided_at IS NULL`).
- [ ] Implement `unlockEntry(db, tenantId, entry)` — clears `locked_at`/`locked_reason`; when the incoming `lockedReason === 'approved'`, also resets `approval_status` from `'locked'` back to `'approved'`. Caller (route, Task 7) is responsible for the `hasLivePayoutBill` 409 check before calling.
- [ ] Tenant-scope every query.
**Schema / Interfaces:**
```ts
// packages/time/src/unlock.ts
import { sql } from 'drizzle-orm'
import type { Db } from '@zync/db'
import type { LockReason } from './types'

export async function hasLivePayoutBill(
  db: Db, tenantId: string, entryId: string,
): Promise<boolean> {
  const res = await db.execute<{ exists: boolean }>(sql`
    SELECT EXISTS (
      SELECT 1
        FROM payout_bill_lines pbl
        JOIN payout_bills pb ON pb.id = pbl.bill_id
       WHERE pbl.time_entry_id = ${entryId}
         AND pb.tenant_id = ${tenantId}
         AND pb.voided_at IS NULL
    ) AS exists
  `)
  return res.rows[0]?.exists ?? false
}

export async function unlockEntry(
  db: Db, tenantId: string,
  entry: { id: string; lockedReason: LockReason | null },
): Promise<void> {
  const resetApproval = entry.lockedReason === 'approved'
  await db.execute(sql`
    UPDATE time_entries
       SET locked_at = NULL,
           locked_reason = NULL
           ${resetApproval ? sql`, approval_status = 'approved'` : sql``}
     WHERE tenant_id = ${tenantId}
       AND id = ${entry.id}
  `)
}
```
**Acceptance:**
- [ ] `hasLivePayoutBill` returns false when the only referencing bill has `voided_at` set, true when a non-voided bill references the entry.
- [ ] `unlockEntry` clears lock fields, and resets `approval_status` to `'approved'` only when the prior reason was `'approved'`.

### Task 6: `POST /api/time/lock-period` route
**Blocks:** 12  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-api/src/routes/time/lock-period.ts`
- Modify: `apps/zync-api/src/routes/time/index.ts` (mount)
**Steps:**
- [ ] Validate body with Zod: `{ cutoffDate: string }` (ISO date `YYYY-MM-DD`, must be a valid past-or-present date).
- [ ] Guard with `authMiddleware` + `requirePermission` for OWNER (the spec restricts to OWNER; period lock is an owner-level operation).
- [ ] Call `lockPeriod(db, tenantId, cutoffDate)`; respond `{ lockedCount }`.
- [ ] After success, call `logAuditEvent(ctx, { ... eventType: 'time.period_locked', metadata: { cutoffDate, lockedCount } })`.
**Schema / Interfaces:**
```ts
// Zod
const lockPeriodSchema = z.object({
  cutoffDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})

// Route contract
// POST /api/time/lock-period
//   body: { cutoffDate: string }
//   auth: OWNER
//   200 → { lockedCount: number }

// Audit
await logAuditEvent(ctx, {
  tenantId,
  userId: session.userId,
  eventType: 'time.period_locked',
  entityType: 'time_period',
  entityLabel: cutoffDate,
  metadata: { cutoffDate, lockedCount },
})
```
**Acceptance:**
- [ ] A non-OWNER caller receives 403.
- [ ] Returns `{ lockedCount }` equal to the number of newly locked entries; entries dated on/after the cutoff stay unlocked.
- [ ] An audit event `time.period_locked` is enqueued on success.

### Task 7: `POST /api/time/:id/unlock` route
**Blocks:** 12  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-api/src/routes/time/unlock.ts`
- Modify: `apps/zync-api/src/routes/time/index.ts` (mount)
**Steps:**
- [ ] Validate body with Zod: `{ reason: string }`, `min(10)`.
- [ ] Guard with `authMiddleware` + `requirePermission` for OWNER.
- [ ] Load the tenant-scoped entry; 404 if missing; if `locked_at IS NULL` respond 409 `not_locked` (nothing to unlock).
- [ ] If `locked_reason === 'approved'` and `await hasLivePayoutBill(db, tenantId, id)` → respond 409 `{ error: 'payout_active' }` (void the payout bill first).
- [ ] Call `unlockEntry(db, tenantId, entry)`.
- [ ] After success, `logAuditEvent(ctx, { eventType: 'time.entry_unlocked', entityType: 'time_entry', entityId: id, metadata: { reason, priorLockReason } })` — the unlock reason MUST appear in the audit metadata (spec 28 cross-cutting rule).
**Schema / Interfaces:**
```ts
const unlockSchema = z.object({
  reason: z.string().min(10),
})

// POST /api/time/:id/unlock
//   body: { reason: string }  (min 10 chars)
//   auth: OWNER
//   200 → { id, lockedAt: null, lockedReason: null, approvalStatus }
//   409 → { error: 'payout_active' }   (live non-voided payout bill references entry)
//   409 → { error: 'not_locked' }
//   404 → entry not found in tenant

await logAuditEvent(ctx, {
  tenantId,
  userId: session.userId,
  eventType: 'time.entry_unlocked',
  entityType: 'time_entry',
  entityId: id,
  metadata: { reason, priorLockReason: entry.lockedReason },
})
```
**Acceptance:**
- [ ] Unlocking an `'invoiced'` entry clears lock fields and leaves `approval_status` untouched.
- [ ] Unlocking an `'approved'` entry whose payout bill is voided clears lock fields AND resets `approval_status` to `'approved'`.
- [ ] Unlocking an `'approved'` entry with a live (non-voided) payout bill returns 409 `payout_active` and changes nothing.
- [ ] `reason` shorter than 10 chars → 400; the accepted reason is present in the audit metadata.

### Task 8: Enforce lock guard on existing mutations + `locked` filter on list
**Blocks:** 12  ·  **Blocked by:** 3, 4
**Files:**
- Modify: `apps/zync-api/src/routes/time/[id].ts` (PATCH, DELETE)
- Modify: `apps/zync-api/src/routes/time/stop.ts` (`POST /api/time/:id/stop`)
- Modify: `apps/zync-api/src/routes/time/beacon.ts` (`POST /api/time/beacon`)
- Modify: `apps/zync-api/src/routes/time/list.ts` (`GET /api/time`)
**Steps:**
- [ ] In `PATCH /api/time/:id`, `DELETE /api/time/:id`, `POST /api/time/:id/stop`, and the beacon stop path: after loading the entry and before mutating, call `assertEntryUnlocked(entry)`; on `EntryLockedError` return `lockedJsonResponse(c, err.reason)` (409 `{ error: 'entry_locked', reason }`). This also blocks reassign / relink / duration changes since those go through PATCH.
- [ ] Extend `GET /api/time` query schema with `locked` (`'true' | 'false'`, optional). When present, filter: `locked=true` → `locked_at IS NOT NULL`; `locked=false` → `locked_at IS NULL`. Compose with the existing `unbilled`, `projectId`, `customerId`, date-range, user filters.
- [ ] Include `lockedAt` and `lockedReason` in the serialized list/detail entry payload so the UI can render indicators and tooltips.
**Schema / Interfaces:**
```ts
// GET /api/time query extension
const listQuerySchema = existingSchema.extend({
  locked: z.enum(['true', 'false']).optional(),
})
// filter:
//   locked === 'true'  → sql`locked_at IS NOT NULL`
//   locked === 'false' → sql`locked_at IS NULL`

// Serialized time entry gains:
//   lockedAt: string | null   (ISO)
//   lockedReason: 'invoiced' | 'period_closed' | 'approved' | null
```
**Acceptance:**
- [ ] PATCH/DELETE/stop/beacon against a locked entry return 409 `{ error: 'entry_locked', reason }` and do not mutate the row.
- [ ] `GET /api/time?locked=true` returns only locked entries; `?locked=false` only unlocked; absence returns all (subject to other filters).
- [ ] List/detail responses include `lockedAt` and `lockedReason`.

### Task 9: `/settings/time-tracking` → Period Lock section
**Blocks:** 12  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/pages/settings/time-tracking.tsx`
- Create: `apps/zync-app/src/features/time/PeriodLockSection.tsx`
**Steps:**
- [ ] Add a "Period Lock" section to the existing `/settings/time-tracking` page (do NOT create a `/settings/time/lock` route — the spec folds it here). Render only for OWNER/ADMIN.
- [ ] Date input "Lock all time entries before" + descriptive copy. On change, optionally show a count preview by calling `GET /api/time?locked=false` filtered client-side, or display the static confirmation copy from the spec.
- [ ] Confirm dialog (`Dialog`): "This will lock {N} entries… Locked entries cannot be edited without manual unlock." with `[Cancel]` / `[Lock period]`.
- [ ] On confirm → `POST /api/time/lock-period` with `{ cutoffDate }`; show `toast` "Locked {lockedCount} entries"; handle 403.
- [ ] Honor RTL/Hebrew (`useDirection`), `prefers-reduced-motion` on the dialog transition, and proper `aria-label`/`role="dialog"` (cross-cutting a11y).
**Schema / Interfaces:** —
**Acceptance:**
- [ ] OWNER/ADMIN sees the Period Lock section; other roles do not.
- [ ] Submitting a cutoff date posts to `/api/time/lock-period` and reflects `lockedCount` in a toast.
- [ ] The section renders correctly under RTL and respects reduced-motion.

### Task 10: Locked indicators in Time Reports (`/reports/time`)
**Blocks:** 12  ·  **Blocked by:** 8
**Files:**
- Modify: `apps/zync-app/src/pages/reports/time.tsx`
- Create: `apps/zync-app/src/features/time/LockBadge.tsx`
**Steps:**
- [ ] For each entry row, when `lockedAt !== null`, render a lock icon in place of edit/delete controls; suppress the edit/delete buttons entirely.
- [ ] Tooltip text by reason: `'invoiced'` → "Locked — included in {invoiceNumber}" (use the entry's linked invoice number); `'period_closed'` → "Locked — period closed"; `'approved'` → "Locked — included in finalized payout".
- [ ] Tooltip uses `@zync/ui` `Tooltip` with `aria-label` so the lock state is exposed to assistive tech; icon has `role="img"` + accessible name.
**Schema / Interfaces:** —
**Acceptance:**
- [ ] Locked rows show the lock icon and no edit/delete controls; unlocked rows keep the edit/delete affordances.
- [ ] Tooltip text matches the entry's `lockedReason`, and the invoice number appears for `'invoiced'`.

### Task 11: Lock state + unlock action on `/time` page
**Blocks:** 12  ·  **Blocked by:** 7, 8
**Files:**
- Modify: `apps/zync-app/src/pages/time/index.tsx`
- Modify: `apps/zync-app/src/features/time/TimeEntryRow.tsx`
- Create: `apps/zync-app/src/features/time/UnlockDialog.tsx`
**Steps:**
- [ ] In `TimeEntryRow`, when `lockedAt !== null`, disable inline edit/delete and render the `LockBadge`.
- [ ] For OWNER, expose an "Unlock" action (menu item) opening `UnlockDialog`.
- [ ] `UnlockDialog`: required reason textarea (min 10 chars, client-validated + server-enforced); on submit `POST /api/time/:id/unlock`. Handle 409 `payout_active` with a clear message: "This entry is on a live payout bill — void the payout bill first." Handle 409 `not_locked`.
- [ ] On success, invalidate the time list query so the row becomes editable again; `toast` confirmation.
- [ ] Active running timer is never shown as locked (lock only applies to past entries); no warning needed on the running timer per spec.
- [ ] a11y: dialog `role="dialog"`, focus trap, reduced-motion transition; RTL via `useDirection`.
**Schema / Interfaces:** —
**Acceptance:**
- [ ] Locked entries on `/time` are non-editable and show the lock badge; OWNER can open the unlock dialog.
- [ ] Submitting a <10-char reason is blocked client-side; a valid unlock refreshes the list and re-enables editing.
- [ ] A `payout_active` 409 surfaces the "void the payout bill first" message and leaves the row locked.

### Task 12: Cross-module wiring + integration verification
**Blocks:** —  ·  **Blocked by:** 6, 7, 8, 9, 10, 11
**Files:**
- Modify: `apps/zync-api/src/routes/invoices/create.ts` (or wherever `time-to-invoice` marks entries billed)
- Modify: `apps/zync-api/src/routes/contractors/bills.ts` (payout finalize + void paths)
**Steps:**
- [ ] In the invoice-save transaction (`POST /api/invoices` with `billedEntryIds`), after setting `invoice_id` / `billed_at`, call `lockEntriesForInvoice(tx, tenantId, billedEntryIds)` so billed entries become `locked_reason = 'invoiced'` in the same transaction.
- [ ] In the payout-bill finalize path (`contractor-payouts`), call `lockEntriesForPayout(tx, tenantId, entryIds)` so finalized entries get `approval_status='locked'` + `locked_reason='approved'` together.
- [ ] In the payout-bill void path (`POST /api/contractors/:id/bills/:bid/void`), after setting `voided_at`, unlock the bill's entries (clear `locked_at`/`locked_reason`, reset `approval_status 'locked' → 'approved'`) — reuse `unlockEntry` per entry, or a batch variant — so they can be re-billed (spec 52 C-9 / this spec's reopening flow).
- [ ] Confirm `invoices` deletion/void leaves entries unbillable-then-unlockable: `invoice_id` clears via `ON DELETE SET NULL`; lock clearing on invoice void is handled by the OWNER unlock flow (spec does not auto-unlock on invoice void — only payout void auto-unlocks).
**Schema / Interfaces:** —
**Acceptance:**
- [ ] Generating an invoice from time entries leaves those entries with `locked_at` set and `locked_reason = 'invoiced'`; they cannot be edited afterward (409 `entry_locked`).
- [ ] Finalizing a payout bill locks its entries with `locked_reason='approved'` and `approval_status='locked'`.
- [ ] Voiding that payout bill unlocks the entries and resets `approval_status` to `'approved'`, making them re-billable.
- [ ] Attempting to unlock an `'approved'` entry while its payout bill is still live returns 409 `payout_active`.
