# Invoices

**Status:** in production
**Lives at:** `/invoices` · `app/(app)/invoices/page.tsx`,
`app/(app)/invoices/actions.ts`, `app/(app)/invoices/[id]/page.tsx`,
`lib/invoices.ts` · uses `invoices` table + `time_entries.invoice_id` FK.

## Summary
Invoices group time entries you're billing for. Attaching an entry to
an invoice locks it (only owners can detach + edit). Each invoice has
a status (open / sent / paid), an optional operator+client scope, an
optional manual total override, and notes. The detail page shows a
per (client × project) summary table with cost, billout, margin% and
margin$. Replaces the old transfer / Toggl-export flow as the
canonical "this entry is billed" signal — see decisions.md #038.

## Why
2026-05-25 — "I want to create an invoice and assign some entries to
that invoice... mark them as 'applied'. The effect will be the same
as 'transfer' where it locks the entries. In fact, it can replace
transfer altogether."

## Data model
- `invoices(id, org_id, name UNIQUE per org, status, operator_id?,
  client_id?, manual_total_usd?, notes?, created_by, created_at,
  invoice_date?, sent_at?, paid_at?)`. Status enum: `'open' | 'sent' | 'paid'`.
  `sent_at` + `paid_at` stamped the first time status crosses into
  that state (preserved on round-trips). **`invoice_date`** is the
  user-facing accounting date (distinct from `created_at`); defaults
  at create-time to the last day of the previous month so monthly
  invoices land on a meaningful date regardless of when they're
  created.
- `time_entries.invoice_id` FK + `invoice_applied_at` +
  `invoice_applied_by`. ON DELETE SET NULL — deleting an invoice
  detaches its entries (they go back to unlocked).
- RLS: org members can read/write/delete via `is_org_member(org_id)`.
- Indexes: `(org_id, status)`, `(operator_id)`, `(client_id)`,
  plus a unique `(org_id, lower(name))` so casing collisions surface.

## Behavior
- **List page (`/invoices`)**: table sorted by created_at desc. Shows
  name (link), status badge, scope (operator/client), entries count,
  cost, billout (with "manual override" marker if `manual_total_usd`
  diverges from sum), margin $ + %. Inline "+ New invoice" form
  toggled by `?new=1`.
- **Detail page (`/invoices/[id]`)**:
  - Header with name + status badge + invoice_date + created/sent/paid
    dates, Edit + Delete buttons (owner-only).
  - Stats card: Entries / Hours / Cost / Billout / Margin. Manual
    total override marked with a ★ on the Billout stat.
  - **Per (client × project) breakdown** — one row per distinct
    tuple under the invoice. Counts + hours + cost + billout +
    margin% + margin$. Sorted client-asc, project-asc.
  - **Paste block** — tab-separated text block for pasting into the
    legacy PlusROI Google Sheet. One line per breakdown row, fixed
    columns (see "Paste block format" below). Copy-to-clipboard
    button + a `<pre>` for triple-click fallback.
  - "View attached entries" link at the bottom →
    `/entries?status=applied&invoice=<id>`.
- **Apply** action on /entries: bulk-action toolbar has a dropdown
  of open + sent invoices and an **Apply to invoice** button.
  Replaces the old "Mark as transferred" button. Eligibility check
  (team_member_id, operator, client, project, description, end_at,
  source_user_email all non-null) — rows missing any are silently
  skipped, matching the old behavior. See decisions.md #038.
- **Detach** action on /entries: clears `invoice_id` +
  `invoice_applied_at` + `invoice_applied_by` for the selected rows.
  Owners only. Explicit selection only (not all-matching, to keep the
  destructive lock-removal scoped to what's checked).
- **Edit invoice**: name / status / operator / client / manual total /
  notes. Status transitions auto-stamp `sent_at` / `paid_at` the
  first time they fire and don't overwrite on subsequent toggles.
- **Delete invoice**: detaches every attached entry first (sets
  `invoice_id = null` + clears `invoice_applied_at`/`by`), then
  deletes the invoice row. Entries become unlocked again.

## Backfill
Migration `20260525000010_invoices.sql` creates a "Legacy transfers"
invoice per org (status=paid) and links every entry that had
`transferred_at IS NOT NULL` to it. `invoice_applied_at`/`by` are
populated from the old `transferred_at`/`by` columns so the audit
trail is preserved. `transferred_at` columns are kept on
`time_entries` as historical timestamps; new writes don't touch them.

## Paste block format

For the legacy PlusROI Google Sheet (last remaining external sheet
dependency — eliminated once that sheet is retired). Format per line:

```
{client} : {project}\tBowden Works Team\t{amount}\t{invoice_date}\t\tBowden Works\tLabour
```

Seven columns, six tabs. The 5th column is intentionally empty per
the destination sheet's layout. Client/project uses the same
`{client} : {project}` separator as the old Toggl export (ADR #029)
so it matches existing PlusROI sheet rows. Column 2 is the literal
`Bowden Works Team` (the vendor name on PlusROI's side); column 6
is `Bowden Works` (the operator). They're intentionally different
strings.

- **Amount**: `XX.XX`, no currency symbol. From the per (client ×
  project) `billout_amount_usd` sum — does NOT apply the project-
  level billout adjustment (ADR #040). If you want adjusted amounts
  in the destination, edit there or use a manual_total_usd workflow.
- **Date**: the invoice's `invoice_date` (YYYY-MM-DD). Falls back to
  `defaultInvoiceDate()` (last day of previous month) if NULL.
- Implementation: `formatPasteBlock(breakdown, date)` in
  `lib/invoices.ts`. Tested by INV-P-001 … INV-P-007.

## Constraints & edge cases
- One invoice per entry. The schema enforces no opinion on
  uniqueness, but the bulk-apply action filters `.is('invoice_id',
  null)` so re-applying a row to a different invoice silently skips
  it. To move a row between invoices: detach first, then apply.
- The manual total override only affects the displayed Billout
  total + margin. Per-row `billout_amount_usd` stamps stay honest
  (locked-at-write per ADR #014).
- `paid` invoices are hidden from the bulk-apply dropdown by default
  (open + sent only). To re-apply onto a paid invoice, edit its
  status back to sent first.
- Detach is explicit-selection-only. "Select all matching" mode
  doesn't combine with Detach to avoid accidentally unlocking
  thousands of rows by URL manipulation.
- The detail page sums attached entries at read time (RLS gates
  visibility, so view-as filters the sum correctly).

## Permissions
- **Owners and super admins** (not in view-as): create / edit /
  delete invoices, apply entries, detach entries. Server-side check
  in `canTransfer(eu)`.
- **Managers / Adi**: can SEE invoices via RLS but the nav link is
  owner-only. Direct URL access returns the page but without the
  Edit / Delete / Apply / Detach buttons.

## Tests
- 🟡 INV-001 Creating an invoice with a duplicate name (case-insensitive) errors.
- 🟡 INV-002 Applying entries stamps invoice_id + invoice_applied_at + invoice_applied_by.
- 🟡 INV-003 Detaching clears all three.
- 🟡 INV-004 Status transition to `sent` stamps sent_at; `paid` stamps paid_at; round-trip preserves the original timestamps.
- 🟡 INV-005 Delete invoice detaches attached entries (they become unlocked).
- 🟡 INV-006 Manual total override is reflected in the displayed Billout + margin.
- 🟡 INV-007 Per (client × project) breakdown sums match per-entry billout_amount_usd.
- ✅ INV-D-001 … INV-D-004 — `defaultInvoiceDate()` returns last day
  of previous month (incl. leap-year + January edge cases).
- ✅ INV-P-001 … INV-P-007 — `formatPasteBlock()` produces tab-
  separated lines with the 7-column PlusROI shape, NULL-safe
  client/project, no `$` on amounts, 5th column empty.

## Open considerations
- No PDF / printable invoice yet. Likely follow-on once the data
  model is settled.
- Detach currently doesn't show a banner with the count — adopting
  the same `?info=` pattern as Apply would be a 5-minute follow-up.
- The list page totals query (`select billout_amount_usd from
  time_entries where invoice_id is not null`) doesn't scale to tens
  of thousands of entries. A per-invoice rollup or an RPC would be
  the obvious refactor at that point.
- No "send" / "mark paid" buttons distinct from the edit form. If
  status transitions become frequent, one-click actions might help.
- Currency is fixed (USD). Multi-currency is a deferred problem.

## Changelog
- **2026-05-25** — Initial invoices feature. Replaces /transfer as the
  primary lock action. Legacy backfill creates a "Legacy transfers"
  invoice per org and links pre-migration transferred entries to it.
  See decisions.md #038.
- **2026-05-25** — Added `invoices.invoice_date` column (separate
  from `created_at`). Defaults at create-time to the last day of the
  previous month via `defaultInvoiceDate()`. Surfaced in header +
  edit form + list table.
- **2026-05-25** — **Paste block** on the detail page — tab-
  separated text for the legacy PlusROI sheet. One line per (client
  × project) breakdown row, format spec'd above. Copy-to-clipboard
  button via the `PasteBlock` client component.
