{
  "slug": "expenses-module",
  "spec_file": "docs/specs/2026-05-30-expenses-module.md",
  "findings": [
    {
      "id": "expenses-module-001",
      "severity": "P0",
      "type": "bug",
      "summary": "Per-diem half-day amount is half the configured flat rate (rate × 0.5 days)",
      "spec_ref": "Per-Diem Rules — amount = rate × days; domestic_half_day default ₪50",
      "code_ref": "packages/expenses/src/per-diem.ts:31",
      "evidence": "Half-day path picks domestic_half_day (50) when days<=0.5 then computes amount=rate*days (50*0.5=25). UI mirrors at apps/zync-app/src/features/expenses/PerDiemForm.tsx:48 (durationType half → days=0.5, rate=domestic_half_day, calculatedAmount=rate*days). Config values are flat allowance tiers, not per-day multipliers.",
      "repro": "POST /api/expenses/per-diem {travel_type:'domestic', days:0.5, date:'2026-06-01'} → expense.amount='25.00' instead of ₪50."
    },
    {
      "id": "expenses-module-002",
      "severity": "P0",
      "type": "bug",
      "summary": "Foreign-currency OCR receipts store raw invoice_total in amount without ILS conversion",
      "spec_ref": "Processing Pipeline step 2 — amount := invoice_total normalized to ILS via multi-currency rate",
      "code_ref": "packages/expenses/src/ai/ocr.ts:98",
      "evidence": "When currency !== 'ILS', amount is set to String(parsed.invoice_total) with comment 'pass through; multi-currency rate resolved at display'. No getTenantCountryAdapter or exchange-rate call in packages/expenses/. Canonical amount column used by reports SUM() will be wrong currency units.",
      "repro": "Upload USD receipt → OCR sets currency='USD', amount equals USD total → expense report / PCN874 sums foreign units as ILS."
    },
    {
      "id": "expenses-module-003",
      "severity": "P0",
      "type": "security",
      "summary": "setExpenseStatus updates by expense id only — no tenant_id predicate",
      "spec_ref": "Plan Task 3 — all queries tenant-scoped via tenantQuery / tenant_id filter",
      "code_ref": "packages/db/src/queries/expenses.ts:433",
      "evidence": "UPDATE expenses SET ... WHERE eq(expenses.id, id) with no tenantId clause. Called from evaluate route and queue consumer after manual tenant checks, but the write helper itself allows cross-tenant row update if a future caller passes a foreign id.",
      "repro": "grep setExpenseStatus — only id-scoped WHERE at :436; no tenant_id in update predicate."
    },
    {
      "id": "expenses-module-004",
      "severity": "P0",
      "type": "security",
      "summary": "getExpenseById selects by primary key only — no tenant_id filter",
      "spec_ref": "Plan Task 3 — two tenants cannot read each other's expenses",
      "code_ref": "packages/db/src/queries/expenses.ts:224",
      "evidence": "SELECT ... WHERE eq(expenses.id, id) — no tenant guard. HTTP routes at apps/zync-api/src/routes/expenses/index.ts:329 and :378 compare row.tenantId to session.tid before acting, but the query helper returns any tenant's row to internal callers.",
      "repro": "Internal call getExpenseById(db, foreignTenantExpenseUuid) returns the row; safety depends entirely on each caller re-checking tenantId."
    },
    {
      "id": "expenses-module-005",
      "severity": "P1",
      "type": "missing",
      "summary": "PCN874 output VAT is hardcoded to zero — invoices VAT never queried",
      "spec_ref": "VAT Summary (PCN874) — Output VAT from invoices table",
      "code_ref": "packages/db/src/queries/expenses.ts:610",
      "evidence": "const outputVat = '0' with no join to invoices. invoices.vat_amount exists at packages/db/src/schema/invoices.ts:87; tax-reports.ts:70+ already aggregates output VAT for tenants.",
      "repro": "GET /api/expenses/reports/vat?from=2026-01-01&to=2026-01-31 → outputVat:'0', netVatDue ignores issued invoice VAT."
    },
    {
      "id": "expenses-module-006",
      "severity": "P1",
      "type": "missing",
      "summary": "Email forwarding intake handler exists but is never wired to a Worker/route",
      "spec_ref": "Inbound Channels — Email forwarding to expenses@{tenantSlug}.zync.is",
      "code_ref": "apps/zync-api/src/intake/email-expense.ts:41",
      "evidence": "grep handleExpenseEmail across repo (excl. worktrees) finds only definition in email-expense.ts and plan doc — zero imports or route/queue registrations.",
      "repro": "Forward receipt to expenses@{slug}.zync.is → no code path invokes handleExpenseEmail."
    },
    {
      "id": "expenses-module-007",
      "severity": "P1",
      "type": "missing",
      "summary": "WhatsApp and Telegram receipt intake not implemented",
      "spec_ref": "Inbound Channels — WhatsApp / Telegram bot creates expense with source telegram|whatsapp",
      "code_ref": "apps/zync-api/src/intake/email-expense.ts:1",
      "evidence": "grep whatsapp|telegram under apps/zync-api/src/intake returns no handlers. Only email-expense.ts exists; no IM intake consumer tagged for expenses.",
      "repro": "Send receipt photo via tenant bot → no expense row created."
    },
    {
      "id": "expenses-module-008",
      "severity": "P1",
      "type": "missing",
      "summary": "Bulk actions 'Evaluate all pending' and 'Export selected' absent from list UI",
      "spec_ref": "Expense List — Bulk actions: Evaluate all pending, Export selected, Delete selected",
      "code_ref": "apps/zync-app/src/features/expenses/ExpensesPage.tsx:133",
      "evidence": "Bulk toolbar renders only deleteSelected button. grep evaluateAll|exportSelected|Evaluate all pending under apps/zync-app/src/features/expenses — no matches.",
      "repro": "Select rows on /expenses → toolbar shows delete only; no evaluate/export actions."
    },
    {
      "id": "expenses-module-009",
      "severity": "P1",
      "type": "missing",
      "summary": "Expense list page has no filter controls despite URL-synced filter params",
      "spec_ref": "Expense List — Filters (URL-synced): date range, category, deduction %, status, source, project",
      "code_ref": "apps/zync-app/src/features/expenses/ExpensesPage.tsx:32",
      "evidence": "Reads dateFrom/dateTo/category/status/source/projectId from searchParams but page JSX has tabs + table only — no filter inputs. deductionPct never read from URL (client supports it at api/client.ts:24).",
      "repro": "Open /expenses — cannot set date/category/status filters from UI; manual URL params required."
    },
    {
      "id": "expenses-module-010",
      "severity": "P1",
      "type": "broken",
      "summary": "Mileage tab calls /api/mileage with wrong query params and expects wrong response shape",
      "spec_ref": "Mileage Tab — filters date range; trip list from mileage endpoints",
      "code_ref": "apps/zync-app/src/features/expenses/MileageTab.tsx:32",
      "evidence": "UI sends dateFrom/dateTo and expects {items,nextCursor,total}. API at apps/zync-api/src/routes/expenses/mileage.ts:62-79 expects startDate/endDate and returns {entries}. Param names and payload shape mismatch.",
      "repro": "Open /expenses/mileage with date filters → request /api/mileage?dateFrom=... ignored; response parsing fails or shows empty."
    },
    {
      "id": "expenses-module-011",
      "severity": "P1",
      "type": "missing",
      "summary": "Mileage annual report export link targets non-existent endpoint",
      "spec_ref": "Mileage Tab — annual report export reachable from logbook header",
      "code_ref": "apps/zync-app/src/features/expenses/MileageTab.tsx:73",
      "evidence": "href='/api/mileage/annual-report/xlsx'. grep annual-report under apps/zync-api — zero routes. Mileage API exposes GET /api/mileage/summary only (mileage.ts:37).",
      "repro": "Click annual report link on /expenses/mileage → 404."
    },
    {
      "id": "expenses-module-012",
      "severity": "P2",
      "type": "divergent",
      "summary": "OCR auto-complete threshold is 0.85 not spec/plan 0.7",
      "spec_ref": "Processing Pipeline — NEEDS_REVIEW when ocr_confidence < 0.7; Plan Task 4",
      "code_ref": "apps/zync-api/src/queues/expense-process.ts:152",
      "evidence": "Uses OCR_CONFIDENCE_AUTO_COMPLETE=0.85 from packages/types/src/expenses.ts:12 (downstream expense-ocr-correction-ux). expenses-module spec/plan cite 0.7.",
      "repro": "OCR confidence 0.75 → status COMPLETED instead of NEEDS_REVIEW per base spec."
    },
    {
      "id": "expenses-module-013",
      "severity": "P2",
      "type": "divergent",
      "summary": "Upload rate limiter configured 20/min in wrangler vs spec 10/min",
      "spec_ref": "Upload — RATE_LIMITER_EXPENSE_UPLOAD 10 uploads/minute per user",
      "code_ref": "apps/zync-api/wrangler.toml:107",
      "evidence": "RATE_LIMITER_EXPENSE_UPLOAD limit=20 period=60. Route message at index.ts:158 says 'Maximum 10 uploads per minute'.",
      "repro": "Deploy with wrangler config → CF allows 20 uploads/min per key, not 10."
    },
    {
      "id": "expenses-module-014",
      "severity": "P2",
      "type": "divergent",
      "summary": "Vendor analysis never groups by vendor_id — vendorId always null",
      "spec_ref": "Vendor Analysis — group by vendor_id when present, else normalized vendor_name",
      "code_ref": "packages/db/src/queries/expenses.ts:634",
      "evidence": "SELECT vendorId: sql`null::text` and GROUP BY lower(trim(vendor_name)) only. expenses.vendor_id column exists at schema/expenses.ts:121.",
      "repro": "Linked vendor_id rows still grouped by OCR vendor_name string, not vendors entity."
    },
    {
      "id": "expenses-module-015",
      "severity": "P2",
      "type": "divergent",
      "summary": "Sidebar lacks dedicated Mileage logbook nav link",
      "spec_ref": "Page Tabs / Mileage — 'Mileage logbook' link in expenses secondary nav",
      "code_ref": "apps/zync-app/src/shell/nav-model.ts:184",
      "evidence": "nav.expenses → /expenses only. No nav item to /expenses/mileage. Mileage reachable via tab Link inside ExpensesPage.tsx:127 only.",
      "repro": "Sidebar shows Expenses, not Mileage logbook sibling link."
    },
    {
      "id": "expenses-module-016",
      "severity": "P2",
      "type": "divergent",
      "summary": "PATCH /api/settings/expenses omits audit row required by plan",
      "spec_ref": "Plan Task 11 — write audit row action settings.expenses.update in same transaction",
      "code_ref": "apps/zync-api/src/routes/expenses/settings.ts:58",
      "evidence": "updateExpenseSettings called directly; grep auditLog|settings.expenses.update in settings.ts — no matches. updateExpense at expenses.ts:378 does insert audit for field edits.",
      "repro": "PATCH /api/settings/expenses → settings persist with no audit_log entry."
    },
    {
      "id": "expenses-module-017",
      "severity": "P2",
      "type": "bug",
      "summary": "Queue transient-error path marks expense FAILED and emits expense.failed before retry",
      "spec_ref": "Processing Pipeline — transient infra errors retry; terminal AI failures only mark FAILED",
      "code_ref": "apps/zync-api/src/queues/expense-process.ts:184",
      "evidence": "catch block sets status FAILED + emitExpenseWebhook expense.failed then msg.retry(). Spurious failed webhook/state on first transient R2/network error even if retry succeeds.",
      "repro": "Simulate transient R2 error on first attempt → expense.failed webhook fires; status may read FAILED until retry completes."
    },
    {
      "id": "expenses-module-018",
      "severity": "P2",
      "type": "divergent",
      "summary": "List receipt column shows PDF/IMG placeholder text, not thumbnail image",
      "spec_ref": "Expense List — Table column: Receipt thumbnail",
      "code_ref": "apps/zync-app/src/features/expenses/ExpenseTable.tsx:182",
      "evidence": "ExpenseRow renders static 'PDF' or 'IMG' span; no signed file URL fetch or <img> for receipt preview.",
      "repro": "Completed expense with JPG receipt → table shows 'IMG' text chip, not thumbnail."
    },
    {
      "id": "expenses-module-019",
      "severity": "P2",
      "type": "divergent",
      "summary": "VAT input query splits 100% vs partial buckets; base spec text says sum vat_amount for deduction_pct>0",
      "spec_ref": "VAT Summary — Input VAT sum vat_amount for deduction_pct>0; partial scaled",
      "code_ref": "packages/db/src/queries/expenses.ts:585",
      "evidence": "inputVat requires deductionPct=100; partialInputVat requires 0<deductionPct<100 scaled. Aligns with plan Task 13 (vat_deductible + split) but diverges from spec line 317 literal wording.",
      "repro": "Compare spec prose vs GET /api/expenses/reports/vat field definitions."
    },
    {
      "id": "expenses-module-020",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Upload success/failure live region uses role=alert for all announcements",
      "spec_ref": "Upload Zone Accessibility — success role=status; failure role=alert",
      "code_ref": "apps/zync-app/src/features/expenses/UploadDropzone.tsx:235",
      "evidence": "Single div role='alert' aria-live='assertive' receives both success and failure via announce(). Spec requires role='status' for success.",
      "repro": "Screen reader on successful upload hears assertive alert, not status announcement."
    },
    {
      "id": "expenses-module-021",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "DELETE /api/expenses/:id returns ok:true even when expense missing or other-tenant",
      "spec_ref": "DELETE /api/expenses/:id — soft delete",
      "code_ref": "apps/zync-api/src/routes/expenses/index.ts:314",
      "evidence": "softDeleteExpense updates with tenant+id predicate but route always returns {ok:true} without checking rows affected.",
      "repro": "DELETE foreign-tenant expense uuid → 200 {ok:true} though zero rows updated."
    },
    {
      "id": "expenses-module-022",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Expense detail History tab label hardcoded English",
      "spec_ref": "i18n — all UI strings via t('...')",
      "code_ref": "apps/zync-app/src/features/expenses/ExpenseDetailSheet.tsx:145",
      "evidence": "TabsTrigger value='history'>History</TabsTrigger> — not wrapped in t().",
      "repro": "Hebrew locale → History tab still English."
    },
    {
      "id": "expenses-module-023",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "PROCESSING status badge has no spinner animation",
      "spec_ref": "Status badge — PROCESSING (yellow spinner)",
      "code_ref": "packages/types/src/expenses.ts:150",
      "evidence": "EXPENSE_STATUS_BADGES.PROCESSING variant warning only; ExpenseTable renders static Badge without spinner (ExpenseTable.tsx:207).",
      "repro": "Processing expense in list → yellow badge text, no animated spinner."
    }
  ],
  "summary": {
    "P0": 4,
    "P1": 7,
    "P2": 8,
    "P3": 4,
    "total": 23
  }
}
