# PlusROI books (schema + sheet importer + settlement + Books UI)

**Status:** in production. **PlusROI is APP-OWNED since 2026-07-12**
(`books_owner="app"`, ADR #021): the nightly leaves its books untouched
and in-app writes are unlocked; the sheet importer now only serves a
sheet-owned (re)seed. The read-only-era notes below are kept for the
sheet-owned mode, which any tenant can be reverted to
(`scripts/flip_books_owner.py --to sheet --confirm-wipe`).
**Lives at:** `alembic/versions/0007_plusroi_books.py`,
`app/models/expense.py`, `app/models/invoice.py` (additive),
`app/services/migrate/sheet_books.py` (the transform step),
`books-import/source/` (the sheet's CSV export),
`app/services/money/settlement.py` + `app/routers/settlement.py` (the
Monthly Split engine), `app/services/books.py` + `app/routers/books.py`
(the ledger lists + meta), `frontend/src/features/books/` (the three pages)
**Spec:** `/srv/apps/work/docs/rebuild/08-current-tooling.md` §1–§4, §9

## Summary

PlusROI (rian's 50/50 revenue-share partnership with Rob Cooper) runs its
books on a Google Sheet: an **Invoices** ledger (receivables), an
**Expenses** ledger (payables), a client/project registry, and a monthly
settlement. `with` is replacing that sheet. This feature lands the DB schema
those ledgers need and the transform that imports the sheet's full CSV export
into the PlusROI tenant's books — reconciled row-for-row and rebuilt nightly.

## Why

Dated record (rian, via 08-current-tooling.md, 2026-07-07): the PlusROI side
of the business is run entirely on the "PlusROI + Bowden Works Bookkeeping"
Google Sheet. The rebuild absorbs it in R2–R4. This slice is the **money
substrate** — the ledgers + registries — not the settlement math (R4) or any
UI. The books are kept in **CAD** with realized-rate conversion of USD
receipts at invoice level, the original face value retained (08 §2).

## Behavior

- **Schema (alembic 0007), all additive so the diff harness stays green:**
  - `invoices` gains nullable enrichment columns: `fee`, `commission_pct`,
    `commission_amount`, `commission_party_id`, `settlement_month`
    (first-of-month — the sheet's "BW Invoice" tag), `relevant_period`,
    `original_amount` + `original_currency` (the USD face, kept only when the
    receipt was USD), `invoice_timing`, `billing_type`, `external_id` (the
    sheet "Invoice ID" / Wave number — **not** unique; the sheet dupes),
    `due_date`. The status CHECK widens to the sheet lifecycle
    (`pending_approval`/`ready`/`sent`/`paid`/`cancelled`/`delayed`) while
    keeping R1's `open`/`sent`/`paid`.
  - `expenses` — NEW payables journal. Either partner fronts an expense
    (`paid_by_party_id` = PlusROI or Bowden Works party); `source` marks
    sheet-imported rows vs future `manual` app entries.
  - `client_profiles` — per-tenant commercial defaults (currency, terms,
    default commission, credit limit, contacts, the ⛔ `archived` flag).
  - `project_billing` — the 1:1 billing profile for a project.
- **The transform** (`sheet_books.step_sheet_books`) runs as a step in
  `transforms.run_all`, AFTER `step_comments`, INSIDE the same transaction:
  - **Tenant + owners:** get-or-create the PlusROI tenant (settings
    `default_currency=CAD`, `bookkeeping_pct=3.0`, `books_owner="sheet"`
    (ADR #021 — supersedes `parallel_run`; a first import is always
    sheet-owned), `partner_split=0.5`); person parties + `official_partnership` engagements
    for rian (reused by email), Rob Cooper, Danielle Cooper; links Rob's and
    Danielle's `users` rows by id-auth username (silent on 0 rows).
  - **Registries:** Clients → parties + `client_profiles` + `direct_client`
    engagements; Vendors → parties (match existing first; `Bowden Works
    Team`/`Bowden Works` → the BW party; a pinned human set → people; else
    org) + in-memory defaults; Projects → `projects` + `project_billing`
    (status Active→active, Closed→archived, Paused→archived + `[paused]`
    note; name collisions suffixed ` (2)`).
  - **Ledgers:** Invoices (live + 2022 archive, deduped) → `invoices` rows
    (books CAD in `manual_total`; USD face in `original_amount`); Expenses
    (live + archive, deduped) → `expenses` rows (`source='sheet'`).
  - **Dedupe:** archive rows that match a live row are dropped (external-id
    first, else a tuple: invoices `(company, date, amount)`, expenses
    `(vendor, date, amount, project)`) — **live wins**; unmatched archive
    rows import as `archive_only`.
  - **Reconciliation:** every CSV row of every consumed tab lands in exactly
    one disposition; the per-tab totals must sum or `report.reconcile` FAILS
    the run (rolling the whole rebuild back). The report carries a
    per-settlement-month invoice/expense summary — the numbers the settlement
    gate checks against the sheet's Monthly Split.

## Constraints & edge cases

- **Opt-in, not always-on.** The step runs only when `run_all` is called with
  `books_source` (a path, or `"auto"` for the app-relative
  `books-import/source`). Default None → the step is skipped entirely — the
  legacy-only behavior every harness/parity/reporting test relies on. The two
  production entrypoints (`scripts/migrate_legacy.py`,
  `scripts/nightly_snapshot.py`) pass `"auto"`. Missing export → LOUD warn +
  skip (a box without the export must not crash).
- **Runs in the rebuild transaction:** a sheet reconciliation failure rolls
  the WHOLE nightly rebuild back (by design — sheet ownership means the
  sheet books are part of the nightly rebuild).
- **App-owned skip (ADR #021):** when the PlusROI tenant's effective
  `books_owner` is `"app"`, the step SKIPS it entirely (checked BEFORE the
  missing-export fatal): the tenant-scoped reset preserved every book row,
  and a stale/absent export can no longer fail — or touch — the nightly.
  Party identity is stable across rebuilds (upsert-by-slug), so the
  app-owned books' party FKs stay valid while BW rebuilds nightly.
- **Money:** parsed via `Decimal` (`$1,234.56`, parenthesised negatives,
  blank→NULL); rounded HALF_UP to 2dp only when the source carries >2dp
  (FX-converted rows do — a warn, not an error). Dates parse ISO strings and
  Excel serials (the "Due Date" column).
- **Blank/padding tail rows** are skipped (`skipped_blank`). Unmapped status
  → `paid` + warn; blank paid-by / unmatched vendor / unmatched project →
  NULL + warn; each warn class is counted in the run report.
- **Not stored:** an invoice↔project link (invoices carry no `project_id` in
  R2) — the project match is resolved for the unmatched-warn count only.

## Permissions

No new authz surface. The importer runs as the transform pipeline (owner /
system). The PlusROI tenant it creates starts sheet-owned; membership +
visibility are the tenancy layer's job (ADR #015) — partner users
(`rob`/`danielle`) see only PlusROI.

## Open considerations

- The settlement engine (Danielle 3% bookkeeping + 50/50 split, grouped by
  `settlement_month`) is R4 — `services/money` reads these ledgers; this
  feature only lands them + the summary numbers.
- Vendor defaults (category / paid-by) are held in-memory during import only
  (no `vendor_defaults` table in v1).
- Commission earners on invoices are not carried per-row (the sheet Invoices
  tab has no earner name column); client/project commission earners resolve
  to a party only when an existing party matches by name.

## Tests

- `tests/migrate/test_sheet_books.py` (dedicated scratch DB, tiny fixture
  CSVs under `tests/migrate/fixtures/sheet/`): tenant + owners creation &
  idempotency, users linkage by username, client match-vs-create + archive,
  vendor mappings, project status + name collision, invoice
  status/currency/original-amount + name uniqueness, live↔archive dedupe
  (id- and tuple-based), expense paid-by mapping + dedupe, blank-row
  skipping, the reconciliation-fails-on-unaccounted-row contract, and a
  guarded real-export smoke run.

## Changelog

- 2026-07-11 — Initial schema (alembic 0007) + sheet importer
  (`sheet_books.py`), wired into `run_all` + the two pipeline entrypoints.
  Code-complete, pending deploy. ADR #016.

## Settlement engine + gate (v0.12.0 addendum)

- `services/money/settlement.py` + `GET /api/settlement[/{month}]` — the
  Monthly Split engine (ADR #017): formula, inclusion rule (tag = the
  grouping rule, no status filter), full-precision rounding policy, the
  suggested BW Wave invoice (split + non-tenant-fronted reimbursements
  grouped Team Cost / PPC / Licences + GST), owner-only.
- `scripts/settlement_gate.py` — the three-layer proof: (1) independent
  CSV oracle vs the DB engine, every month since the live sheet's
  2023-10 inception — **32/32 to the cent (GREEN 2026-07-11)**;
  (2) sheet-displayed anchors (exact / ≤2¢ import-rounding artifacts);
  (3) the three real Wave invoices, every delta classified (May split
  −49.44 post-invoice ledger drift; Apr Team Cost itemized extra
  2,500.00; Mar ad-hoc AISV 8,000.00; Mar PPC −3.00 invoice-side,
  ledger verified row-by-row).

## Books UI (v0.13.0)

The pages that replace rian's PlusROI bookkeeping Google Sheet. Three
owner-only routes under a **Books** nav group (gated on `can_see_income`),
fed by the frozen settlement engine plus two thin ledger-list endpoints.

**Routes**

- **`/books/settlement` — the Monthly Split (the centerpiece).** A left
  rail lists every settlement month newest-first (each a compact split/net
  row with a revenue-vs-expenses `<progress>` spark) plus an "awaiting a
  settlement month" chip linking to the ledgers filtered to unsettled. The
  main panel renders the selected month (`GET /api/settlement/{month}`) as:
  1. **the waterfall** — the formula as a top-to-bottom cascade with the
     operators visible (Gross → − fees → − commissions → = Revenue →
     − expenses [expandable to the category breakdown] → − bookkeeping (3%)
     → = Net → ÷ 2 → **the split**, big), so Danielle reads it like the
     sheet's Monthly Split rows;
  2. **a comparison strip** — vs the previous month and vs the same month
     last year, small delta chips (+8.5% style) on Revenue / Expenses /
     Net / Split, computed client-side from the summary months list;
  3. **"This month's Bowden Works invoice"** — a Wave-invoice-shaped panel
     from `suggested_invoice` (lines, subtotal, GST, total) with a "Copy
     lines" button that puts a clean tab-separated `label\tamount` block on
     the clipboard, and the footnote "Computed from this month's ledger —
     add any ad-hoc lines in Wave.";
  4. **the "Fronted by …" reimbursements table** (payer from data, not
     hardcoded).
- **`/books/invoices` — the receivables ledger.** `FilterBar` with a
  settlement-month picker (settlement months + Unsettled + All), status
  chips (`pending_approval`/`ready`/`sent` shown as **Invoiced**/`paid`/
  `cancelled`/`delayed`), and a debounced free-text search. Columns:
  Invoice # (opens the drawer), Client, Name, Date, Status, Amount (with a
  "USD {original_amount}" subtext when the receipt was USD), Fee,
  Commission, Settlement month. Footer: filtered count + Σ amount + Σ fees.
  A row opens a read-only right-side detail drawer (all fields incl. notes,
  relevant period, billing type).
- **`/books/expenses` — the payables ledger.** Same shape with month /
  category / paid-by filters + search. Columns: Date, Vendor, Project,
  Category, Paid by (a badge — muted when the tenant fronted it, accented
  when a partner did = "this gets reimbursed"), Amount, Settlement month.
  Footer: count + Σ. Read-only drawer as above.

**Backend — two list endpoints + one meta read** (`app/services/books.py`,
`app/routers/books.py`):

- `GET /api/books/invoices?month=&status=&q=&page=&page_size=` →
  `{rows, total_count, page, page_size, sum_amount, sum_fees}`. `month` is
  `"YYYY-MM"` | `"unsettled"` (settlement_month IS NULL) | absent (all);
  `status` is exact; `q` is a LIKE-escaped substring over invoice name /
  external_id / client (to_party) name. `amount` carries `manual_total`;
  the USD face survives in `original_amount`/`original_currency`.
- `GET /api/books/expenses?month=&category=&paid_by=&q=&page=&page_size=` →
  the same shape plus the distinct `categories` + `paid_by_options` lists
  the dropdowns need (one round trip). `q` matches vendor name /
  external_invoice_id / project name. Each row carries
  `paid_by_is_tenant` (the reimbursement flag the badge reads).
- `GET /api/books/meta` → `{parallel_run: bool}` — since ADR #021 COMPUTED
  from the tenant's effective `books_owner` (sheet-owned → true; app-owned
  or legacy-mirrored → false), same wire shape — drives the banner (see
  below), which therefore lifted by itself at the flip. Kept in the Books
  router because `services/money/` is frozen (ADR #018).
- Both ledgers: tenant-scoped via `actor.tenant_id` ALWAYS; sums computed
  over the FULL filtered set (not the page); sort settlement_month desc
  then date desc; page size default 50, cap 200. Every route is owner-only
  (403 `INCOME_FORBIDDEN`, matching the settlement engine's error shape).

**Parallel-run banner.** Every Books page shows a dismissable-per-session
banner while `GET /api/books/meta` reports `parallel_run: true`: "these
books rebuild nightly from the Google Sheet — edits made here would be
overwritten. The sheet stays the source of truth until cutover." This is
the visible half of the P4 parallel-run caveat.

**Read-only rationale (sheet-owned mode).** While a tenant is sheet-owned
(the nightly transform clear-rebuilds these ledgers from the sheet — ADR
#016/#021), any write made in the app would be silently overwritten on the
next rebuild. So the Books UI is read-only THEN — enforced by the service
write-block (409 `SHEET_OWNED_READ_ONLY`) — and the banner makes the
reason visible. Since the 2026-07-12 flip PlusROI is app-owned: the block
and banner are off, and the phase-3 write forms build on this.

**Nav gating.** The Books nav group renders only when the CURRENT actor's
`can_see_income` is true (already on `GET /api/me.capabilities`) — partner
/ worker / none users never see it. Gating is defence-in-depth only: the
pages themselves show an owner-only notice and the API returns 403
regardless, so a deep-link by a non-owner is safe.

## Clients / Contacts / Vendors write forms (phase-3 slice 1)

The first write surface built on the app-owned flip (docs/design/plusroi-forms.md
§1–§2). No schema change — every row lands in a table that already exists
(`parties`/`organizations`/`people`/`party_affiliations`/`client_profiles`/
`engagements`). **Lives at:** `app/services/clients.py`, `app/services/vendors.py`,
`app/services/plusroi_gates.py` (the shared owner + sheet-owned + tenant gate),
`app/routers/clients.py`, `app/routers/vendors.py`;
`frontend/src/features/clients/` (ClientsPage + ContactsPage + clientsLogic),
`frontend/src/features/vendors/` (VendorsPage + vendorsLogic).

**The write-gate (every mutation).** `services/plusroi_gates`:
`require_owner` (`can_transfer`, 403 `NOT_ALLOWED`) → `require_writable_books`
(refuses SHEET-owned books, 409 `SHEET_OWNED_READ_ONLY`, keyed on
`effective_books_owner` — the SAME predicate as the invoice write-block, so
both lift together at a flip) → `require_tenant`. Every write is audited
(`clients.*` / `vendors.*`) with before/after images. Reads are owner-gated
only (`require_owner` + `require_tenant`).

**Clients.** A client = an **org party** (+ its 1:1 `organizations` detail:
`legal_name` from *official name*, `currency`) + a **`client_profiles`** row
for THIS tenant (the tenant-scoped anchor + authoritative "my clients" list) +
a **`direct_client` engagement** (operator = tenant party, client = org).
Parties are created ONLY through `services.entities.parties.create_org_party`
(the 1:1 subtype choke point). **Reuse-existing-org dedup** (the
`services/registry` pattern): create matches an ACTIVE org by `lower(name)` and
REUSES it (shared-identity model — one "Copernic" across tenants), adding just
this tenant's profile + edge; a party that is ALREADY this tenant's client is a
409 `CLIENT_EXISTS`, not a duplicate. On reuse, shared org identity is enriched
non-destructively (blanks filled, never clobbered). Currency is validated
against a small set (`CAD`/`USD`/`EUR`/`GBP`).
- `GET /api/clients` (list) · `GET /api/clients/{party_id}` (detail + contacts) ·
  `POST /api/clients` (create; accepts an inline `contacts:[{name,email}]`
  repeater) · `PATCH /api/clients/{party_id}` (edit — org-identity keys touch
  the shared party/org, profile keys touch this tenant's row; PATCH semantics,
  one save writes all present fields).

**Contacts.** A contact is a **person party** (via `create_person_party`, email
on the person) linked to a client org by a **`party_affiliations`** row
`role='cc_recipient'`. Reusable across clients; listed per-person on `/contacts`
(each carrying every client they're cc'd on). Remove = END the affiliation
(`ended_on` stamped — append-only history, not a hard delete), scoped so a
tenant can only end a link whose org is one of ITS clients.
- `POST /api/clients/{party_id}/contacts` · `GET /api/contacts` ·
  `DELETE /api/contacts/{affiliation_id}`.

**Vendors.** Thin — just a payee **party** (org OR person); no profile table.
`POST /api/vendors` match-existing-by-`(kind, lower(name))` first (reuse — never
a duplicate). `GET /api/vendors` = the tenant's `tenant_vendors` markers ∪ the
distinct parties this tenant's expenses reference, with an expense count.
- **Vendor listability — RESOLVED in slice 2a (alembic 0008, ADR #022).** The
  slice-1 gap (a freshly created vendor with no expenses wasn't listable) is
  closed by the **`tenant_vendors`** marker table: `create_vendor` now writes a
  marker `(tenant_id, vendor_party_id)` (idempotent — a re-create refreshes the
  defaults), so the vendor shows immediately, and `list_vendors` returns
  markers ∪ expense-referenced parties. The marker also holds per-tenant vendor
  DEFAULTS (`default_category`, `default_paid_by_party_id`) the Expense form
  pre-fills. It is **NOT an engagement** (an engagement is an entity-graph edge
  that can grant tenant data access; a payee must never gain that — ADR #022).
- **Also deferred:** the client `commission_party_id` (the commission earner) is
  supported end-to-end in the API + payload builder, but the create/edit UI does
  not yet surface a party picker for it (needs a people/parties search picker).

**Frontend.** Real pages replace the ComingSoon placeholders at `/clients`,
`/contacts`, `/vendors` (router.tsx). Native in the PlusROI skin via tokens only
(the `no-inline-styles` test passes); keyboard-reachable; pessimistic-save +
verified refetch (the M5 pattern — await the server, toast, invalidate so the
table refetches VERIFIED state). Pure form logic (contact-repeater state +
payload builders) is extracted to `clientsLogic.ts` / `vendorsLogic.ts` for unit
tests.

## Projects write form + phase-3 schema (slice 2a)

The Projects create/edit surface + a small additive migration
(docs/design/plusroi-forms.md §3; **alembic 0008, ADR #022**). **Lives at:**
`alembic/versions/0008_plusroi_phase3.py`, `app/models/project.py`
(`ProjectMilestone`), `app/models/expense.py` (`ProjectBilling` recurrence
columns + `TenantVendor`), `app/models/invoice.py` (`collected_*`),
`app/services/projects_plus.py`, `app/routers/projects_plus.py`;
`app/services/migrate/transforms.py` (`_SCOPED_DELETES` + `TRUNCATE_TABLES`);
`frontend/src/features/projects/PlusProjectsPage.tsx` + `projectsPlusLogic.ts`
+ `ProjectsPlus.css`.

**Schema (alembic 0008, additive — harness byte-identical).**
- `project_billing` gains the **clean recurrence model**: `is_recurring`
  (bool), `cycle_months` (int, 1/3/12), `billing_timing`
  (`month_end`|`mid_month`, CHECK). The free-text `billing_type` stays for
  back-compat/import mapping.
- **NEW `project_milestones`** — a non-recurring project's schedule (deposit /
  milestone / final): `label`, `amount`, `currency`, `sort_order`,
  `generated_invoice_id` (FK invoices SET NULL — one invoice per milestone),
  CASCADE on project delete, index `(project_id, sort_order)`.
- `invoices` gains `collected_amount` + `collected_currency` (the realized CAD
  at Paid for a USD invoice — the lifecycle write is slice 2b; only the columns
  land here).
- **NEW `tenant_vendors`** — the vendor-listability marker (see the Vendors
  section above).

**Nightly survival (ADR #021/#022 — the #1 hazard).** Both new tenant-scoped
tables are added to `transforms._SCOPED_DELETES` AND `TRUNCATE_TABLES` so an
app-owned tenant's rows survive the BW rebuild:
`project_milestones` scopes THROUGH its project (`project_id IN (SELECT id FROM
projects p WHERE {gone:p.tenant_id})` — the same qualified-column anti-join as
`project_billing`), placed before `invoices`/`projects`; `tenant_vendors` scopes
by its own `tenant_id`, before `tenants`. They MUST also be in the TRUNCATE set
(the fast path) because each references a truncated parent — omitting them would
make `TRUNCATE projects/tenants` fail. The survival gate
(`tests/migrate/test_app_owned_survival.py`) is extended to plant an app-era
milestone + vendor-marker and prove both survive a simulated nightly.

**The form (owner-gated, writable-books, tenant-scoped, audited — reuses
`plusroi_gates`).** `/api/plus-projects`:
- `POST` create attached to a client — the client MUST be a `client_profiles`
  row for THIS tenant (the tenant-scope anchor AND inheritance source; else 404
  `CLIENT_NOT_FOUND`). **Inheritance:** currency/terms/invoice_timing/commission
  default from the client profile, a provided non-blank value overrides. Then
  EITHER recurring (`is_recurring` + `cycle_months` + `billing_timing` + a
  required default `amount`) OR a milestone list `[{label, amount}]` →
  `project_milestones`. Uniqueness `(tenant, client_party, lower(name))` (409
  `PROJECT_EXISTS`). Writes `projects` + `project_billing` (+ milestones).
- `PATCH /{id}` edit (only SET keys change): identity/defaults + the recurrence
  shape. Switching TO recurring drops un-generated milestones; a schedule edit
  REPLACES only un-generated milestones (a milestone that already generated an
  invoice is preserved).
- `GET /api/plus-projects` list this tenant's projects with their billing shape
  (+ optional `recurring` / `timing` filters for the §4 batch selectors);
  `GET /{id}` detail + milestones.
- **Additive** — `/api/projects` (the BW rollup) and the `/projects` page are
  untouched; the PlusROI profile's "Projects" nav repoints to the new
  `/projects/new` surface (label unchanged). Frontend mirrors the Clients page
  (shared `pf-*` kit, tokens only, `no-inline-styles` passes); the client picker
  is `EntityPicker`; the recurring-vs-milestones toggle + payload builders are
  pure in `projectsPlusLogic.ts`.

## Invoice generation + status lifecycle (phase-3 slice 2b)

Generation from projects + the sheet status lifecycle + USD realized-FX
(docs/design/plusroi-forms.md §4; **ADR #023**; NO migration — writes the
alembic-0008 columns). **Lives at:** `app/services/money/invoices_plus.py`,
`app/routers/invoices_plus.py`; `app/services/books.py` (the ledger rows now
carry `collected_amount`/`collected_currency`);
`frontend/src/features/books/invoiceLifecycle.ts` + `InvoiceLifecycleModal.tsx`
+ the `manage` column in `ledgerCatalog.tsx`,
`frontend/src/features/projects/PlusProjectsPage.tsx` + `projectsPlusLogic.ts`
(generate modals + payload/copy helpers).

**SEPARATE from the BW invoice service.** `services/money/invoices.py` (BW
time invoices: open|sent|paid via `clean_status`, line apply/detach) is
untouched. PlusROI invoices are receivables — no lines — on the sheet
lifecycle, written to the SAME `invoices` table, tenant-scoped. BW's `open`
is not a state in the PlusROI transition map: a BW-shaped row gets a 409 (and
no Manage control), never a coercion. Every mutation: `plusroi_gates` (owner →
writable-books → tenant) + audit with before/after images.

**Generate from a project** — `POST /api/plus-projects/{id}/generate-invoice
{month}` (owner-gated):
- **Recurring** → ONE invoice, `status='pending_approval'`, to_party = the
  client, `manual_total` COPIED from `project_billing.amount` (currency from
  billing; commission pct/party/amount stamped from billing), name
  `"{project} — {YYYY-MM}"` (per-tenant unique, ` (2)`… suffix on an unrelated
  collision), `relevant_period="YYYY-MM"`, `billing_type='recurring'`,
  `settlement_month=first-of-month` (the sheet's billing tag, stamped at
  birth). A repeat for the same (project, month) is 409 `ALREADY_GENERATED` —
  identity = client + relevant_period + name prefix (invoices carry no
  project FK; a project rename between generations re-opens the month,
  ADR #023 #4).
- **Milestone** → ALL un-generated milestones at once (one
  `pending_approval` invoice each, amount = the milestone's), each stamped
  `project_milestones.generated_invoice_id` — at most one invoice per
  milestone ever; a repeat call returns `created: []`. Deleting a generated
  invoice re-opens its milestone (FK SET NULL).
- A generated USD invoice carries `currency='USD'` + the face in
  `original_amount/original_currency`.

**Batch month-end/mid-month** — `POST /api/plus-invoices/generate-batch
{month, timing}` (Danielle's twice-monthly run): every ACTIVE recurring
project with that `billing_timing` gets the month's invoice; idempotent —
per-project results `created` / `skipped_exists` / `skipped_no_amount` /
`skipped_no_anchor` / `skipped_off_cycle`. Cycle guard: quarterly/annual
projects generate only on due months anchored on `billing.start_date`;
unanchored multi-month cycles are SKIPPED (never over-invoiced) — generate
those from the project row.

**Lifecycle** — `PATCH /api/plus-invoices/{id}`:
- Transitions `pending_approval → ready → sent → paid`, one guarded step;
  `cancelled`/`delayed` from any live state; `delayed` resumes anywhere live
  except straight to `paid`; `ready` can go back to `pending_approval`;
  `paid`/`cancelled` terminal. Illegal jump → 409 `ILLEGAL_TRANSITION`.
- **sent** requires `external_id` (the QuickBooks #; 422
  `EXTERNAL_ID_REQUIRED`); first-crossing sent_at/paid_at stamps as BW.
- **paid** on a non-CAD invoice REQUIRES `collected_amount` (422
  `COLLECTED_AMOUNT_REQUIRED`): stored in `collected_*` AND realized into the
  book value (`manual_total`=collected, `currency`='CAD', face preserved) —
  the exact sheet-importer shape, which keeps the FROZEN settlement engine
  (Σ manual_total, no status filter) summing realized CAD. CAD invoices may
  record `collected_*` optionally (no book rewrite).
- **cancelled clears `settlement_month`** so a dead invoice never pollutes
  the monthly split (the frozen engine has no status filter). `delayed`
  keeps its tag.
- `amount` editable while `pending_approval`/`ready` only (409
  `AMOUNT_LOCKED` after); the edit updates the non-CAD face + recomputes the
  stamped commission, and NEVER touches the project default (Rob's $1,500 →
  $2,000 flow).

**Frontend.** `/books/invoices` gains a `manage` catalog column (PlusROI
profile only — BW's Books view stays read-only, zero `if (tenant)`): a
per-row modal offering the legal next steps, the QB # at Invoiced, the CAD
collected at Paid (USD), and the draft-amount edit, pessimistic-save +
verified refetch (ledger + settlement queries invalidated). The Projects page
gains per-row "Create invoice" (recurring, month picker) / "Generate
schedule" (milestones) and a "Generate recurring invoices…" batch modal
(month + timing) with a per-reason skip summary toast. Lifecycle rules are
pure + vitest-pinned in `invoiceLifecycle.ts` (server mirror; the server
enforces).

## Expenses writes + CC bridge + settlement apply (phase-3 slice 3)

The LAST forms slice (docs/design/plusroi-forms.md §5–§6; **ADR #024**; NO
migration). **Lives at:** `app/services/money/expenses.py` +
`app/routers/expenses.py`; `app/services/money/settlement_apply.py` (+ the
apply route in `app/routers/settlement.py`); `app/services/cc/service.py`
(per-user list scope + `port_to_expenses`) + the port route in
`app/routers/cc.py`; ONE guard added to `app/services/money/invoices.py`;
frontend `features/books/ExpenseFormModal.tsx` + `expensesLogic.ts` +
`SettlementApplyModal.tsx`, the CC batch page port panel, and the PlusROI
nav gains CC expenses. Every mutation: `plusroi_gates` (owner →
writable-books → tenant) + audit. `services/money/settlement.py` stays
FROZEN throughout — slice 3 only writes the rows/tags it already sums.

**Manual expense** — `POST /api/expenses` {vendor_party_id, project_id?,
amount, currency?, category?, paid_by_party_id?, expense_date,
settlement_month?, notes?} → one `source='manual'` row,
`created_by` = the real human. `PATCH`/`DELETE /api/expenses/{id}` for
corrections (PATCH-style, provided keys only; delete carries the full
before-image in audit). Row DTOs mirror `/api/books/expenses` exactly, so
created rows appear in the ledger list verbatim. `GET /api/expenses/options`
feeds the form in one round trip: vendor options (with the `tenant_vendors`
defaults — picking a vendor pre-fills category/paid-by), project options
(`{client} : {project}`), paid-by parties (tenant party ∪ historical payers
∪ the caller), the category datalist, currencies.

**Multi-line expense (Yulia)** — `POST /api/expenses/batch`
{vendor_party_id, expense_date, currency?, paid_by_party_id?,
settlement_month?, lines:[{project_id, amount, category?, notes?}]} → N
rows sharing vendor + date + currency, one per {project, amount} line
(max 100). ONE transaction, ONE audit row (`expenses.create_batch`) with
every created image. Any bad line aborts the whole submit.

**CC extractor → PlusROI:**
- **Per-user batch privacy** — the batch LIST scopes to
  `created_by == actor.user_id` (the EFFECTIVE user; Rob sees only his
  batches) plus ownerless `created_by IS NULL` rows (migrated/orphaned —
  visible to every owner rather than stranded). Only the list scopes;
  batch detail/lines/rules stay tenant-scoped (a shared URL still opens).
  `create_batch` now stamps `created_by` with the effective user so
  ownership follows impersonation (ADR #024 #2).
- **Port to expenses** — `POST /api/cc/batches/{id}/port-to-expenses`
  {line_ids?, paid_by_party_id?}: each assigned line (project + amount)
  becomes one `source='manual'` expense — batch currency, line
  category/date, notes = bill-through description, vendor NULL (the
  merchant string lives in notes), `paid_by` = the caller's own party
  unless chosen, `settlement_month` NULL (the month-end apply tags it).
  Provenance + idempotency: the row's `external_invoice_id` =
  `cc:<line-id>`; a re-port skips lines whose marker already exists
  (`skipped_already_ported`, plus `skipped_unassigned` /
  `skipped_no_amount`; all-skipped → 409 `NOTHING_TO_PORT`). Deleting the
  ported expense frees the marker (delete = undo the port). No schema
  change; `invoice_line_id` untouched.

**Settlement "apply to month" (Rian)** — `POST /api/settlement/apply`
{month, invoice_ids?, expense_ids?} tags the tenant's rows
`settlement_month = first-of-month`; the FROZEN engine then includes them
in that month's split. Money-safety: invoices tag only when `paid`
(`skipped_not_paid`), already-tagged rows are never silently moved between
months (`skipped_already_tagged`), unknown/foreign ids are a hard 404
(all-or-nothing), all-skipped → 409 `NOTHING_TO_APPLY`. ONE audit row with
before/after tag images. The apply picker reads the EXISTING ledger lists:
`/api/books/invoices?month=unsettled&status=paid` +
`/api/books/expenses?month=unsettled`.

**The BW-endpoint guard** (ADR #024 #5, extended by **ADR #025**) — every
BW time-invoice write that reaches a row by id now refuses with 409
`PLUS_LIFECYCLE_INVOICE` when the invoice's CURRENT status is
`pending_approval`/`ready`/`cancelled`/`delayed`: `PATCH
/api/invoices/{id}` (its `clean_status` would silently coerce a PlusROI
receivable to `open`), `POST /api/entries/bulk/apply-to-invoice` (would
attach a `time`-kind `invoice_lines` row to a receivable that's supposed
to carry none), and `DELETE /api/invoices/{id}` (would destroy the
receivable outright, bypassing the PlusROI lifecycle entirely). One
shared helper, `_reject_plus_lifecycle`, in
`services/money/invoices.py`. `open`/`sent`/`paid` (shared vocabulary)
stay editable/appliable/deletable there exactly as before. The PlusROI
surface is `PATCH /api/plus-invoices/{id}`.

**Frontend.** `/books/expenses` gains "+ New expense" — one modal, two
modes (Single; Multi-line split-by-project with a line repeater + running
total), vendor pick pre-fills the tenant defaults, pessimistic save +
ledger/settlement refetch. `/books/settlement` gains "Apply to month…" —
a month picker + two select-all checklists (paid-untagged invoices,
untagged expenses) → one apply → verified refetch of settlement + both
ledgers. The CC batch page gains a "Move into shared expenses" panel
(eligible-count button + paid-by select defaulting to "Me"). The PlusROI
profile nav adds CC expenses under Books (`can_transfer`). Pure form logic
vitest-pinned in `features/books/expensesLogic.ts`. Zero `if (tenant)`.

## Tests

- **Slice 3** — `tests/expenses/test_expenses.py` (SQLite, house style):
  manual round-trip (row shape, ledger visibility, tenant scoping,
  `source='manual'`), minimal-payload defaults, the full validation table
  (vendor/amount/currency/date/month/project/paid-by), foreign-project 404,
  multi-line N-rows-one-vendor + ONE audit group + atomic abort on a bad
  line + line-count caps, PATCH provided-keys-only + tag clearing + project
  reassign/clear, delete + audit before-image, owner-gate 403 on every
  mutation AND the options read, sheet-owned 409 on every mutation, tenant
  isolation (404 by id, empty foreign ledger), audit action coverage,
  options shape (vendor defaults, project labels, paid-by ∪ tenant party,
  categories, currencies) + options tenant isolation. 29 tests.
  `tests/expenses/test_settlement_apply.py`: tag paid invoices + expenses,
  the FROZEN engine reflecting the apply (waterfall math on freshly-tagged
  rows), unpaid/already-tagged counted skips (tags never silently move),
  all-skipped 409, validation (month/ids/empty selection), foreign-id hard
  404 with nothing tagged, owner 403 + sheet-owned 409, the single audit
  row. 11 tests.
- `tests/cc/test_cc_port_privacy.py` (SQLite, two owner users in one
  tenant): per-user list isolation (Rob never sees rian's batch), ownerless
  NULL-created_by visibility, detail-stays-tenant-wide (shared URL), the
  view-as ownership stamp (effective user owns; audit carries
  impersonated_by), cross-tenant 404, port round-trip (row fields, marker,
  books visibility, paid-by default = the caller's party), idempotent
  re-port + wave-two port of newly assigned lines, line_ids selection +
  foreign-line 404, paid-by override + unknown-party 404,
  delete-frees-the-marker, port gates (403 / sheet-owned 409 / cross-tenant
  404), port audit. 12 tests.
- `tests/invoices/test_plus_lifecycle_guard.py` (SQLite): the BW PATCH,
  apply-to-invoice, and delete all 409 on all four PlusROI statuses
  (name/status/amount patches, an empty apply selection, delete; row
  never coerced/attached/deleted) while open/sent/paid rows still edit +
  open→sent still transitions + apply/delete still run exactly as
  before (ADR #025). 21 tests.
- `frontend/src/features/books/expensesLogic.test.ts`: amount validity,
  single-form validity + payload trimming/nulling, line cleaning, batch
  validity, the batch payload builder (shared fields + per-line), the
  running total. 8 tests.
- **Slice 2b** — `tests/invoices_plus/test_invoices_plus.py` (SQLite, house
  style; owner actor carries both `allowed` and `capabilities` so the frozen
  engine + books list run on the same fixtures): recurring generate
  round-trip (copy-from-project, commission stamp, USD face, settlement tag),
  duplicate-month 409 + suffix-on-unrelated-collision, milestone generate-all
  + `generated_invoice_id` idempotency + partial (pre-generated preserved),
  batch per-timing + skip-existing + archived-skip + the cycle guard
  (anchored on-cycle / off-cycle / no-anchor), the full lifecycle (ready →
  sent requires QB # → paid; USD paid requires + records collected CAD and
  realizes the book value), illegal-jump 409s incl. BW `open`, cancel clears
  the settlement tag, delay/resume, amount edit leaves the project default
  alone + recomputes commission + locks after sent, owner-gate 403,
  sheet-owned 409, tenant isolation (generate/patch/batch), audit rows with
  before/after, generated rows visible in `books.list_invoices`, and the
  FROZEN settlement engine reconciling a generated+tagged month (gross =
  CAD book + realized CAD, never a USD face). 28 tests.
- `frontend/src/features/books/invoiceLifecycle.test.ts` (transition map,
  editability, side-data requirements/blockers, patch builders, labels) +
  `projectsPlusLogic.test.ts` additions (month-key helpers, generate payload
  builders, generate/batch toast copy). 23 tests.

- `tests/clients/test_clients.py` + `tests/vendors/test_vendors.py` (SQLite
  fixtures per the `tests/books` house style: an app-owned tenant, a second
  tenant for isolation, a sheet-owned tenant, owner/worker actors): create
  round-trip (party + `organizations` + `client_profiles` + `direct_client`
  edge + cc_recipient contacts), reuse-existing-org (no duplicate party),
  owner-gate 403, sheet-owned 409 on every mutation, tenant isolation (a second
  tenant's client invisible / 404 by id), contact role + ended-on removal +
  cross-tenant 404, vendor person-vs-org + reuse + list-from-expenses,
  validation (name/currency/email/amount/kind). 28 tests.
- `frontend/src/features/clients/clientsLogic.test.ts` +
  `vendors/vendorsLogic.test.ts`: contact-repeater add/update/remove,
  `cleanContacts` blank-drop + trim, the create/update payload builders,
  form hydration + validity, the vendor payload builder. 12 tests.
- **Slice 2a** — `tests/projects_plus/test_projects_plus.py` (SQLite, same
  house style + a seeded client with inheritable defaults): recurring +
  milestone create round-trips, default inheritance vs override, recurring
  requires amount/timing, milestones-required, uniqueness 409, owner-gate 403,
  sheet-owned 409 (fires before any client load), client-must-be-this-tenant's
  404, list + `recurring`/`timing` filters, get isolation, update
  identity/amount, toggle-to-recurring drops milestones, milestone-replace
  PRESERVES a generated one, cross-tenant 404. 19 tests. `tests/vendors` gains
  the listability cases (marker written, listable with zero expenses, defaults
  stored, tenant-scoped, re-create no-dup). The survival gate
  (`test_app_owned_survival.py`) is extended to prove `project_milestones` +
  `tenant_vendors` survive a simulated nightly for an app-owned tenant.
- `frontend/src/features/projects/projectsPlusLogic.test.ts`: milestone
  repeater add/update/remove, `cleanMilestones` blank-drop + trim, the
  create/update payload builders (recurring vs milestones), `applyClientDefaults`
  (fills, doesn't clobber), `formFromProject` hydration, validity. 14 tests.

- `tests/books/test_books.py` (SQLite fixtures per `tests/money`
  house style): authz denial on all three routes, tenant isolation (a
  second-tenant fixture, both directions), month + unsettled filters,
  status/category/paid-by filters, `q` LIKE-escaping (a literal `%` in a
  name), pagination with sums over the full filtered set, page-size cap,
  row-shape correctness (settlement_month formatting, `manual_total` as
  `amount`, USD face retained, `paid_by_is_tenant`), the distinct
  category/paid-by option lists, and empty-state shapes. 26 tests.
- `frontend/src/features/books/booksLogic.test.ts`: month-key helpers,
  the comparison-strip delta math (incl. the zero-base guard), the
  suggested-invoice copy-block formatting, invoice status labels, and the
  ledger filter-param builders. 13 tests.

## Changelog
- 2026-07-11 — v0.13.0. Books UI: `/books/settlement` (the Monthly Split
  waterfall + suggested BW invoice + comparison strip + reimbursements),
  `/books/invoices` + `/books/expenses` ledgers, the parallel-run banner,
  and the two list endpoints + `/api/books/meta`. Read-only v1. ADR #018.
- 2026-07-11 — v0.12.0. Schema + importer (this doc) shipped with the
  multi-tenant foundation; settlement engine + gate landed same
  version; gate GREEN on all 32 live-sheet months.
- 2026-07-12 — ADR #021 (soft-launch phase 2): `books_owner` supersedes
  `parallel_run`; PlusROI freshly imported (2,391 invoices / 4,724
  expenses / 403 projects) and FLIPPED app-owned on the live sidecar;
  sheet step skips app-owned tenants; write-block re-keyed
  (`SHEET_OWNED_READ_ONLY`); stable party identity + tenant-scoped
  rebuild reset guarantee survival (gate:
  `tests/migrate/test_app_owned_survival.py`); settlement gate re-run
  GREEN 32/32 after the flip AND after a simulated nightly.
- 2026-07-12 — phase-3 slice 2a (ADR #022): additive migration **alembic
  0008** — `project_billing` recurrence model (`is_recurring`/`cycle_months`/
  `billing_timing`), NEW `project_milestones`, `invoices.collected_amount`/
  `collected_currency`, NEW `tenant_vendors` (vendor-listability marker,
  closing the slice-1 gap). Both new tenant-scoped tables added to
  `_SCOPED_DELETES` + `TRUNCATE_TABLES` (app-owned survival); survival gate
  extended. Projects write form (`services/projects_plus.py`,
  `/api/plus-projects`) with client-default inheritance + recurring-vs-
  milestone schedules; `services/vendors.py` now writes/lists the marker.
  Harness parity byte-identical (additive, harness reads a fixed table/column
  set). Backend 882 / frontend 160 green. Not yet deployed (deploys on the
  next `alembic upgrade head`).
- 2026-07-12 — phase-3 slice 2b (ADR #023): invoice generation from projects
  (single recurring + milestone generate-all + the idempotent month-end/
  mid-month batch with the cycle guard), the guarded sheet lifecycle on
  `PATCH /api/plus-invoices/{id}` (QB # at Invoiced; realized CAD at Paid for
  USD; cancel clears the settlement tag; draft-amount edits pending/ready
  only), `/api/books/invoices` rows gain `collected_*`, and the frontend
  lifecycle modal (`manage` column, PlusROI profile only) + Projects generate
  actions. NO migration. `services/money/invoices.py` + `settlement.py`
  untouched. Backend 910 / frontend 183 green. Not yet deployed.
- 2026-07-12 — phase-3 slice 3 (ADR #024, the LAST forms slice): Expenses
  writes (`POST/PATCH/DELETE /api/expenses`, the multi-line
  `POST /api/expenses/batch`, `GET /api/expenses/options`), CC per-user
  batch privacy (list scoped to the effective creator; `created_by` now
  stamps the effective user) + `POST /api/cc/batches/{id}/port-to-expenses`
  (idempotent via the `cc:<line-id>` marker on `external_invoice_id`),
  `POST /api/settlement/apply` (tag paid invoices + untagged expenses;
  frozen engine reflects it), and the BW guard (`PATCH /api/invoices/{id}`
  409s on PlusROI-lifecycle statuses). Frontend: the New-expense modal
  (single + multi-line), the settlement Apply-to-month modal, the CC port
  panel, CC expenses in the PlusROI nav. NO migration; `settlement.py`
  untouched. Backend 970 / frontend 191 green. Not yet deployed.
- 2026-07-13 — ADR #025: the BW-endpoint guard (ADR #024 #5) extended
  from `update_invoice` to also cover `POST
  /api/entries/bulk/apply-to-invoice` (`apply_entries`) and `DELETE
  /api/invoices/{id}` (`delete_invoice`) — both reach a PlusROI
  receivable by id and neither was previously guarded. Refactored the
  inline check into `_reject_plus_lifecycle`, called from all three
  writes. `open`/`sent`/`paid` unaffected.
  `tests/invoices/test_plus_lifecycle_guard.py` grows 8 → 21 tests. NO
  migration; `services/money/invoices_plus.py` untouched.
