"INV invoice substrate — draft invoices from TIME per-cycle rollup with rate-card snapshot preservation + closed enums + lifecycle FSM + per-line traceability"
§1 — Description (BCP-14 normative)
The INV service MUST ship invoice substrate at services/inv/src/ with 5 migrations, 8-state status FSM, append-only line corrections, rate-card snapshot, per-engagement scoping, gap-free per-tenant numbering, CFO-gated write-off, and 9 memory audit kinds. Anchors all downstream INV tasks (002-011) and cross-module invoice references.
- MUST define closed
invoice_statusenum:('draft','ready_for_review','approved','sent','partially_paid','paid','void','written_off')per DEC-1361. Cardinality 8.
- MUST define closed
invoice_line_kindenum:('time_entry','fixed_fee','expense_reimbursement','discount','tax','late_fee')per DEC-1362. Cardinality 6.
- MUST define
invoicesat migration0001:(invoice_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, engagement_id UUID NOT NULL, invoice_number TEXT UNIQUE NOT NULL, status invoice_status NOT NULL DEFAULT 'draft', billing_currency billing_currency_enum NOT NULL, issued_at TIMESTAMPTZ, due_at TIMESTAMPTZ, sent_at TIMESTAMPTZ, paid_at TIMESTAMPTZ, voided_at TIMESTAMPTZ, written_off_at TIMESTAMPTZ, billing_period_start TIMESTAMPTZ, billing_period_end TIMESTAMPTZ, client_subject_id UUID, rate_card_snapshot JSONB NOT NULL, total_pre_tax_minor BIGINT NOT NULL DEFAULT 0, total_tax_minor BIGINT NOT NULL DEFAULT 0, total_minor BIGINT NOT NULL DEFAULT 0, paid_minor BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by_subject_id UUID NOT NULL, trace_id CHAR(32)). Append-only on mutation of lines (status, amount fields updated through state-machine handlers only).
- MUST define
invoice_linesat migration0002:(line_id UUID PRIMARY KEY, invoice_id UUID NOT NULL REFERENCES invoices(invoice_id), line_kind invoice_line_kind NOT NULL, description TEXT NOT NULL, quantity NUMERIC(18,4) NOT NULL, unit_price_minor BIGINT NOT NULL, amount_minor BIGINT NOT NULL, vat_rate_pct NUMERIC(5,2) NOT NULL DEFAULT 0, source_kind TEXT NOT NULL CHECK (source_kind IN ('time_entry','expense','manual','correction','discount_policy')), source_ref UUID, sort_order INT NOT NULL, correction_of_line_id UUID REFERENCES invoice_lines(line_id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), trace_id CHAR(32)). Append-only via REVOKE UPDATE/DELETE per task-audit skill rule 12 + DEC-1366; correction = new row referencing original.
- MUST define
invoice_status_historyat migration0003:(id BIGSERIAL PRIMARY KEY, invoice_id UUID NOT NULL REFERENCES invoices(invoice_id), from_status invoice_status, to_status invoice_status NOT NULL, transitioned_at TIMESTAMPTZ NOT NULL DEFAULT now(), transitioned_by_subject_id UUID NOT NULL, reason TEXT, trace_id CHAR(32)). Append-only.
- MUST define
invoice_number_sequenceat migration0004:(tenant_id UUID NOT NULL, year INT NOT NULL, last_sequence INT NOT NULL DEFAULT 0, notes JSONB NOT NULL DEFAULT '[]'::jsonb, PRIMARY KEY (tenant_id, year)). Gap-free per-tenant annual; skipped sequences logged innotesfor Decree-123 conformance (slice-2 hóa đơn integration).
- MUST define
rate_card_snapshottable at migration0005storing per-engagement rate-card versions:(snapshot_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, engagement_id UUID NOT NULL, version INT NOT NULL, rates_jsonb JSONB NOT NULL, effective_from TIMESTAMPTZ NOT NULL DEFAULT now(), created_by_subject_id UUID NOT NULL, UNIQUE(engagement_id, version)). Versioned rate cards; invoices snapshot the latest at creation time.
- MUST enforce RLS with USING and WITH CHECK on all 5 tables:
tenant_id = current_setting('auth.tenant_id')::uuid.
- MUST expose draft creation
POST /v1/inv/invoices/draftbody{ engagement_id, billing_period_start, billing_period_end }. Handler:
- Validates engagement_id in tenant scope.
- Resolves billing currency from engagement.
- Allocates invoice number per §1 #10.
- Snapshots current rate card per §1 #11.
- Aggregates unbilled TIME entries via TASK-TIME-009 rollup into
invoice_linesrows. - Computes totals.
- Marks TIME entries
invoiced_at = now()to prevent double-billing. - INSERTs invoices + invoice_lines + invoice_status_history (status='draft').
- Emits
inv.draft_createdsev-2.
- MUST allocate invoice number per DEC-1367 via
numbering/mod.rs:
SELECT last_sequence FROM invoice_number_sequence WHERE tenant_id=$1 AND year=$2 FOR UPDATE.last_sequence + 1.INVOICE_NUMBER = "INV-" + tenant_slug + "-" + (year%100) + "-" + ZeroPad(seq, 6).- UPDATE sequence.
- On rollback after allocation: record skipped number in
notesJSONB for Decree-123 audit clarity (matches TASK-TEN-102 §1 #17 pattern).
- MUST snapshot rate card per DEC-1363. The
snapshot/rate_card.rs::snapshot_for_engagement(engagement_id):
- Resolves latest active rate-card version.
- Deep-copies into
invoices.rate_card_snapshotJSONB. - Records snapshot version reference for traceability.
- MUST validate state-machine transitions per DEC-1361 via
status/state_machine.rs. Allowed transitions:
draft → ready_for_review(any user can flip own draft).ready_for_review → approved(cfo OR engagement_admin only per DEC-1370).approved → sent(auto on send action OR manual).sent → partially_paid(auto on partial payment receipt via TASK-INV-003/005).sent | partially_paid → paid(auto on full payment).draft | ready_for_review | approved → void(cfo only; with reason).sent | partially_paid → written_off(cfo only; with reason per DEC-1371). Invalid transition → 400invalid_status_transition.
- MUST support append-only corrections per DEC-1366.
POST /v1/inv/invoices/{id}/lines/correctionbody{ correction_of_line_id, line_kind, description, quantity, unit_price_minor, amount_minor, vat_rate_pct, reason }. Handler:
- Validates
correction_of_line_idexists + same invoice. - INSERTs new line with
source_kind='correction'+correction_of_line_idpopulated. - Recomputes invoice totals.
- Emits
inv.correction_addedsev-2.
- MUST support approve action
POST /v1/inv/invoices/{id}/approve. Caller hascfoORengagement_admin. Validates transition; INSERTs status history; UPDATEs status; emitsinv.approvedsev-1 (material commercial event).
- MUST support send action
POST /v1/inv/invoices/{id}/sendbody{ delivery_method, delivery_target }. Caller hascfoORengagement_admin. Validates status='approved'; transitions to 'sent'; setssent_at; emitsinv.sentsev-1 (triggers TASK-INV-007 VN hóa đơn for VN tenants).
- MUST support write-off
POST /v1/inv/invoices/{id}/write-offbody{ reason }. Caller hascfoonly. Records reason; transitions to 'written_off'; emitsinv.written_offsev-1.
- MUST support void
POST /v1/inv/invoices/{id}/voidbody{ reason }. Caller hascfoonly. Validates status NOT IN ('sent','partially_paid','paid','written_off'); transitions to 'void'; emitsinv.voidsev-1.
- MUST auto-generate drafts at billing-cycle boundaries per DEC-1369 via
draft/scheduler.rs::run_for_engagement(engagement_id, cycle_end). Scheduled job runs hourly; selects engagements whose cycle ends in past hour; creates draft if unbilled TIME entries exist + engagement hasauto_draft_enabled=true.
- MUST scope invoice to ONE engagement per DEC-1368. Cross-engagement consolidation = anti-pattern; deferred.
- MUST store amounts as BIGINT minor per task-audit skill rule 11. NEVER FLOAT.
- MUST mark TIME entries
invoiced_atto prevent double-billing per DEC-1369 derivative. TASK-TIME-009 rollup filter excludes entries with non-NULLinvoiced_at.
- MUST emit 9 memory audit kinds per DEC-1374:
inv.draft_created(sev-2)inv.lines_added(sev-3)inv.status_transitioned(sev-2)inv.approved(sev-1)inv.sent(sev-1)inv.paid(sev-1)inv.void(sev-1)inv.written_off(sev-1)inv.correction_added(sev-2)
- MUST PII-scrub description + reason via TASK-MEMORY-111 — SHA256 in chain.
- MUST thread trace_id end-to-end.
- MUST NOT mutate invoice_lines after approval per DEC-1366 — correction-only path.
- MUST NOT allow gap in invoice numbering per DEC-1367 — skipped numbers documented in notes JSONB.
- MUST NOT allow non-cfo write-off per DEC-1371.
§2 — Why this design (rationale)
Why rate-card snapshot (§1 #11, DEC-1363)? Rates change. An invoice generated last quarter at $X/hr stays at $X/hr regardless of subsequent rate hikes. Live reference = re-invoicing legally indefensible (client paid based on a number we can no longer reproduce).
Why append-only corrections (§1 #13, DEC-1366)? Post-approval edits silently changing past invoices = fraud signal in any audit. Correction lines (with reason) maintain the original AND the fix AND the auditor's ability to see the delta.
Why gap-free numbering (§1 #10, DEC-1367)? Decree 123 + general accounting principle — gaps suggest hidden transactions. Skip-with-reason logged in notes satisfies tax-authority audit while permitting transaction rollback.
Why ONE engagement per invoice (§1 #19, DEC-1368)? Multi-engagement invoices conflate client billing relationships; "Acme paid invoice X — for which engagement?" becomes ambiguous. Engagement-scoped invoices = clean audit + clean reporting.
Why CFO-only write-off (§1 #16, DEC-1371)? Write-off = revenue erasure with tax implications. Requires CFO sign-off both for accounting control (prevent fraud) and for tax-treatment correctness.
Why 8-state FSM vs simpler 4-state (§1 #12)? Financial workflows need draft/review/approved/sent/partially_paid/paid as distinct states for status reporting + automation triggers. void + written_off cover the two distinct "no payment received" terminal cases (cancelled-by-us vs uncollectable).
§3 — API contract
-- 0001_invoices.sql
CREATE TYPE invoice_status AS ENUM ('draft','ready_for_review','approved','sent','partially_paid','paid','void','written_off');
CREATE TABLE invoices (
invoice_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
engagement_id UUID NOT NULL,
invoice_number TEXT UNIQUE NOT NULL,
status invoice_status NOT NULL DEFAULT 'draft',
billing_currency billing_currency_enum NOT NULL,
issued_at TIMESTAMPTZ,
due_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
voided_at TIMESTAMPTZ,
written_off_at TIMESTAMPTZ,
billing_period_start TIMESTAMPTZ,
billing_period_end TIMESTAMPTZ,
client_subject_id UUID,
rate_card_snapshot JSONB NOT NULL,
total_pre_tax_minor BIGINT NOT NULL DEFAULT 0,
total_tax_minor BIGINT NOT NULL DEFAULT 0,
total_minor BIGINT NOT NULL DEFAULT 0,
paid_minor BIGINT NOT NULL DEFAULT 0,
notes TEXT,
internal_notes TEXT,
sync_class TEXT NOT NULL DEFAULT 'client-visible' CHECK (sync_class IN ('private','team-internal','client-visible','client-visible-redacted')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by_subject_id UUID NOT NULL,
trace_id CHAR(32)
);
CREATE INDEX idx_inv_engagement ON invoices(engagement_id, status);
CREATE INDEX idx_inv_status_due ON invoices(status, due_at) WHERE status NOT IN ('paid','void','written_off');
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY invoices_rls ON invoices
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE DELETE ON invoices FROM cyberos_app;
GRANT UPDATE (status, issued_at, due_at, sent_at, paid_at, voided_at, written_off_at,
total_pre_tax_minor, total_tax_minor, total_minor, paid_minor, notes) ON invoices TO cyberos_app;
-- 0002_invoice_lines.sql
CREATE TYPE invoice_line_kind AS ENUM ('time_entry','fixed_fee','expense_reimbursement','discount','tax','late_fee');
CREATE TABLE invoice_lines (
line_id UUID PRIMARY KEY,
invoice_id UUID NOT NULL REFERENCES invoices(invoice_id),
line_kind invoice_line_kind NOT NULL,
description TEXT NOT NULL,
quantity NUMERIC(18,4) NOT NULL,
unit_price_minor BIGINT NOT NULL,
amount_minor BIGINT NOT NULL,
vat_rate_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
source_kind TEXT NOT NULL CHECK (source_kind IN ('time_entry','expense','manual','correction','discount_policy')),
source_ref UUID,
sort_order INT NOT NULL,
correction_of_line_id UUID REFERENCES invoice_lines(line_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
trace_id CHAR(32)
);
CREATE INDEX idx_inv_lines_invoice ON invoice_lines(invoice_id, sort_order);
ALTER TABLE invoice_lines ENABLE ROW LEVEL SECURITY;
CREATE POLICY invoice_lines_rls ON invoice_lines
USING (invoice_id IN (SELECT invoice_id FROM invoices WHERE tenant_id = current_setting('auth.tenant_id')::uuid))
WITH CHECK (invoice_id IN (SELECT invoice_id FROM invoices WHERE tenant_id = current_setting('auth.tenant_id')::uuid));
REVOKE UPDATE, DELETE ON invoice_lines FROM cyberos_app;
-- 0003_invoice_status_history.sql
CREATE TABLE invoice_status_history (
id BIGSERIAL PRIMARY KEY,
invoice_id UUID NOT NULL REFERENCES invoices(invoice_id),
from_status invoice_status,
to_status invoice_status NOT NULL,
transitioned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
transitioned_by_subject_id UUID NOT NULL,
reason TEXT,
trace_id CHAR(32)
);
CREATE INDEX idx_inv_history_invoice ON invoice_status_history(invoice_id, transitioned_at);
ALTER TABLE invoice_status_history ENABLE ROW LEVEL SECURITY;
CREATE POLICY invoice_status_history_rls ON invoice_status_history
USING (invoice_id IN (SELECT invoice_id FROM invoices WHERE tenant_id = current_setting('auth.tenant_id')::uuid))
WITH CHECK (invoice_id IN (SELECT invoice_id FROM invoices WHERE tenant_id = current_setting('auth.tenant_id')::uuid));
REVOKE UPDATE, DELETE ON invoice_status_history FROM cyberos_app;
-- 0004_invoice_number_sequence.sql
CREATE TABLE invoice_number_sequence (
tenant_id UUID NOT NULL,
year INT NOT NULL,
last_sequence INT NOT NULL DEFAULT 0,
notes JSONB NOT NULL DEFAULT '[]'::jsonb,
PRIMARY KEY (tenant_id, year)
);
ALTER TABLE invoice_number_sequence ENABLE ROW LEVEL SECURITY;
CREATE POLICY invoice_number_sequence_rls ON invoice_number_sequence
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE UPDATE, DELETE ON invoice_number_sequence FROM cyberos_app;
GRANT UPDATE (last_sequence, notes) ON invoice_number_sequence TO cyberos_app;
-- 0005_rate_card_snapshot.sql
CREATE TABLE rate_card_snapshot (
snapshot_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
engagement_id UUID NOT NULL,
version INT NOT NULL,
rates_jsonb JSONB NOT NULL,
effective_from TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by_subject_id UUID NOT NULL,
UNIQUE (engagement_id, version)
);
ALTER TABLE rate_card_snapshot ENABLE ROW LEVEL SECURITY;
CREATE POLICY rate_card_snapshot_rls ON rate_card_snapshot
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE UPDATE, DELETE ON rate_card_snapshot FROM cyberos_app;
Endpoints:
POST /v1/inv/invoices/draft (engagement_admin or cfo)
POST /v1/inv/invoices/{id}/lines/correction (cfo or engagement_admin)
POST /v1/inv/invoices/{id}/approve (cfo or engagement_admin)
POST /v1/inv/invoices/{id}/send (cfo or engagement_admin)
POST /v1/inv/invoices/{id}/void (cfo)
POST /v1/inv/invoices/{id}/write-off (cfo)
GET /v1/inv/invoices/{id} (engagement_admin or cfo or client per sync_class)
GET /v1/inv/invoices?engagement_id=...&status=... (engagement_admin or cfo)
§4 — Acceptance criteria
- invoice_status cardinality 8.
- invoice_line_kind cardinality 6.
- Draft from TIME rollup — unbilled TIME entries aggregated into invoice_lines; entries marked
invoiced_at. - Rate-card snapshot immutable — invoice created; rate card subsequently changed; invoice retains original rates in snapshot.
- Append-only lines — UPDATE on
invoice_linesraises permission-denied. - Correction line — POST correction creates new line with
correction_of_line_idpopulated; totals recomputed. - Status FSM transitions —
draft → ready_for_review → approved → sent → paidlegal; reverse illegal. - Approval requires cfo or engagement_admin — tenant_admin → 403.
- Write-off requires cfo — engagement_admin write-off → 403.
- Write-off requires reason — empty reason → 400.
- Invoice number format —
INV-acme-26-000001for first tenant=acme invoice in 2026. - Gap-free numbering — 1000 concurrent draft creations → 1000 unique numbers no duplicates.
- Skipped numbers logged — rollback after allocation → skip recorded in notes.
- Per-engagement scope — invoice has exactly one engagement_id.
- TIME entries not double-billed — second draft generation on same period → 0 lines (all entries invoiced_at).
- Status history append-only — every transition recorded; UPDATE on history → permission-denied.
- 9 memory audit kinds emitted — full lifecycle.
- Trace_id end-to-end.
- BIGINT minor amounts — schema validation; never FLOAT.
- RLS cross-tenant denied — tenant A invoice invisible to tenant B.
§5 — Verification
#[tokio::test]
async fn draft_from_time_rollup() {
let ctx = TestContext::with_engagement_and_time_entries().await;
let r = ctx.post_draft(ctx.eng_id, ctx.period_start, ctx.period_end).await;
assert_eq!(r.status(), 201);
let body: serde_json::Value = r.json().await.unwrap();
let inv_id: Uuid = body["invoice_id"].as_str().unwrap().parse().unwrap();
let lines: Vec<(String,)> = sqlx::query_as("SELECT description FROM invoice_lines WHERE invoice_id=$1")
.bind(inv_id).fetch_all(&ctx.pool).await.unwrap();
assert!(lines.len() >= 1);
let invoiced_count: i64 = sqlx::query_scalar(
"SELECT count(*) FROM time_entries WHERE engagement_id=$1 AND invoiced_at IS NOT NULL"
).bind(ctx.eng_id).fetch_one(&ctx.pool).await.unwrap();
assert!(invoiced_count > 0);
}
#[tokio::test]
async fn rate_card_snapshot_immutable() {
let ctx = TestContext::with_engagement_and_time_entries().await;
let inv_id = ctx.create_draft().await;
let snapshot1: serde_json::Value = sqlx::query_scalar(
"SELECT rate_card_snapshot FROM invoices WHERE invoice_id=$1"
).bind(inv_id).fetch_one(&ctx.pool).await.unwrap();
ctx.bump_rate_card(ctx.eng_id, /*new rate*/ 200_00).await;
let snapshot2: serde_json::Value = sqlx::query_scalar(
"SELECT rate_card_snapshot FROM invoices WHERE invoice_id=$1"
).bind(inv_id).fetch_one(&ctx.pool).await.unwrap();
assert_eq!(snapshot1, snapshot2);
}
#[tokio::test]
async fn append_only_corrections() {
let ctx = TestContext::with_invoice().await;
let r = sqlx::query("UPDATE invoice_lines SET amount_minor=999 WHERE invoice_id=$1")
.bind(ctx.inv_id).execute(&ctx.pool).await;
assert!(r.is_err());
let r = ctx.post_correction(ctx.inv_id, ctx.line_id, /*amount*/ 500_00, "rate correction").await;
assert_eq!(r.status(), 201);
let line_count: i64 = sqlx::query_scalar("SELECT count(*) FROM invoice_lines WHERE invoice_id=$1")
.bind(ctx.inv_id).fetch_one(&ctx.pool).await.unwrap();
assert_eq!(line_count, 2); // original + correction
}
#[tokio::test]
async fn status_fsm_enforces_transitions() {
let ctx = TestContext::with_invoice().await;
let r = ctx.transition_invoice(ctx.inv_id, "paid").await; // draft → paid skip
assert_eq!(r.status(), 400);
ctx.transition_invoice(ctx.inv_id, "ready_for_review").await;
ctx.as_cfo().transition_invoice(ctx.inv_id, "approved").await;
ctx.transition_invoice(ctx.inv_id, "sent").await;
let r = ctx.transition_invoice(ctx.inv_id, "paid").await;
assert_eq!(r.status(), 200);
}
#[tokio::test]
async fn write_off_cfo_only_with_reason() {
let ctx = TestContext::with_sent_invoice().await;
let r = ctx.as_engagement_admin().post_write_off(ctx.inv_id, "uncollectable").await;
assert_eq!(r.status(), 403);
let r = ctx.as_cfo().post_write_off(ctx.inv_id, "").await;
assert_eq!(r.status(), 400);
let r = ctx.as_cfo().post_write_off(ctx.inv_id, "uncollectable").await;
assert_eq!(r.status(), 200);
}
#[tokio::test]
async fn invoice_number_gap_free() {
let ctx = TestContext::new().await;
let nums: Vec<String> = futures::stream::iter(0..100).then(|_| async {
let r = ctx.post_draft_minimal().await;
r.json::<serde_json::Value>().await.unwrap()["invoice_number"].as_str().unwrap().to_owned()
}).collect::<Vec<_>>().await;
let unique: std::collections::HashSet<_> = nums.iter().cloned().collect();
assert_eq!(unique.len(), nums.len());
}
// 5.7..5.12: enum cardinality, per-engagement scope, TIME no-double-bill, status history, audit emission, RLS
§7 — Dependencies
Upstream: TASK-TIME-009 (per-cycle rollup source). Cross-module: TASK-INV-002 (multi-currency), TASK-INV-003 (Stripe payment), TASK-INV-005 (VietQR), TASK-INV-006 (cash app), TASK-INV-007 (VN hóa đơn), TASK-TEN-003 (Stripe ref), TASK-TEN-102 (VND ref), TASK-PORTAL-001 (invoices view), TASK-PORTAL-006 (billing_inquiry workflows), TASK-CRM-001 (client subject), TASK-AUTH-101 (cfo + engagement_admin roles), TASK-AI-003, TASK-MEMORY-111, TASK-OBS-007. Downstream: TASK-INV-002 through TASK-INV-011.
§8 — Example payload
inv.draft_created:
{
"kind": "inv.draft_created",
"severity": 2,
"tenant_id": "8a2f...",
"actor_id": "user.engagement_admin.456",
"trace_id": "...",
"occurred_at": "2026-05-17T...",
"payload": {
"invoice_id": "0190...",
"invoice_number": "INV-acme-26-000042",
"engagement_id": "0190...",
"billing_currency": "USD",
"billing_period_start": "2026-04-17T00:00:00Z",
"billing_period_end": "2026-05-17T00:00:00Z",
"line_count": 12,
"total_minor": 875_000,
"rate_card_version": 3
}
}
§9 — Open questions
Deferred:
- Deferred: Multi-currency per invoice (slice 2 / TASK-INV-002).
- Deferred: Subscription-style recurring invoice templates (slice 3).
- Deferred: Customer-facing payment link in sent email (slice 2).
- Deferred: Late-fee auto-calc + late_fee line generation (slice 3).
- Deferred: Multi-engagement consolidated invoicing (anti-pattern; not planned).
§10 — Failure modes inventory
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| TIME entry already invoiced | filter excludes invoiced_at | Skipped from draft | Inherent |
| Rate card missing for engagement | snapshot fails | 412 + no_rate_card_configured | Engagement_admin configures |
| Invalid status transition | FSM check | 400 + invalid_status_transition | Caller fixes flow |
| Approve without chief-financial-officer/engagement_admin role | check | 403 | Inherent |
| Write-off without CFO | check | 403 | Inherent |
| Write-off without reason | validation | 400 | Inherent |
| Numbering rollback | exception in INSERT after sequence allocated | Skip logged in notes | Inherent forensic |
| Duplicate invoice_number (race) | UNIQUE constraint | Re-attempt with next sequence | Inherent retry |
| Correction reference invalid | FK check | 400 | Caller fixes line_id |
| Cross-tenant invoice access | RLS | 0 rows | Inherent |
| Concurrent line addition pre-approval | tx isolation | Last writer wins | Inherent |
| Multi-currency on single invoice | engagement.billing_currency immutable | Inherent prevention | New engagement required |
| Send before approval | FSM check | 400 + invalid transition | Approve first |
| Void after send | FSM check | 400 | Use write-off instead |
| Negative total computed | balance check | sev-2 alert | Operator review |
| Status history insert fails post-transition | tx isolation | Rollback; status unchanged | Inherent |
| TIME entry deleted after invoiced | source_ref orphan | Line retained with description; TASK-TIME-009 prevents delete after invoiced_at | Inherent guard |
| Rate card snapshot JSONB > 100 KB | size limit | sev-2; investigate rate-card explosion | Slim rate card |
| Approval after period close | period_close hooks | Allowed; backfills supported | Inherent |
| Invoice viewed by client with internal_notes leaked | sync_class filter via PORTAL-001 | Internal hidden | PORTAL-001 enforces |
§11 — Implementation notes
§11.1 Rate-card snapshot deep-copy via serde_json::to_value(&rate_card) ensures complete capture.
§11.2 TIME-side invoiced_at column added via migration in this task's modified_files.
§11.3 Numbering sequence uses Postgres FOR UPDATE row lock to prevent race; skipped logged via post-rollback hook.
§11.4 State machine encoded as Rust match table; CI test asserts every (from, to) pair documented.
§11.5 Per-engagement billing currency consumed from engagements.billing_currency; immutable once set (TASK-TEN-003 derivative).
§11.6 Cross-line totals computed at write-time (not view-time) for query simplicity.
§11.7 Append-only at SQL grant level prevents handler bypass.
§11.8 Status_history from_status is NULL on initial create row.
§11.9 PORTAL-001 view of invoices applies sync_class filter to hide internal fields.
§11.10 Draft auto-scheduler runs hourly; engagement.auto_draft_enabled gates participation.
End of TASK-INV-001 spec.