"TIME expense capture — photo → AWS Textract OCR → hóa đơn parser → Member confirm + categorisation + invoice integration"
§1 — Description (BCP-14 normative)
The TIME service MUST ship expense capture pipeline at services/time/src/expense/ with photo upload → async OCR via TASK-MCP-007 → hóa đơn or generic parsing → Member confirm → policy validation → optional invoice attach, 7-kind enum, 5-state status enum, and 6 memory audit kinds.
- MUST define closed
expense_kindenum:('meal','transport','accommodation','supplies','communication','other_billable','other_non_billable')per DEC-1451. Cardinality 7.
- MUST define closed
expense_statusenum:('pending_ocr','pending_member_confirm','confirmed','rejected','invoiced')per DEC-1452. Cardinality 5.
- MUST define
expensestable at migration0009: full row with photo_s3_key, ocr_raw_jsonb, parsed_fields_jsonb (merchant, total_minor, currency, issued_at, vat_amount_minor, mst), confirmed_fields_jsonb (Member-edited final), kind, status, member_subject_id, engagement_id, billable flag, invoice_line_id (when invoiced), trace_id.
- MUST define
expense_policiestable at migration0010per DEC-1454: per (engagement, kind) → max_amount_minor, requires_receipt boolean, requires_approval_above_minor, default_billable.
- MUST expose
POST /v1/time/expenses/uploadbody{ engagement_id, kind, expected_currency }. Handler:
- Returns TASK-DOC-001 presigned S3 URL.
- Creates expense row status='pending_ocr'.
- Emits
time.expense_capturedsev-3.
- MUST trigger async OCR via TASK-MCP-007 Tasks on photo-upload-complete webhook from S3:
- Task invokes AWS Textract
AnalyzeDocumentwithFORMS+TABLESfeatures. - Parses via §1 #7 or §1 #8.
- Transitions status='pending_member_confirm'.
- Notifies Member (push via TASK-PORTAL-007 PWA push).
- Emits
time.expense_ocr_completedsev-3 ORtime.expense_ocr_failedsev-2.
- MUST parse hóa đơn (VN tenants) per DEC-1456 via
hoadon_parser.rs:
- Extract MST: regex
(?:MST|Mã số thuế)[: ]*(\d{10,13}). - Extract total VND: regex over amount-formatted fields.
- Extract issued_at: DD/MM/YYYY pattern.
- Extract supplier_name: header line.
- Extract VAT: 10% line item.
- Confidence scores per field.
- MUST parse generic receipt (non-VN tenants) per DEC-1456 via
generic_parser.rs. Universal fields: merchant_name, total, currency, date.
- MUST require Member confirm per DEC-1453.
POST /v1/time/expenses/{id}/confirmbody{ confirmed_fields, kind_override?, billable_override? }. Handler:
- Validates expense status='pending_member_confirm'.
- Persists Member-edited final values.
- Policy check per §1 #10.
- Transitions status='confirmed'.
- Emits
time.expense_confirmedsev-2.
- MUST validate against engagement policy per DEC-1454. If
total_minor > policy.max_amount_minor→ return 412 +policy_cap_exceeded. If> policy.requires_approval_above_minor→ status remains 'pending_member_confirm' withrequires_admin_approval=truefield; engagement_admin approves separately.
- MUST support reject
POST /v1/time/expenses/{id}/rejectbody{ reason }. Transitions status='rejected'. Emitstime.expense_rejectedsev-3.
- MUST support invoice attach
POST /v1/time/expenses/{id}/attach-to-invoicebody{ invoice_id }per DEC-1457 derivative. Caller hascfoorengagement_admin. Handler:
- Validates expense status='confirmed' + billable=true.
- Validates invoice status='draft' or 'ready_for_review'.
- Creates invoice_line row (line_kind='expense_reimbursement') via TASK-INV-001.
- Transitions expense status='invoiced' + populates invoice_line_id.
- Emits
time.expense_invoicedsev-2.
- MUST emit 6 memory audit kinds per DEC-1457. PII-scrub merchant/supplier name via TASK-MEMORY-111 — hash only in chain.
- MUST thread trace_id end-to-end.
- MUST NOT persist without Member confirm (per DEC-1453).
- MUST NOT auto-bill > policy cap (per DEC-1454).
§2 — Why this design (rationale)
Why Member confirm mandatory (§1 #9, DEC-1453)? OCR errors are common (~5-15% field accuracy issues). Auto-billing wrong amounts to clients = trust + revenue + legal risk. Human-in-loop gate.
Why TASK-MCP-007 async (§1 #6, DEC-1455)? Textract analyse is 5-30s; sync would tie up gateway workers. Tasks primitive fits.
Why VN-specific parser (§1 #7, DEC-1456)? Hóa đơn fields are regulatory-defined (Decree 123); generic OCR misses MST + VAT structure. VN parser produces tax-compliant data; generic parser is fallback.
Why per-engagement policies (§1 #10, DEC-1454)? Different engagements have different reimbursement rules. Per-engagement config respects this; without it, one engagement's rules apply to all (wrong).
§3 — API contract
-- 0009_expenses.sql
CREATE TYPE expense_kind AS ENUM ('meal','transport','accommodation','supplies','communication','other_billable','other_non_billable');
CREATE TYPE expense_status AS ENUM ('pending_ocr','pending_member_confirm','confirmed','rejected','invoiced');
CREATE TABLE expenses (
expense_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
member_subject_id UUID NOT NULL,
engagement_id UUID NOT NULL,
kind expense_kind NOT NULL,
status expense_status NOT NULL DEFAULT 'pending_ocr',
photo_s3_key TEXT NOT NULL,
ocr_raw_jsonb JSONB,
parsed_fields_jsonb JSONB,
confirmed_fields_jsonb JSONB,
total_minor BIGINT,
currency billing_currency_enum,
issued_at TIMESTAMPTZ,
merchant_name TEXT,
is_billable BOOLEAN,
invoice_line_id UUID,
requires_admin_approval BOOLEAN NOT NULL DEFAULT false,
reject_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
confirmed_at TIMESTAMPTZ,
invoiced_at TIMESTAMPTZ,
trace_id CHAR(32)
);
ALTER TABLE expenses ENABLE ROW LEVEL SECURITY;
CREATE POLICY expenses_rls ON expenses
USING (tenant_id = current_setting('auth.tenant_id')::uuid
AND (member_subject_id = current_setting('auth.subject_id')::uuid
OR EXISTS (SELECT 1 FROM subject_roles WHERE subject_id = current_setting('auth.subject_id')::uuid AND role IN ('engagement_admin','cfo'))))
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid
AND member_subject_id = current_setting('auth.subject_id')::uuid);
REVOKE DELETE ON expenses FROM cyberos_app;
GRANT UPDATE (status, ocr_raw_jsonb, parsed_fields_jsonb, confirmed_fields_jsonb,
total_minor, currency, issued_at, merchant_name, is_billable,
invoice_line_id, requires_admin_approval, reject_reason,
confirmed_at, invoiced_at) ON expenses TO cyberos_app;
-- 0010_expense_policies.sql
CREATE TABLE expense_policies (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
engagement_id UUID NOT NULL,
kind expense_kind NOT NULL,
max_amount_minor BIGINT,
requires_receipt BOOLEAN NOT NULL DEFAULT true,
requires_approval_above_minor BIGINT,
default_billable BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (engagement_id, kind)
);
ALTER TABLE expense_policies ENABLE ROW LEVEL SECURITY;
CREATE POLICY expense_policies_rls ON expense_policies
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE UPDATE, DELETE ON expense_policies FROM cyberos_app;
Endpoints:
POST /v1/time/expenses/upload (member)
POST /v1/time/expenses/{id}/confirm (member)
POST /v1/time/expenses/{id}/reject (member)
POST /v1/time/expenses/{id}/approve (engagement_admin)
POST /v1/time/expenses/{id}/attach-to-invoice (cfo or engagement_admin)
GET /v1/time/expenses?status=...&engagement_id=... (member or admin)
POST /v1/admin/engagements/{id}/expense-policy (engagement_admin)
§4 — Acceptance criteria
- expense_kind cardinality 7.
- expense_status cardinality 5.
- Upload returns presigned URL + status=pending_ocr.
- Async OCR completes — Textract result populates parsed_fields_jsonb.
- Hóa đơn MST extracted — VN-format receipt → MST in parsed_fields.
- Generic receipt fallback — non-VN photo → merchant/total/date extracted.
- Member confirm required — no persistence until confirm.
- Policy cap enforced — exceeds max → 412.
- Above approval threshold — requires_admin_approval=true; status stays pending.
- Invoice attach creates line — confirmed expense → invoice line via TASK-INV-001.
- Reject transitions — Member rejects → status=rejected.
- 6 memory audit kinds emitted.
- PII scrub merchant — name_hash16 in chain only.
- Trace_id end-to-end.
- RLS Member-scoped (engagement_admin/cfo broader).
- Photo upload max 25 MiB — S3 upload size limit.
- OCR failure path — Textract error → status remains pending_ocr; manual entry alternative.
- VN tenant uses hóa đơn parser — residency='vn-1' → hoadon_parser invoked.
- Non-VN uses generic — residency≠'vn-1' → generic_parser.
- Cross-tenant denied via RLS.
§5 — Verification
#[tokio::test]
async fn upload_returns_presigned_url() {
let ctx = TestContext::with_member().await;
let r = ctx.post_expense_upload(ctx.eng_id, "meal", "VND").await;
assert_eq!(r.status(), 201);
let body: serde_json::Value = r.json().await.unwrap();
assert!(body["upload_url"].as_str().unwrap().starts_with("https://"));
assert_eq!(body["status"], "pending_ocr");
}
#[tokio::test]
async fn vn_hoadon_parsed() {
let ctx = TestContext::with_vn_member().await;
let expense_id = ctx.upload_and_simulate_ocr_hoadon_image().await;
ctx.run_ocr_task(expense_id).await;
let parsed: serde_json::Value = sqlx::query_scalar("SELECT parsed_fields_jsonb FROM expenses WHERE expense_id=$1")
.bind(expense_id).fetch_one(&ctx.pool).await.unwrap();
assert!(parsed["mst"].is_string());
assert!(parsed["total_vnd"].is_number());
}
#[tokio::test]
async fn member_confirm_required_before_invoice() {
let ctx = TestContext::with_member().await;
let expense_id = ctx.complete_ocr().await;
let r = ctx.attach_to_invoice(expense_id, ctx.invoice_id).await;
assert_eq!(r.status(), 412); // not yet confirmed
ctx.member_confirm(expense_id, /*overrides*/ json!({})).await;
let r2 = ctx.attach_to_invoice(expense_id, ctx.invoice_id).await;
assert_eq!(r2.status(), 201);
}
#[tokio::test]
async fn policy_cap_blocks() {
let ctx = TestContext::with_meal_policy_cap(100_000).await;
let expense_id = ctx.complete_ocr_with_amount(150_000).await;
let r = ctx.member_confirm(expense_id, json!({})).await;
assert_eq!(r.status(), 412);
}
// 5.5..5.10: enum cardinality, reject, RLS, audit emit, OCR fail
§7 — Dependencies
Upstream: TASK-CRM-010 (engagement context). Cross-module: TASK-DOC-001 (S3 storage), TASK-MCP-007 (async OCR task), TASK-PORTAL-007 (push notification), TASK-INV-001 (invoice attach), TASK-AI-003, TASK-MEMORY-111.
§8 — Example payload
time.expense_ocr_completed:
{
"kind": "time.expense_ocr_completed",
"severity": 3,
"tenant_id": "8a2f...",
"actor_id": "system.time.ocr",
"trace_id": "...",
"payload": {
"expense_id": "0190...",
"member_subject_id_hash16": "f8a1...",
"parsed_total_minor": 125000,
"parsed_currency": "VND",
"merchant_name_hash16": "9c4e...",
"ocr_confidence_avg": 87
}
}
§9 — Open questions
Deferred:
- Deferred: Multi-receipt batch upload — slice 3.
- Deferred: Per-Member spending dashboard — slice 3.
- Deferred: Smart-categorise (ML kind prediction) — slice 3.
- Deferred: Foreign-currency auto-convert at upload — slice 3 (TASK-INV-002 derivative).
- Deferred: Mileage tracking (transport with start/end coords) — slice 3.
§10 — Failure modes inventory
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Textract API quota exceeded | API error | Status remains pending_ocr; sev-2 alert; manual fallback | Quota raised or batch later |
| Photo upload > 25 MiB | S3 size check | 413 | Member compresses |
| Photo not a receipt (random image) | confidence < 30% | Parsed_fields empty; Member edits manually | Inherent |
| MST extraction false-positive (10-digit string elsewhere) | confidence score | Member catches at confirm | Inherent |
| Currency mismatch with engagement | confirm validation | 400 + currency_mismatch | Member fixes |
| Policy cap retro-applied | check at confirm time | Inherent | Member splits or routes to non-billable |
| Approval required but engagement_admin unavailable | sev-3 reminder | Pending indefinitely until admin acts | Email reminder |
| Invoice attach with wrong currency | check at attach | 400 | Inherent |
| OCR task lost (TASK-MCP-007 crash) | task retry | Re-run | Inherent |
| Duplicate expense detection (same receipt twice) | hash photo bytes | Sev-3 alert; user prompted | Inherent |
| Multi-language receipt (English VN mix) | parser fallback | Generic parser tries first | Member edits |
| Hóa đơn lacks MST (informal receipt) | extraction null | Member fills | Inherent |
| Cross-tenant access | RLS | 403 | Inherent |
| Member subject_id changes (rare) | FK soft | Expense retained | Inherent forensic |
| Expense rejected after invoice attach | state check | 409 — already invoiced | Use correction_to via TASK-INV-001 |
| TASK-PORTAL-007 push not sent | best-effort | Email fallback via TASK-EMAIL-001 | Inherent |
| Concurrent confirm + reject | tx isolation | First wins | Inherent |
§11 — Implementation notes
§11.1 AWS Textract FORMS + TABLES features; per-page pricing.
§11.2 Hóa đơn parser regex maintained in const HOADON_PATTERNS; quarterly review.
§11.3 Confidence per field; aggregated for UI display.
§11.4 S3 photo expires 90d post-confirm (storage cost).
§11.5 Member confirm UI shows OCR result side-by-side with photo for verification.
§11.6 Policy check evaluates at confirm (not OCR) — allows kind change to affect policy.
§11.7 Invoice attach delegates to TASK-INV-001 line-add endpoint.
§11.8 Push notification via TASK-PORTAL-007 with payload { kind: "ocr_complete", expense_id }.
§11.9 PII: merchant + supplier names hashed; total amount retained in chain (financial context).
§11.10 Cross-tenant denied via RLS + explicit subject check.
End of TASK-TIME-008 spec.