{
  "slug": "bank-statement-import",
  "spec_file": "docs/specs/2026-06-01-bank-statement-import.md",
  "findings": [
    {
      "id": "bank-statement-import-001",
      "severity": "P0",
      "type": "missing",
      "summary": "Incoming credits are skipped — no invoice auto-match, manual match, or send-to-reconcile path",
      "spec_ref": "Import Flow Step 2 — auto-match credits to invoices; Step 3 — Send to reconcile / Match to invoice on unmatched credits",
      "code_ref": "packages/db/src/queries/bank-import.ts:279",
      "evidence": "importBankTransactions explicitly continues on tx.type === 'credit' with skipped++. No matchInvoice, no unmatched_payments insert, no bank_transactions table. grep matchInvoice|sent_to_reconcile across packages/ and apps/zync-api/ returns zero implementation hits.",
      "repro": "Upload Leumi CSV with credit rows → API returns skipped=N for all credits; no rows reach /invoices/reconcile.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-002",
      "severity": "P0",
      "type": "missing",
      "summary": "No bank_transactions staging — dedup is weak expense fingerprint only; description drift allows double-import",
      "spec_ref": "Data Model — bank_transactions table; dedup of already-imported transactions",
      "code_ref": "packages/db/src/queries/bank-import.ts:285",
      "evidence": "grep bank_imports|bank_transactions in packages/db/migrations and schema returns zero. Duplicate check is only eq(tenantId)+expenseDate+amount+vendorName on expenses (:290-296). No reference/FITID/import_id key. Re-upload with trimmed or bank-suffixed description bypasses dedup and inserts a second expense.",
      "repro": "Import debit row {date, amount, 'BEZEQ'} → succeeds. Re-import same row with description 'BEZEQ INTERNATIONAL' → duplicates++ is 0, imported++ is 1 (second expense row).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-003",
      "severity": "P1",
      "type": "missing",
      "summary": "bank_imports and bank_transactions tables absent from schema and migrations",
      "spec_ref": "Data Model — CREATE TABLE bank_imports / bank_transactions",
      "code_ref": "packages/db/src/schema/index.ts:1",
      "evidence": "grep bank_imports|bank_transactions under packages/db/ (excl. worktrees) — only packages/db/src/queries/bank-import.ts comment references feature name; no Drizzle tables, no migration SQL.",
      "repro": "drizzle-kit / psql \\dt bank_* → tables do not exist.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-004",
      "severity": "P1",
      "type": "missing",
      "summary": "@zync/bank-import package (parsers, matching, queue consumer) not present",
      "spec_ref": "Architecture — packages/bank-import/src/process.ts, parsers/, matching.ts",
      "code_ref": "docs/plans/tasks/bank-statement-import.md:10",
      "evidence": "glob packages/bank-import/** → 0 files. grep processBankImport|parseOFX|matchInvoice in repo (excl. worktrees/docs) → zero code.",
      "repro": "ls packages/bank-import → directory does not exist.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-005",
      "severity": "P1",
      "type": "missing",
      "summary": "Async R2 upload + bank.process queue consumer not implemented",
      "spec_ref": "Import Flow Step 1 — file to R2, job enqueued; Step 2 — Queue consumer processBankImport",
      "code_ref": "apps/zync-api/src/routes/imports/bank-statement.ts:117",
      "evidence": "Route parses CSV synchronously in-request (:117) and calls importBankTransactions directly (:143). No STORAGE.put, no QUEUE.send. grep bank.process in apps/zync-api → zero matches (only plan doc).",
      "repro": "POST /api/imports/bank-statement with CSV → immediate JSON response; no bank_imports row, no queue message.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-006",
      "severity": "P1",
      "type": "missing",
      "summary": "Spec API surface /api/bank-imports/* entirely absent — only POST /api/imports/bank-statement exists",
      "spec_ref": "API — GET/POST /api/bank-imports, GET transactions, PATCH transaction, create-expense, complete",
      "code_ref": "apps/zync-api/src/routes/index.ts:323",
      "evidence": "grep /api/bank-imports in apps/zync-api → zero routes. Mounted path is routes.route('/imports', importRouter) with sub-route /bank-statement (apps/zync-api/src/routes/imports/router.ts:365). No GET list, GET :id, GET transactions, PATCH, create-expense, or complete handlers.",
      "repro": "curl GET /api/bank-imports → 404; only POST /api/imports/bank-statement is wired.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-007",
      "severity": "P1",
      "type": "missing",
      "summary": "Auto-match engine (matchInvoice / matchExpense with confidence thresholds) not implemented",
      "spec_ref": "Import Flow Step 2 — confidence >= 0.85 invoice / >= 0.80 expense",
      "code_ref": "packages/db/src/queries/bank-import.ts:268",
      "evidence": "importBankTransactions only inserts expenses for debits; no invoice or expense lookup, no match_confidence, no markMatched helper. grep matchInvoice|matchExpense in packages/ → zero.",
      "repro": "Import CSV with debit matching existing expense amount/date → expense_created path never runs; only blind insert or duplicate skip.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-008",
      "severity": "P1",
      "type": "missing",
      "summary": "Review UI /expenses/bank-import/:importId/review not implemented",
      "spec_ref": "Import Flow Step 3 — review queue with filters and per-row actions",
      "code_ref": "apps/zync-app/src/routes/imports/bank-statement.tsx:111",
      "evidence": "Only upload+preview+one-shot import page exists. No importId route, no transaction table with match status chips, no Create expense / Match / Send to reconcile / Ignore actions, no Complete button. grep BankImportReview|useBankTransactions in apps/zync-app → zero.",
      "repro": "No route or component for /expenses/bank-import/:id/review.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-009",
      "severity": "P1",
      "type": "missing",
      "summary": "Bank statement UI page not registered in app router — /imports/bank-statement is unreachable",
      "spec_ref": "Import Flow Step 1 — /expenses/bank-import upload page",
      "code_ref": "apps/zync-app/src/routes/index.tsx:97",
      "evidence": "BankStatementImportPage file exists at routes/imports/bank-statement.tsx but moduleRoutes has no imports/* or bank-statement entry (routes/index.tsx:97-158). Cashflow CTA navigates to /imports/bank-statement (cashflow/page.tsx:367) which 404s in Shell.",
      "repro": "Click 'import statement →' on Cash Flow report → no matching Route; page never lazy-loaded.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-010",
      "severity": "P1",
      "type": "missing",
      "summary": "OFX/QFX format support absent — API rejects non-CSV MIME types",
      "spec_ref": "Overview — CSV and OFX/QFX; Upload accepted .csv, .ofx, .qfx",
      "code_ref": "apps/zync-api/src/routes/imports/bank-statement.ts:98",
      "evidence": "MIME allowlist is text/csv, text/plain, application/csv only (:94-98); returns 415 'Only CSV files are accepted'. No parseOFX anywhere in codebase.",
      "repro": "POST .ofx file → 415; parseBankStatement has no ofx branch.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-011",
      "severity": "P1",
      "type": "missing",
      "summary": "Mizrahi, Max, and Isracard bank parsers not implemented",
      "spec_ref": "Bank-Specific CSV Parsers — mizrahi, max, isracard in PARSERS registry",
      "code_ref": "packages/db/src/queries/bank-import.ts:20",
      "evidence": "BankFormat union is only 'leumi'|'hapoalim'|'discount'|'visa-cal'|'generic' (:20). rowParser switch (:241-246) has no mizrahi, max, or isracard cases.",
      "repro": "POST with format=mizrahi → zod enum rejection (format not in BANK_FORMATS).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-012",
      "severity": "P1",
      "type": "missing",
      "summary": "CP1255 / UTF-8 BOM encoding auto-detect not implemented",
      "spec_ref": "Bank-Specific CSV Parsers — Encoding: CP1255 or UTF-8 with BOM auto-detect",
      "code_ref": "apps/zync-api/src/routes/imports/bank-statement.ts:107",
      "evidence": "File read via uploadedFile.text() with no encoding sniff/decode. grep detectEncoding|cp1255 under packages/db/src/queries/bank-import.ts → zero. CP1255 helper exists only in apps/zync-api/src/lib/cp1255.ts for exports, unused by bank import.",
      "repro": "Upload Hapoalim CP1255 Hebrew CSV → mojibake descriptions; positional parsers may mis-parse if columns shift.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-013",
      "severity": "P1",
      "type": "missing",
      "summary": "expenses.bank_transaction_id column absent",
      "spec_ref": "Step 3 — Create expense links expense.bank_transaction_id = txn.id",
      "code_ref": "packages/db/src/schema/expenses.ts:44",
      "evidence": "grep bank_transaction_id in packages/db → zero schema/migration hits. importBankTransactions inserts expenses without any bank txn FK (:305-320).",
      "repro": "Create expense from bank txn (per spec) impossible — column does not exist.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-014",
      "severity": "P1",
      "type": "missing",
      "summary": "invoice_payments.source CHECK excludes 'bank_import'",
      "spec_ref": "Ownership — bank-statement-import writes invoice_payments with source='bank_import'",
      "code_ref": "packages/db/src/schema/invoice-payments.ts:55",
      "evidence": "sourceCheck allows only manual|gateway|bank_transfer|auto_billing (:55). Migration 0012_narrow_loki.sql:38 mirrors same four values. No match-to-invoice route exists to write payments.",
      "repro": "INSERT invoice_payments(source='bank_import') would violate CHECK if flow were implemented.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-015",
      "severity": "P1",
      "type": "missing",
      "summary": "Send-to-reconcile flow (unmatched_payments + sent_to_reconcile status) not implemented",
      "spec_ref": "Step 3 — Send to reconcile inserts unmatched_payments; match_status sent_to_reconcile; unmatched_payment_id FK",
      "code_ref": "packages/db/src/schema/unmatched-payments.ts:7",
      "evidence": "Schema comment references bank-statement-import source (b) but grep sent_to_reconcile|unmatched_payment_id in apps/ and packages/ → zero code. No API handler inserts unmatched_payments from bank import.",
      "repro": "Unmatched credit after import cannot appear in /invoices/reconcile via bank import path.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-016",
      "severity": "P1",
      "type": "missing",
      "summary": "Match-to-invoice with single invoice_payments write and row lock not implemented",
      "spec_ref": "Ownership — bank import writes invoice_payments inside DB transaction with FOR UPDATE",
      "code_ref": "apps/zync-api/src/routes/imports/bank-statement.ts:42",
      "evidence": "Only POST handler exists; no PATCH matched_invoice, no recordInvoicePayment call, no SELECT FOR UPDATE on bank_transactions.",
      "repro": "No endpoint to link a bank credit to an invoice and update invoices.amount_paid/status.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-017",
      "severity": "P1",
      "type": "missing",
      "summary": "POST /api/bank-imports/:id/complete with OWNER/ADMIN gate not implemented",
      "spec_ref": "Step 4 — complete when all transactions resolved; OWNER/ADMIN only",
      "code_ref": "apps/zync-api/src/routes/imports/bank-statement.ts:1",
      "evidence": "No complete route in bank-statement.ts or elsewhere. bank_imports.status lifecycle (pending→processing→review→complete) not modeled.",
      "repro": "No way to mark an import complete or enforce unresolved-count 409.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-018",
      "severity": "P1",
      "type": "missing",
      "summary": "Expenses header 'Import statement' link to /expenses/bank-import absent",
      "spec_ref": "Step 1 — linked from /expenses header: Import statement",
      "code_ref": "apps/zync-app/src/modules/expenses/index.tsx:1",
      "evidence": "grep Import statement|bank-import|bank-statement under apps/zync-app/src/modules/expenses → zero. Only cashflow footnote CTA exists (reports/cashflow/page.tsx:367).",
      "repro": "/expenses UI has no import-statement entry point per spec wireframe.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-019",
      "severity": "P1",
      "type": "missing",
      "summary": "React-Query hooks and features/bank-import module from plan not implemented",
      "spec_ref": "Plan Task 9 — useBankImports, useBankImport, useBankTransactions, useCompleteImport",
      "code_ref": "apps/zync-app/src/routes/imports/bank-statement.tsx:62",
      "evidence": "UI uses inline fetch helpers only. glob apps/zync-app/src/features/bank-import/** → 0 files.",
      "repro": "grep useBankImports|useCompleteImport in apps/zync-app → zero.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-020",
      "severity": "P1",
      "type": "missing",
      "summary": "Parser/matching/API invariant tests from plan Task 12 absent",
      "spec_ref": "Plan Task 12 — packages/bank-import/test/* and apps/zync-api/test/bank-imports.test.ts",
      "code_ref": "packages/db/src/queries/bank-import.ts:1",
      "evidence": "grep parseBankStatement|bank-import in **/*test* → zero test files.",
      "repro": "No automated coverage for parsers, thresholds, or no-double-write invariant.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-021",
      "severity": "P2",
      "type": "divergence",
      "summary": "Upload path and URL diverge from spec (/api/imports/bank-statement vs /api/bank-imports, /imports/bank-statement vs /expenses/bank-import)",
      "spec_ref": "Step 1 — /expenses/bank-import; API — /api/bank-imports",
      "code_ref": "apps/zync-api/src/routes/imports/bank-statement.ts:4",
      "evidence": "Implemented endpoint is POST /api/imports/bank-statement. UI targets same path. Spec/plan specify /api/bank-imports and /expenses/bank-import.",
      "repro": "Spec clients calling /api/bank-imports receive 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-022",
      "severity": "P2",
      "type": "divergence",
      "summary": "Synchronous in-request parse+import instead of async R2+queue architecture",
      "spec_ref": "Architecture Decisions — file stored in R2, parsed async; not in-request parsing",
      "code_ref": "apps/zync-api/src/routes/imports/bank-statement.ts:116",
      "evidence": "Full CSV parsed and imported inside POST handler before response. Worker CPU/time limits risk on 300+ row files.",
      "repro": "Large 12-month statement blocks request until all rows inserted.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-023",
      "severity": "P2",
      "type": "divergence",
      "summary": "Bank name enum diverges — visa-cal/generic vs spec cal/max/isracard/generic_csv/ofx",
      "spec_ref": "Data Model — bank_name CHECK enum; Parsers registry",
      "code_ref": "packages/db/src/queries/bank-import.ts:20",
      "evidence": "Implementation uses visa-cal not cal; generic not generic_csv; missing max, isracard, ofx as bank_name values.",
      "repro": "API zod BANK_FORMATS (:26) rejects spec enum values cal, max, isracard, generic_csv, ofx.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-024",
      "severity": "P2",
      "type": "divergence",
      "summary": "Amount model uses positive string + credit/debit type instead of signed NUMERIC amount",
      "spec_ref": "Data Model — amount NUMERIC(12,2) positive=credit negative=debit; BankRow.amount signed",
      "code_ref": "packages/db/src/queries/bank-import.ts:22",
      "evidence": "ParsedTransaction stores amount as positive string and separate type field (:22-27). Never persists signed amount to bank_transactions (table missing). Expenses always get positive debit amount.",
      "repro": "Normalized row model differs from spec BankRow; downstream matching logic expecting signed amount not portable.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-025",
      "severity": "P2",
      "type": "bug",
      "summary": "Amount parsing uses parseFloat before toFixed(2) — not decimal-safe for NUMERIC(12,2)",
      "spec_ref": "Data Model — amount NUMERIC(12,2); parsed amounts MUST preserve precision",
      "code_ref": "packages/db/src/queries/bank-import.ts:107",
      "evidence": "parseAmount uses parseFloat(s) then Math.abs(n).toFixed(2) (:107-109). Binary float can mis-round edge cases before DB NUMERIC cast. No string-based decimal parser.",
      "repro": "Rare high-precision bank strings (e.g. accumulated fee lines) may round differently than NUMERIC expectations.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-026",
      "severity": "P2",
      "type": "divergence",
      "summary": "Debits auto-inserted as expenses — bypasses review queue and expense_created match_status",
      "spec_ref": "Step 3 — user reviews unmatched debits; Create expense sets match_status expense_created",
      "code_ref": "packages/db/src/queries/bank-import.ts:304",
      "evidence": "importBankTransactions directly db.insert(expenses) for every non-duplicate debit (:304-320) with status COMPLETED. No review step, no match_status tracking, no user confirmation.",
      "repro": "POST import (non-preview) immediately creates expense rows without review UI.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-027",
      "severity": "P2",
      "type": "missing",
      "summary": "GET list/view routes lack expenses:read permission — only POST guarded",
      "spec_ref": "API — expenses:write (upload/review) or expenses:read (list/view)",
      "code_ref": "apps/zync-api/src/routes/imports/bank-statement.ts:42",
      "evidence": "Single route uses requirePermission('expenses:write'). No read endpoints exist for import history or transaction list.",
      "repro": "Read-only expense role cannot list bank imports per spec permission matrix.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-028",
      "severity": "P2",
      "type": "missing",
      "summary": "RATE_LIMITER_EXPENSE_UPLOAD not applied to bank statement upload",
      "spec_ref": "Plan Task 6 — apply RATE_LIMITER_EXPENSE_UPLOAD on POST upload",
      "code_ref": "apps/zync-api/src/routes/expenses/index.ts:156",
      "evidence": "Expenses upload route applies RATE_LIMITER_EXPENSE_UPLOAD (:156). bank-statement.ts POST has no rate limiter call.",
      "repro": "Repeated POST /api/imports/bank-statement is not throttled.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-029",
      "severity": "P2",
      "type": "divergence",
      "summary": "Parsers use fixed column positions — no Hebrew/English header auto-detection per generic_csv spec",
      "spec_ref": "Parsers — generic_csv auto-detect columns by header; Hebrew column handling",
      "code_ref": "packages/db/src/queries/bank-import.ts:208",
      "evidence": "parseGenericRow assumes columns [date, description, amount] at indices 0,1,2 (:208-214). Skips rows where fields[0] lacks digits (:251) but does not map headers by name.",
      "repro": "Generic CSV with columns reordered or extra leading columns → rows skipped or wrong fields parsed.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-030",
      "severity": "P2",
      "type": "divergence",
      "summary": "Uploaded bank file not persisted to R2 — no import history or re-download",
      "spec_ref": "Step 1 — tenants/{tenantId}/bank-imports/{importId}.csv; Step 4 — import appears in history, can be downloaded",
      "code_ref": "apps/zync-api/src/routes/imports/bank-statement.ts:107",
      "evidence": "CSV read into memory string (:107) and discarded after import. Fake r2Key generated per expense row in importBankTransactions (:308) not tied to statement file.",
      "repro": "After import completes, original statement file cannot be retrieved.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-031",
      "severity": "P2",
      "type": "divergence",
      "summary": "Preview mode and one-shot import response shape not in spec API contract",
      "spec_ref": "API — bank-imports resources with status/stats/transactions",
      "code_ref": "apps/zync-api/src/routes/imports/bank-statement.ts:131",
      "evidence": "Returns { imported, skipped, duplicates, preview, total } with optional preview query flag. Spec defines paginated import history and per-import transaction lists instead.",
      "repro": "Client built to spec API cannot consume { imported, skipped, duplicates } shape.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-032",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Upload UI missing account number field, drag-and-drop, and spec bank labels",
      "spec_ref": "Step 1 wireframe — Account #, drag & drop, full bank/card list",
      "code_ref": "apps/zync-app/src/routes/imports/bank-statement.tsx:101",
      "evidence": "FORMAT_OPTIONS has 5 banks (:101-107); no account input; plain file input not drag-drop (:192-205). Missing Bank of Israel, Max, Isracard, OFX options from spec list.",
      "repro": "Upload form does not match spec fields or bank picker options.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-033",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "UI route path and copy diverge from spec (/imports vs /expenses/bank-import)",
      "spec_ref": "Step 1 — /expenses/bank-import",
      "code_ref": "apps/zync-app/src/routes/reports/cashflow/page.tsx:367",
      "evidence": "Navigation target is /imports/bank-statement. Page title 'ייבוא דפי חשבון' but spec English path is /expenses/bank-import.",
      "repro": "Deep links per spec path /expenses/bank-import do not resolve.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bank-statement-import-034",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Plan registry marks bank-statement-import COMPLETED despite major spec gaps",
      "spec_ref": "Plan — full @zync/bank-import package, API, review UI, tests",
      "code_ref": "docs/plans/status/bank-statement-import.json:4",
      "evidence": "status.json shows status COMPLETED (completed_at 2026-06-04) while schema, queue, review UI, matching, and 6/7 API routes are absent.",
      "repro": "Read docs/plans/status/bank-statement-import.json vs grep bank_imports in packages/db.",
      "fix_direction": "code_to_spec"
    }
  ],
  "summary": {
    "P0": 2,
    "P1": 18,
    "P2": 11,
    "P3": 3,
    "total": 34
  }
}