# Time Entry Locking

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 95  
**Tier:** All tiers  
**Depends on:** `time-management`, `invoices-core`, `time-to-invoice`, `foundation-auth-rbac`  
**Referenced by:** `time-management`, `time-to-invoice`

---

## Overview

Spec 13 (`time-management`) and spec 59 (`time-to-invoice`) allow time entries to be linked to invoices via `time_entries.invoice_id`. Once billed, entries should be immutable. This spec adds locking: explicit lock on invoice billing, auto-lock on period close, and UI indication of locked entries.

---

## Data Model

```sql
-- Schema delta on time_entries:
ALTER TABLE time_entries ADD COLUMN locked_at TIMESTAMPTZ;
ALTER TABLE time_entries ADD COLUMN locked_reason TEXT CHECK (locked_reason IN ('invoiced', 'period_closed', 'approved'));
-- locked_at IS NOT NULL = entry is locked (read-only)
```

Lock triggers:
1. **On invoice generation** (spec 59): when time entries are billed → `locked_at = now(), locked_reason = 'invoiced'`
2. **On approval** (spec 95 — manual): OWNER/ADMIN can lock a period explicitly
3. **On period close**: cron `time-entry-period-lock` (see 00-index manifest) or manual action locks all entries before a cutoff date

---

## Lock Enforcement

All write operations on time_entries (edit, delete, stop-timer) check:

```ts
if (entry.locked_at !== null) {
  return c.json({ error: 'entry_locked', reason: entry.locked_reason }, 409)
}
```

Locked entries also cannot be reassigned, relinked to a different project, or have their duration changed.

OWNER can override: `POST /api/time/:id/unlock` — requires explicit unlock with reason (audit-logged per spec 28 cross-cutting rule).

---

## Period Lock: `/settings/time-tracking` → Period Lock

Period locking lives as a **"Period Lock" section within `/settings/time-tracking`** (the canonical time settings page, already in nav and owner of the contractor-approval toggle, spec 87). There is no separate `/settings/time/lock` route — that orphan path is folded here so the control is reachable from the settings sidebar.

OWNER/ADMIN can lock all entries before a cutoff date:

```
┌──────────────────────────────────────────────────────────────┐
│  Lock time period                                            │
│                                                              │
│  Lock all time entries before:  [2026-05-31]                 │
│                                                              │
│  This will lock 347 entries across 12 staff members.         │
│  Locked entries cannot be edited without manual unlock.      │
│                                                              │
│  [Cancel]                    [Lock period]                   │
└──────────────────────────────────────────────────────────────┘
```

`POST /api/time/lock-period` — body: `{ cutoffDate: string }` — sets `locked_at = now(), locked_reason = 'period_closed'` on all unlocked entries where `date < cutoffDate`.

---

## UI: Locked Entry Indicators

### Time Reports (`/reports/time`)

Locked entries shown with lock icon, no edit/delete controls:

```
│ 2026-05-28  Website redesign  Dev  3.5h  🔒  INV-0042  |     │
│ 2026-05-28  API integration   Dev  2.0h  🔒  INV-0042  |     │
│ 2026-05-29  API integration   Dev  4.0h  ✏️  —         | ✕   │
```

Lock icon tooltip: "Locked — included in INV-0042" or "Locked — period closed".

### Active Timer

If timer is running on a project with a locked period, no warning shown (active timer is always after the lock cutoff). Only affects past entries.

---

## Staff Approval Locking (Contractor Entries)

Contractor entries (spec 87) have their own approval status. When payout generation locks contractor entries (spec 52):
- Set `time_entries.approval_status = 'locked'`
- Set `time_entries.locked_at = NOW(), locked_reason = 'approved'`

Both fields must be set together — `approval_status = 'locked'` signals the entry is in a finalized payout; `locked_at` is the timestamp of that lock event.

This prevents the contractor from modifying hours post-approval.

---

## Unlock Flow

OWNER can unlock individual entries:

`POST /api/time/:id/unlock` — body: `{ reason: string }` (required, min 10 chars)

Clears `locked_at` and `locked_reason`. Audit log entry (spec 28) includes the unlock reason.

### Reopening payout-locked entries (approval reset)

When the lock originated from a finalized payout (`locked_reason = 'approved'` and `approval_status = 'locked'`, set by spec 52 contractor-payouts — see "Staff Approval Locking" above), clearing `locked_at` alone leaves the entry stuck in the terminal `approval_status = 'locked'` state and it can never be edited or re-approved. To prevent this dead-end:

- `/unlock` MUST also reset `approval_status` from `'locked'` back to `'approved'` whenever `locked_reason = 'approved'`. The entry returns to the approved-but-unbilled state and becomes editable / re-approvable again (per spec 87 `time-approval-workflow`).
- This reset is **only valid when the originating payout bill has been voided** (`payout_bills.voided_at IS NOT NULL`, spec 52 C-9). The handler verifies the entry is no longer linked to a live (non-voided) payout bill; if a live bill still references the entry, `/unlock` returns 409 `payout_active` — void the payout bill first.
- Entries unlocked from `locked_reason = 'invoiced'` or `'period_closed'` carry no `approval_status` change (they were never payout-locked).

```ts
// inside POST /api/time/:id/unlock, after the lock checks:
const updates: Record<string, unknown> = { locked_at: null, locked_reason: null }
if (entry.locked_reason === 'approved') {
  if (await hasLivePayoutBill(entry.id)) {
    return c.json({ error: 'payout_active' }, 409)  // void the payout bill first
  }
  updates.approval_status = 'approved'  // exit the terminal 'locked' state
}
```

---

## API

```
POST /api/time/lock-period
     → lock all entries before cutoff date
       body: { cutoffDate }
       Returns: { lockedCount }
       Requires: OWNER

POST /api/time/:id/unlock
     → unlock single entry; clears locked_at + locked_reason.
       If locked_reason = 'approved', also resets approval_status
       'locked' → 'approved' (requires the originating payout bill
       to be voided; 409 'payout_active' if a live bill still references it).
       body: { reason: string }
       Requires: OWNER

GET /api/time?locked=true|false
    → filter time entries by lock status
      (extends existing list endpoint)
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| `locked_at` column | Not status flag | Timestamp captures when lock occurred; useful for audit; nullable means "unlocked" without adding an extra boolean |
| `locked_reason` enum | Not free text | Programmatic check for "is this entry invoiced?" vs "period closed" — different UI messaging |
| OWNER unlock only | Not self-service | Unlocking already-billed time risks billing discrepancy; OWNER accountability required |
| Period lock as bulk operation | Not per-entry | Most useful for payroll cutoff; staff shouldn't edit last month's hours after close |
