# Adjustments & Rates (the money-rules system) — current-system spec (parity bar for the rebuild)

> This is the system that turns hours into dollars: per-member **cost** and **billout** rates
> (per source hour, post ADR #034), per-project **rate overrides** (absolute $ or signed %,
> project-wide or per-user, resolved by specificity via the `effective_billout_rate` SQL
> function), per-project **billout adjustments** (pct + fixed, display-only per ADR #040),
> and the **stamping / re-stamping** machinery that writes `billout_cost_usd` +
> `billout_amount_usd` onto `time_entries` at write time and rewrites them only via explicit
> actions (`restamp_billout_for_project`, `restamp_billout_for_entry_ids`). The core
> invariant chain is ADR #014 → #039 → #040: per-entry money is locked at write; rate changes
> never silently rewrite history; total-level adjustments never touch per-row stamps.

## User workflows

- **"What do I pay, what do I charge, per person."** Rian sets each team member's
  `cost_rate_usd` (what he pays them per source hour) and owner-only `billout_rate_usd`
  (what he charges the client for that person's hours) on `/team`. Margin = the difference,
  visible on `/projects` and invoices. Origin: "I take Adi's billout hours and bill them to
  plusroi at a different rate… I need to be able to add billout rates for each user" (2026-05-25).
- **"Prompt Victoria gets all work at −20%."** A project-wide percentage override on one
  project, set from the ⚙️ gear on `/projects`, without touching individual entries. Rian
  explicitly asked what happens when rules conflict ("all users @ $20 and then Adi @ $25?")
  and accepted **specificity precedence**: user-specific beats project-wide beats member default.
- **"Knock 10% off this project's total for the client."** A display-only billout adjustment
  (pct and/or fixed $) via the ✏️ pencil on `/projects` — instantly reversible, never rewrites
  entry stamps, so he can experiment with pct-vs-fixed without touching history.
- **"I changed a rate — fix the old entries."** Explicit re-stamp: saving/deleting an override
  re-stamps the project automatically; the bulk **Recalculate** button on `/entries` re-stamps
  an arbitrary selection at current rates. Both report exact row counts (trust in bulk-action
  counts matters enormously to rian — see the 3,274-row incident).
- **Rate-change safety (regression pattern #12):** changing a member's rate must NEVER
  retroactively alter stamped historical entries by itself. Only the explicit actions above do.

## Complete behavior

### Member rates (the defaults)

- **B1.** `team_members.cost_rate_usd` (numeric(10,2)) is the canonical cost basis:
  **USD per source hour** the owner pays that person. `teams.base_rate_usd` and
  `team_members.rate_proportion` are vestigial for money math (ADR #034); proportion feeds
  only the retired Toggl export's `converted_duration_seconds`.
- **B2.** `team_members.billout_rate_usd` (numeric(10,2)) is the default billout rate:
  **USD per source hour** the owner charges the client for that person's work. Owner-only at
  the app layer (ADR #039); rescaled in-place from per-converted-hour to per-source-hour by
  migration `20260524000013_eliminate_proportion.sql`.
- **B3.** NULL semantics: `cost_rate_usd` NULL ⇒ entries stamp `billout_cost_usd = NULL`
  ("cost unknown" in margins). `billout_rate_usd` NULL (and no absolute override) ⇒
  `billout_amount_usd = NULL` at write time.
- **B4.** On `/team`, rates are entered per member (number inputs, step 0.01, min 0, blank →
  NULL, rounded to cents). The billout input renders **only** for owners (`canSeeBillout =
  canTransfer(eu)`); the update action includes `billout_rate_usd` in the payload **only when
  the form field was present**, so a manager-submitted form can't null an existing value.
- **B5.** Special case baked into the migration: Gary (`gary@rian.ca`) is an AI —
  `cost_rate_usd = 0`, billout $25. Zero-cost billable workers are a real, supported case.

### Effective-rate resolution (`effective_billout_rate` / `getEffectiveBilloutRate`)

- **B6.** SQL function `effective_billout_rate(p_team_member_id uuid, p_project_id uuid)
  returns numeric` (STABLE, **security invoker**, latest definition in
  `20260524000012_override_pct.sql`). Precedence — pick exactly ONE candidate row:
  1. user-specific override on this project (`team_member_id = p_team_member_id`),
  2. project-wide override (`team_member_id IS NULL`),
  then resolve the value:
  - candidate has `override_rate_usd` → return it verbatim;
  - candidate has `override_pct` → return `team_members.billout_rate_usd × (1 + pct/100)`;
  - no candidate → return `team_members.billout_rate_usd`.
- **B7.** **Pct always applies to the MEMBER's default rate**, never to another override.
  A user-specific pct rule on a project that also has a project-wide absolute rule uses
  `member_default × (1+pct/100)` — the project-wide rule is ignored entirely (no stacking,
  no chaining). Worked example: member default $25, project-wide −20% ⇒ $20.00/hr.
- **B8.** NULL fall-through: pct × NULL member default = NULL. NULL at every level ⇒ NULL.
  A NULL `p_team_member_id` can still match a project-wide **absolute** override inside the
  SQL (the project-wide branch doesn't require a member) — but see B9.
- **B9.** TS wrapper `getEffectiveBilloutRate(supabase, teamMemberId, projectId)` in
  `lib/teams.ts` returns `null` immediately when `teamMemberId == null` (never calls the RPC),
  else `Number(rpc result)` or null. So on the **write path** member-less entries never get a
  billout amount; the SQL-level B8 nuance only surfaces via `restamp_billout_for_project`
  (which calls the function directly with `te.team_member_id`, possibly NULL).
- **B10.** Pct bounds are loose by design: any pct ≥ −100 is accepted (−100 ⇒ $0/hr; +200 ⇒
  triples). "Garbage in, garbage out."
- **B11.** Because the function is `security invoker` and `project_rate_overrides` RLS is
  **owner-only** (see Permissions), a **manager-run write path (e.g. Adi's CSV import, Adi's
  per-row edit) resolves the rate WITHOUT seeing any overrides** — it silently falls through
  to the member default. Owner-run re-stamps later correct those rows. This is a shipped wart,
  not a design goal; the rebuild must pick one semantic (see Rebuild deltas).

### Project rate overrides (stamped rules)

- **B12.** Table `project_rate_overrides(id, org_id, project_id, team_member_id?,
  override_rate_usd?, override_pct?, notes, created_at, updated_at)`.
  `team_member_id` NULL = project-wide ("All hours on this project"); SET = user-specific.
  `notes` exists in the schema but **no UI reads or writes it**.
- **B13.** XOR constraint `project_rate_overrides_one_kind`: exactly one of
  `override_rate_usd` / `override_pct` is non-NULL. Column check `override_rate_usd >= 0`.
  `override_pct` is numeric(7,4), signed (negative = discount).
- **B14.** Unique index `project_rate_overrides_unique` on
  `(project_id, coalesce(team_member_id, '00000000-0000-0000-0000-000000000000'))` — at most
  one project-wide rule and one rule per (project, member).
- **B15.** Upsert (`upsertProjectRateOverride` in `app/(app)/projects/actions.ts`) is a manual
  find-or-insert (PostgREST lacks NULL-safe multi-column upsert): SELECT by `project_id` +
  NULL-safe `team_member_id`; UPDATE both value columns (one set, one NULL) if found, else
  INSERT. Form fields: `project_id` (required), `team_member_id` ('' → NULL),
  `override_kind` = `'rate'` (default) | `'pct'`, `override_value` (required; strips `[$,%]`),
  `_return_url`. pct stored rounded to **4 decimals**; rate rounded to cents.
- **B16.** Validation messages (exact): "Override value is required. To remove a rule, use
  Delete instead." / "Override value must be a number." / "Percentage discount cannot be less
  than -100% (which is free)." / "Absolute rate must be non-negative." / "Missing project id."
- **B17.** Delete (`deleteProjectRateOverride`): loads the row first (to learn the re-stamp
  scope), deletes by id, then re-stamps the same (project, member?) scope. "Override not
  found." when missing.
- **B18.** **Every override save AND delete triggers an automatic re-stamp** of affected
  entries via `restamp_billout_for_project`. Scope: project-wide rule change re-stamps ALL
  entries on the project (`p_team_member_id = NULL` — deliberately, "even users whose entries
  fall through to the new project default"); user-specific rule change re-stamps only that
  member's entries on the project.
- **B19.** Success banner (exact): `Saved override · re-stamped N entries.` /
  `Deleted override · re-stamped N entries.` (singular "entry" when N = 1), delivered via
  `?info=` on the `_return_url` (must start with `/projects`, else falls back).
- **B20.** If the re-stamp throws AFTER the override row was committed:
  "Saved the override but failed to re-stamp some entries: …" (or "Deleted the override but…").
  **No rollback** — the rule is live, stamps are stale until the next re-stamp/Recalculate.

### Stamping (write-path — where the effective rate is consumed)

- **B21.** Every entry-write path stamps both money columns at write time:
  `billout_cost_usd = round(duration_seconds/3600 × cost_rate_usd, 2)` (NULL if either input
  NULL) and `billout_amount_usd = round(duration_seconds/3600 × effective_rate, 2)` (NULL if
  rate NULL). Paths: **CSV import confirm** (`import/resolve/actions.ts`, with a per
  (member, project) `effRateCache` Map so the RPC isn't hit once per row; uses
  `effectiveRate ?? member.billout_rate_usd` as a redundant fallback), **manual entry create**
  (`createEntry`), **per-row edit** (`updateEntry` — re-stamps from the picked member +
  resolved project), **per-row 🔄 Re-resolve** (`resolveEntry` — looks up the member by
  `source_user_email` in the batch uploader's team, then stamps), and **bulk team-member
  reassignment** (`bulkUpdateFields` → `set_team_member_for_entry_ids` +
  `restamp_billout_for_entry_ids`).
- **B22.** Changing a member's `cost_rate_usd`/`billout_rate_usd` on `/team` re-stamps
  **nothing**. Historical stamps stay until an explicit re-stamp (ADR #014; regression
  pattern #12).

### Explicit re-stamp actions (the only sanctioned stamp rewrites)

- **B23.** `restamp_billout_for_project(p_org_id, p_project_id, p_team_member_id default
  null) returns bigint` (single SQL UPDATE, `security invoker`; latest definition in
  `20260524000013`). Touches **only `billout_amount_usd`** (not cost). Row scope:
  `org_id = p_org_id AND project_id = p_project_id AND (p_team_member_id IS NULL OR
  te.team_member_id = p_team_member_id) AND duration_seconds IS NOT NULL`. Formula:
  `round(duration_seconds/3600 × coalesce(effective_billout_rate(te.team_member_id,
  te.project_id), 0), 2)`. Returns count of rows updated.
- **B24.** ⚠️ **`coalesce(…, 0)`: re-stamp writes `$0.00` where the write path would write
  NULL.** A member with no billout rate (and no absolute override) has NULL
  `billout_amount_usd` after import but **0.00** after any project re-stamp / Recalculate.
  NULL-member entries on a project with an absolute project-wide override get billed AT that
  override rate by `restamp_billout_for_project` (per B8) — but are skipped entirely by
  `restamp_billout_for_entry_ids` (B25). Inconsistent; rebuild must unify (NULL is the honest
  value; see Rebuild deltas).
- **B25.** `restamp_billout_for_entry_ids(p_org_id, p_entry_ids uuid[]) returns bigint`
  (migration `20260525000001`). Touches **BOTH `billout_cost_usd` AND `billout_amount_usd`**
  for the given ids, via an inner join to `team_members` — **rows with NULL `team_member_id`
  are not touched at all** (the migration comment claiming they "get NULL cost / billout" is
  wrong; the join excludes them). Cost: NULL when `duration_seconds` or `cost_rate_usd` NULL;
  amount: `coalesce(effective_rate, 0)` (same $0.00 wart as B24). Does NOT re-resolve
  `team_member_id` from `source_user_email` (that's per-row Re-resolve).
- **B26.** ⚠️ The migration header comment says the returned count is "rows whose cost OR
  billout actually changed" — **the SQL actually counts every row matched by the UPDATE**
  (the RETURNING clause has no change filter). UI copy ("Recalculated … N entries") reflects
  rows scanned/updated, not rows whose dollars moved.
- **B27.** **Neither re-stamp function skips invoiced/locked rows.** There is no
  `invoice_id IS NULL` / `transferred_at IS NULL` predicate in either SQL function. An
  override save on a project whose old entries sit on **paid invoices** rewrites those
  entries' `billout_amount_usd` and silently changes the historical invoice's displayed sums.
  (The invoice's `manual_total_usd`, if set, is unaffected.) Confirmed against source —
  resolves the ambiguity flagged in the old feature-doc extraction.
- **B28.** Bulk **Recalculate** on `/entries` (`bulkRecalculate`): permission is
  `canManageImports` (owner OR manager — NOT owner-only). Explicit selection posts `id`
  fields (view-as guarded via `guardEntryIds`); `all_matching=1` re-runs the page filter via
  `readBulkFilter` + `applyBulkFilter` to collect ids. Zero ids → "No entries selected to
  recalculate." Calls `restamp_billout_for_entry_ids` once with the full id array. **No lock
  guard** — a manager can re-stamp invoiced rows through Recalculate even though per-row edit
  would refuse (`canEditEntry` locks invoice-attached rows to owners). Known wart.
- **B29.** Recalculate UX: confirm dialog "Recalculate cost + billout for N entries using
  current rates and project overrides?"; success redirect `/entries?recalculated=N` renders
  banner "✓ Recalculated cost + billout for N entry/entries using current rates."
- **B30.** Pending-state UX: the override add/update submit is a `SubmitButton`
  (`components/submit-button.tsx`, `useFormStatus`) — disables itself and swaps the label to
  **"Re-stamping entries…"** while the action (save + single-UPDATE re-stamp) runs. Default
  pending label elsewhere is "Saving…". Born from rian's "not clear if it is [updating], we
  should have some sort of indicator". The single-query SQL function replaced an
  N-HTTP-PATCH loop that took ~10s for a 1000-row project.

### Project billout adjustments (display-only knob, ADR #040)

- **B31.** Columns on `projects`: `billout_adjustment_pct` numeric(7,4) (signed %, e.g. −10)
  and `billout_adjustment_amount` numeric(12,2) (signed $, e.g. −50.00). NULL ≡ 0 (no
  adjustment). Migration `20260524000008_project_billout_adjustments.sql` — whose header
  comment cites "ADR #036" by **editing error**; the governing ADR is **#040**.
- **B32.** Formula, applied at read time inside the summary RPCs (`project_summary`,
  `summary_by_project`): `adjusted = raw_billout × (1 + coalesce(pct,0)/100) +
  coalesce(amount,0)`. **Pct first, then fixed amount.** Never alters
  `time_entries.billout_amount_usd`.
- **B33.** The fixed amount is applied **once per displayed window** (this-month, prev-month,
  all-time each get the full `+ amount`) — deliberately "pragmatic, not perfectly summable
  across windows" (migration comment). Window columns in `summary_by_project`:
  `this/prev/all_billout_adj_usd`; in `project_summary`: `billout_adjusted_usd` +
  passthrough `billout_adjustment_pct` / `billout_adjustment_amount`.
- **B34.** Edited via the ✏️ pencil on the `/projects` Income cell → inline yellow
  (`#fff8e1`) form → `updateProjectIncome`. One form writes all three of
  `income_usd` / `billout_adjustment_pct` / `billout_adjustment_amount` on every save (blank
  = clear = writes NULL). Saving **re-stamps nothing**. Owner-only (`canTransfer`); error:
  "Only an owner can edit project income."
- **B35.** `updateProjectIncome` parse rules: income strips `[$,]`, must be finite ≥ 0
  ("Income must be a non-negative number, or empty to clear."), cents-rounded. Adjustments
  parse via `readOptionalNumber` (strips `[$,%]`; "Adjustment % must be a number, or empty to
  clear." etc.), **rounded to 2 decimals** — note the precision inconsistency vs
  `override_pct`'s 4 decimals. Entity resolution walks operator → client → project by
  case-insensitive `ilike` name match within the org; failures: `Operator "X" not found.` /
  `Client "X" not found under operator "Y".`. NULL anywhere in the tuple →
  "Cannot set income on a project group with missing operator, client, or project. Fix the
  entries first." If the `projects` row is missing but any value is non-null, a defensive
  INSERT creates it (backfill-era leftover).
- **B36.** Where the adjustment shows: `/projects` Billout column shows the **adjusted**
  total in bold with a small muted second line `raw · adjTxt` (adjTxt = signed % and/or
  signed $ joined by space, e.g. `+10% +$50`) and tooltip `raw $X + adjTxt`; when no
  adjustment, shows adjusted (= raw) plain, `—` when null/0. `/` (Summary) billout cells use
  `*_billout_adj_usd`. **Margin ignores adjustments AND billout entirely** — /projects margin
  = `income_usd − cost`. **Invoices ignore adjustments** — invoice pages and the PlusROI
  paste block aggregate raw per-entry stamps (ADR #040's acknowledged modeling gap: /projects
  total and the invoice total for the same project can disagree).

### The overrides UI (⚙️ panel on /projects)

- **B37.** Gear ⚙️ link on the Income cell (owner + row has `project_entity_id`) toggles
  `?edit_rates=<projectKey>`; a green superscript shows the count of existing rules. Mutually
  exclusive with `?edit_income=` (each link deletes the other param). Opens an indigo
  (`#eef2ff`) full-width `RatesEditRow`: header "Rate overrides for {operator / client /
  project}. Re-stamps affected entries on save."
- **B38.** Rules table sorted: project-wide rule first, then by member display name. Scope
  column: *"All hours on this project"* (em) vs member name + muted email. Rate column:
  `$X.XX/hr` for rate-kind; `+N% on rate` / `-N% on rate` for pct-kind (tooltip: "Percentage
  adjustment on top of the user's normal billout rate. Negative = discount."); `—` if both
  NULL (defensive). Per-rule Delete button. Empty state: "No overrides set. Entries on this
  project bill at each team_member's default rate."
- **B39.** Add/update form: Scope select (`team_member_id`, default "" = "All hours on this
  project", options labeled `display_name (team)` from the org-wide `team_members` roster
  ordered by display_name); Kind select (`override_kind`: "Absolute rate ($/hr)" |
  "Adjustment (% of user's rate)"); Value input (`override_value`, placeholder
  "e.g. 30.00 or -20", title "For rate: a non-negative dollar amount per billout hour. For
  pct: signed % (-20 = 20% discount, +10 = 10% markup)."). Submit = "Add / update rule",
  pending "Re-stamping entries…". Footer: "User-specific rule wins over 'all hours'. Both
  fall back to the team_member's default billout rate."

## Data model

| Object | Columns / semantics |
|---|---|
| `team_members` | `cost_rate_usd numeric(10,2)` (per-source-hour cost; NULL = unknown), `billout_rate_usd numeric(10,2)` (per-source-hour default billout; owner-only at app layer; NULL = no billout), `rate_proportion` (Toggl-export legacy only), `consolidate_as`, `is_active`. |
| `teams.base_rate_usd` | **Vestigial** since ADR #034 (retained in schema; `lib/teams.ts getRateForTeamMember` still reads it but has **no remaining callers**). |
| `projects` | `income_usd numeric?`, `billout_adjustment_pct numeric(7,4)?`, `billout_adjustment_amount numeric(12,2)?` — all owner-edited, NULL = unset. RLS on `projects` hides `income_usd` from managers (comes back NULL through the RPC) and restricts UPDATE to owners. |
| `project_rate_overrides` | `id uuid pk`, `org_id fk→organizations cascade`, `project_id fk→projects cascade`, `team_member_id fk→team_members cascade NULLABLE`, `override_rate_usd numeric(10,2)? check ≥0`, `override_pct numeric(7,4)?`, `notes text` (unused), `created_at`, `updated_at` (trigger `set_updated_at`). XOR check `project_rate_overrides_one_kind`. Unique `(project_id, coalesce(team_member_id, uuid-zero))`. |
| `time_entries` | `billout_cost_usd numeric` and `billout_amount_usd numeric(12,2)` — **locked-at-write stamps** (ADR #014/#039); `duration_seconds` is the hours basis (post #034); `team_member_id` + `project_id` are the resolution keys. |
| SQL functions | `effective_billout_rate(p_team_member_id, p_project_id)` (STABLE, security invoker); `restamp_billout_for_project(p_org_id, p_project_id, p_team_member_id?)`; `restamp_billout_for_entry_ids(p_org_id, p_entry_ids uuid[])` (both VOLATILE, security invoker, return bigint counts). Adjustments applied read-time inside `project_summary` and `summary_by_project`. |
| TS helpers | `lib/teams.ts`: `getEffectiveBilloutRate` (wraps the RPC, null-shortcircuit on null member), `getCostRateForTeamMember` (currently uncalled — actions select `cost_rate_usd` inline), `getRateForTeamMember` (vestigial). `app/(app)/projects/actions.ts`: `restampEntriesForProject` (wraps the project RPC, throws on error, returns Number(count)). |

## Permissions

- **`canTransfer(eu)`** (`lib/permissions.ts`: super_admin-not-viewing-as OR
  `org_role === 'owner'`) — the "owner" gate for ALL money-rules writes and visibility:
  edit project income + adjustments (`updateProjectIncome`), create/update/delete rate
  overrides (`upsertProjectRateOverride` / `deleteProjectRateOverride`), see the Billout /
  Income / Margin columns on `/projects`, see the Billout column on `/entries`, see + edit
  `billout_rate_usd` on `/team` (aliased there as `canSeeBillout`).
- **`canManageImports(eu)`** (owner OR manager) — bulk **Recalculate** and bulk reassignment
  (`bulkRecalculate`, `bulkUpdateFields`), team-member CRUD (with the billout-field-absent
  payload guard, B4).
- **`canEditEntry(eu, entry)`** — per-row stamping paths (edit / re-resolve / delete):
  locked rows (`invoice_id` OR legacy `transferred_at` set) require `canTransfer`; unlocked
  rows require `canManageImports`.
- **RLS (data layer):** `project_rate_overrides` SELECT **and** ALL are owner-only
  (`is_org_owner(org_id) OR is_super_admin_caller()` — migration `20260524000009`). ⚠️ The
  legacy feature doc (`docs/features/adjustments.md`) claims org-wide read with app-layer-only
  gating — **the migration contradicts that; RLS is owner-only. Code wins.** By contrast
  `team_members.billout_rate_usd` IS org-readable at RLS level (app-layer gate only,
  ADR #039). Under view-as (ADR #043 JWT swap), an owner viewing-as a manager loses both the
  UI gates and the RLS visibility.
- Consequence of owner-only override RLS + security-invoker functions: manager-run stamping
  ignores overrides (B11), and manager-run Recalculate re-stamps at member defaults —
  potentially **overwriting owner-stamped override rates with default-rate values**.

## Edge cases & error behavior

- **Save-then-restamp is not atomic** (B20): override committed, re-stamp failed ⇒ live rule
  + stale stamps; error message tells the user, but recovery is manual (re-save or Recalculate).
- **Race on manual upsert** (B15): two concurrent saves of the same (project, member) scope
  can both pass the SELECT and one INSERT hits the unique index — surfaces as a raw Postgres
  error message, no friendly translation.
- **Re-stamp includes invoiced rows** (B27) — changes historical invoice sums; no warning.
- **Recalculate lock hole** (B28) — managers can re-stamp locked rows they can't edit.
- **$0.00 vs NULL divergence** (B24/B25) — write path stamps NULL for no-rate; re-stamps
  coalesce to 0.00; entry-ids re-stamp skips NULL-member rows entirely while project re-stamp
  processes them.
- **Count semantics** (B26) — reported counts are rows-updated, not rows-changed, despite the
  migration comment.
- **Manager-invoker resolution** (B11) — overrides silently ignored on manager write paths.
- **No effective-date scoping and no history**: overrides apply to ALL past + future entries;
  re-stamp destructively overwrites `billout_amount_usd` with no audit trail. Mid-engagement
  rate changes can't be expressed (the old doc flags this; `compensation_terms.effective_from/to`
  is the rebuild answer).
- **No stacking** (B7): user-specific and project-wide rules never combine; to "stack",
  model the final rate as a single user-specific rule.
- **Pct of NULL default = NULL** (B8): a pct override on a member with no `billout_rate_usd`
  yields no billout — use an absolute override for those members.
- **Adjustment guard rails**: income must be ≥ 0; adjustments may be any sign; tuple with any
  NULL part is rejected ("Fix the entries first."); `ilike` name-walk means entity names
  containing `%`/`_` can mis-match (standing wart shared with /projects/manage).
- **Return-URL guard**: `_return_url` must start with `/projects` (income form) or is parsed
  through `new URL(ret, 'http://x')` → `pathname + search` (override actions) — open-redirect
  protection.
- **Owner-fail messages** (exact): "Only an owner can edit project income." / "Only an owner
  can edit rate overrides." / "Only an owner can delete rate overrides." /
  "You do not have permission to recalculate entries."

## UI notes

- Current placement: everything hangs off the `/projects` summary table — ✏️ pencil (Income
  cell) for income + adjustments, ⚙️ gear (with green rule-count superscript) for rate
  overrides; member default rates live on `/team`. Rian's stated placement preference:
  "the rate rules are probably best implemented in the team section and the projects in the
  project area" — the shipped app put per-project rules on /projects with a member Scope
  select instead; nobody complained, but a rebuild could honor the original split.
- Inline editors are full-width colored rows (yellow `#fff8e1` = income edit, indigo
  `#eef2ff` = rates panel), mutually exclusive via URL params `edit_income` / `edit_rates`
  (`scroll={false}` links — page-jump-on-edit was an explicit complaint).
- Taste-profile fit (transcript §17): **progress feedback is mandatory** — the
  "Re-stamping entries…" SubmitButton exists because rian flagged actions with no indicator;
  **counts must match reality** (banner reports exact re-stamped counts; keep them honest —
  see B26's rows-updated vs rows-changed nit); **icons over words** (✏️/⚙️), **detail on
  hover** (raw billout + adjustment string on hover/tooltip rather than cluttering the cell),
  owner-only numbers tucked away from managers, searchable-combobox-style pickers for
  anything with many options.
- Known cosmetic bug to not reproduce: `/projects` `colCount` is one short for owners
  (3 owner columns added, count adds 2) so expanded/edit rows are one column narrow.

## Rebuild deltas

- **`compensation_terms` replaces the default-rate columns** (02-domain-model.md §2): one
  table generalizes `cost_rate_usd`, `billout_rate_usd`, and the vestigial org/team base
  rates. Cost and billout become terms on **two engagement hops** (Adi→BW subcontract =
  today's cost; BW→PlusROI partnership = today's billout). `project_id` NULL = engagement
  default, SET = project-specific terms; `effective_from/to` gives append-only **rate
  history** — fixing the no-date-scoping / no-audit wart natively.
- **`project_rate_overrides` does NOT flatten into terms** (02-domain-model.md §2
  `rate_overrides`; 07-judgment-calls.md §25): project-wide NULL-member rows and signed pct
  rows are resolution-time rules, not static per-engagement terms — R1 carries the table
  near-verbatim as the dedicated `rate_overrides` table (absolute XOR pct, same specificity
  precedence: user-specific > project-wide > member default). R4's money-engine pass decides
  whether terms subsume it. Per-user-per-project specificity precedence must be preserved in
  the resolution logic.
- **Stamping centralizes** in `services/money/stamping.py` (04-architecture.md "Money
  paths") — the ONLY place stamps are computed; re-stamp stays an explicit, scoped,
  count-reporting action. ⚠️ 04-architecture says re-stamp "skips invoiced rows (current
  behavior preserved)" — **the skip is NOT current behavior** (B27); it's a deliberate
  improvement and should be specified as such. All money becomes decimal end-to-end (kills
  the `number | string` unions).
- **Adjustments + income carry over as-is on `projects`** (02-domain-model.md §3: "carried
  over … until the invoice engine (R4) supersedes them", display-only per ADR #040). The
  /projects-vs-invoice total mismatch is resolved later by invoice line-item adjustments
  (invoices + invoice_lines model), not by stamping.
- **Phase-outs** (00-vision-and-scope.md): `rate_proportion`, `teams.base_rate_usd`,
  `converted_duration_seconds`, Toggl entirely — never implemented in the rebuild.
  Supabase RLS is replaced by service-layer authz (`can()`/`scope()`), which must enforce
  billout confidentiality **at the data layer** and give managers/imports a single consistent
  rate-resolution semantic (fixes B11 — no more invoker-dependent math).
- 07-judgment-calls.md §12: per-source-hour rates carried verbatim; zero-cost billable
  workers (Gary) are first-class, tested explicitly.

## Test specs

Carried IDs (ADJ-*) come from `docs/features/adjustments.md` (all were 🟡 = specified, never
automated); new IDs continue the series.

- T-ADJ-001: given a user-specific override and a project-wide override on the same project / when resolving that member's rate / then the user-specific row wins. *(carries ADJ-001)*
- T-ADJ-002: given only a project-wide override / when resolving any member on the project / then the project-wide row applies. *(carries ADJ-002)*
- T-ADJ-003: given no overrides / when resolving / then `team_members.billout_rate_usd` is returned. *(carries ADJ-003)*
- T-ADJ-004: given member default $25 and project-wide `override_pct = -20` / when resolving / then effective rate = $20.00 (pct applies to the member default). *(carries ADJ-004)*
- T-ADJ-005: given entries already stamped / when an override is saved or deleted / then affected entries' `billout_amount_usd` are re-stamped and the reported count equals rows touched. *(carries ADJ-005)*
- T-ADJ-006: given a row with both `override_rate_usd` and `override_pct` set / when inserting / then the `project_rate_overrides_one_kind` CHECK rejects it. *(carries ADJ-006)*
- T-ADJ-007: given a user-specific pct rule AND a project-wide absolute rule / when resolving that member / then effective = member_default × (1+pct/100); the project-wide rule is ignored (no chaining, B7).
- T-ADJ-008: given a pct override and a member with NULL `billout_rate_usd` / when resolving / then the effective rate is NULL (B8).
- T-ADJ-009: given `override_value = -150` with kind pct / when saving / then rejected with "Percentage discount cannot be less than -100% (which is free)." (B10/B16).
- T-ADJ-010: given a second save for the same (project, member) scope / when upserting / then the existing row is updated (no duplicate; unique index holds) (B14/B15).
- T-ADJ-011: given a project-wide rule change / when re-stamping / then ALL entries on the project are re-stamped, including other members' rows; a user-specific change re-stamps only that member's rows (B18).
- T-ADJ-012: given entries attached to a paid invoice / when the project is re-stamped / then those rows' `billout_amount_usd` change too (current behavior, B27) — **rebuild target: they are skipped and reported separately** (04-architecture).
- T-ADJ-013: given an entry whose member has no billout rate / when write-path stamping / then `billout_amount_usd` = NULL; when re-stamped via either RPC / then it becomes 0.00 (current, B24) — **rebuild target: NULL both ways**.
- T-ADJ-014: given entry ids including rows with NULL `team_member_id` / when `restamp_billout_for_entry_ids` runs / then NULL-member rows are untouched and excluded from the count (B25).
- T-ADJ-015: given a Recalculate over N selected rows where only k rows' dollars change / then the current count reports N (rows updated), not k (B26) — pin whichever semantic the rebuild picks and make the copy match.
- T-ADJ-016: given a manager (or owner in view-as-manager) runs import/edit on a project with overrides / then current behavior stamps at member default (overrides invisible under invoker RLS, B11) — **rebuild target: resolution is identical regardless of actor**.
- T-ADJ-017: given a manager and locked (invoiced) rows / when bulk Recalculate is invoked on them / then current behavior re-stamps them (B28) — rebuild target: lock respected.
- T-ADJ-018: given `billout_adjustment_pct = 10` and `_amount = 50` on a project with raw billout $1,000 / when `project_summary` runs / then `billout_adjusted_usd` = 1,000 × 1.10 + 50 = $1,150; per-entry stamps unchanged (B31–B32).
- T-ADJ-019: given a fixed adjustment / when viewing this-month, prev-month, and all-time windows / then EACH window includes the full fixed amount (B33).
- T-ADJ-020: given an adjustment edit saved with all fields blank / then all three of income/pct/amount are cleared to NULL (B34).
- T-ADJ-021: given adjustments set / then /projects margin = income − cost (unaffected), and invoice totals/paste block use unadjusted per-entry sums (B36, ADR #040).
- T-ADJ-022: given a member rate change on /team / then zero entries are re-stamped (B22).
- T-ADJ-023: given a manager-rendered team form (no billout input) / when submitted / then an existing `billout_rate_usd` is NOT nulled (payload-absent guard, B4).
- T-ADJ-024: given cost_rate 0 and billout 25 (Gary) / when stamping / then cost stamps $0.00 (not NULL) and billout stamps normally (B5).
- T-ADJ-025: given a non-owner calls `upsertProjectRateOverride` / `deleteProjectRateOverride` / `updateProjectIncome` / then rejected with the exact owner-only messages; and `project_rate_overrides` rows are invisible to manager reads at the data layer (Permissions).
- T-ADJ-026: given the override save's re-stamp fails after commit / then the error surfaces as "Saved the override but failed to re-stamp some entries: …" and the rule remains live (B20) — rebuild target: atomic.
