# Project financials (income + billout adjustments)

**Status:** in production
**Lives at:** `/projects` (owner-only inline editor on the Income cell) ·
`PATCH /api/projects/{id}/financials` · `app/services/projects_admin.py` ·
`app/routers/projects.py` · frontend `features/projects/ProjectsPage.tsx`
(+ `financials.ts`), `lib/api.ts`
**Spec:** `/srv/apps/work/docs/rebuild/01-current-system/features/projects-and-entities.md`
(B21–B23) + `adjustments-and-rates.md` (B31–B36); ADR #040 (display-only)

## Summary
An owner sets a project's **income** (what Bowden Works charges the end
client) and two **display-only billout adjustments** — a signed percentage
and a signed fixed amount — inline on the `/projects` rollup. One save
writes all three; reporting applies the adjustment at read time so the
row's billout-adjusted total and margin update on the next fetch. Nothing
is re-stamped.

## Why
Carried verbatim from the legacy `updateProjectIncome` server action:
rian records `income_usd` plus optional billout adjustments per project so
`/projects` shows per-project margin, and he can experiment with pct-vs-
fixed discounts without touching entry history ("knock 10% off this
project's total for the client" — 2026-05-25). This was the one
genuinely-unbuilt tier-A feature at the end of M8, closed in v0.9.1.

## Behavior
- **One owner-only write, three fields.** `income`,
  `billout_adjustment_pct`, `billout_adjustment_amount`. Each is optional;
  a **blank field CLEARS** its column (parity B23/B34 — a save fully
  replaces the project's financials).
- **Income** parses `$`/`,` away, must be a finite number **≥ 0** (else
  "Income must be a non-negative number, or empty to clear."), rounds to
  cents. It carries the tenant currency (CAD, 07 #21).
- **Adjustments** parse `$`/`,`/`%` away, may be **any sign**, round to 2
  decimals (else "Adjustment % / Adjustment $ must be a number, or empty
  to clear."). pct and amount are **independent and COMBINE** at read
  time: `adjusted = raw × (1 + pct/100) + amount` (adjustments-and-rates
  B32). There is **no XOR** between them (see Constraints).
- **Display-only, never stamped (ADR #040).** The write touches only
  `projects` columns and re-stamps no `time_entries`. `reporting/projects`
  and `reporting/summary` recompute billout-adjusted + margin at read
  time, so the values flow straight into the rollup on the next fetch.
- **Audited.** Every save writes an `audit_log` row
  (`action="projects.update_financials"`, `entity_type="project"`) with a
  complete before-image of the four financial fields.
- **Inline editor.** Owner-only pencil on the Income cell opens a small
  popover (income / adj % / adj $). Save is **pessimistic**: the row does
  not change optimistically — on success the `/projects` query is
  invalidated and the table refetches, so income, billout-adjusted and
  margin all re-render from authoritative numbers (M5 verified-feedback
  pattern). Blank clears; Esc / Cancel closes; the Save button shows
  "Saving…" while in flight and is disabled until the draft is dirty.

## Constraints & edge cases
- **NOT XOR (deliberate, ADR #010).** The orchestrator brief said "pct
  XOR amount"; the parity bar overrules it. T-ADJ-018 sets BOTH, the
  migrated "Site Rebuild" project carries pct 10 AND amount 250, and
  neither the legacy nor the R1 `projects` table has an XOR CHECK on those
  columns. The only XOR in the system is `rate_overrides` (rate XOR pct) —
  a separate feature (team admin).
- **income⇔currency pairing IS enforced.** The real R1 CHECK is
  `ck_projects_income_currency_pair`. The service always sets
  `income_currency` when income is present and NULL when cleared, so the
  pair can never violate the CHECK (even when an adjustment is kept while
  income is cleared).
- **"Fix the entries first" is structural.** Financials attach to a
  project *entity id*, so an unassigned/"(missing)" rollup group has no
  edit surface — the UI omits the pencil, and the endpoint answers a clean
  `PROJECT_NOT_FOUND` (never a silent no-op). This satisfies the legacy
  NULL-tuple guard (T-PRJ-029) without a text-tuple resolution walk.
- **/projects vs invoice totals can still disagree** — the adjustment is
  display-only; invoices aggregate raw per-entry stamps (ADR #040's known
  modeling gap, resolved later by the R4 invoice engine).
- No schema change / migration — the columns shipped in the M2 schema
  (alembic 0003) and reporting already consumed them; only the write path
  was missing.

## Permissions
- **Owner-only**, gated on `can_transfer` (the money-visibility capability;
  identical R1 truth table to `can_see_income`). Message: "Only an owner
  can edit project income." Manager / worker / none are refused (403)
  **before any project read**. Not a `services/money/` change (no stamp
  touched), so outside the single-writer money lock.

## Open considerations
- Currency is fixed to the tenant default (CAD) in R1; a per-project
  billout currency lands with the R4 invoice engine.
- The adjustment is intentionally unbounded (no −100% floor, unlike a rate
  override) — it is a display knob, "garbage in, garbage out".

## Tests
- **T-ADJ-020** — blank save clears income + both adjustments to NULL
  (`tests/reporting/test_project_financials.py::test_t_adj_020_blank_clears_all`).
- **T-PRJ-028** — income "1,234.50" / pct "-10" / amount "5" round-trip +
  clear (`::test_t_prj_028_income_and_adjustments_parse`;
  `tests/routers/test_projects_financials_api.py::test_owner_financials_round_trip`).
- **T-PRJ-029** — unassigned/unknown project has no write surface →
  `PROJECT_NOT_FOUND`
  (`::test_t_prj_029_unassigned_group_has_no_write_surface`,
  `::test_unknown_project_404`).
- Adjustment-math parity vs the old formula + never-stamped
  (`::test_adjustment_math_parity_and_never_stamped`); pct+amount coexist
  (`::test_pct_and_amount_coexist_not_xor`); income⇔currency pairing
  (`::test_income_currency_pairing_holds_on_clear`); owner-only 403 matrix
  (`::test_owner_only_403_matrix`, `::test_migrated_manager_adi_is_refused`,
  `::test_manager_forbidden_structured`); audit before-image
  (`::test_save_writes_audit_before_image`); validation
  (`::test_income_rejects_negative_and_nonnumeric`,
  `::test_negative_income_422`). Frontend pure logic:
  `features/projects/financials.test.ts` (12).

## Changelog
- 2026-07-08 (v0.9.1) — Feature shipped. `PATCH /api/projects/{id}/
  financials` + `services/projects_admin.py` + the /projects inline
  editor. Closes tier-A T-ADJ-020 / T-PRJ-028 / T-PRJ-029. ADR #010.
