{
  "slug": "bulk-invoice-generation",
  "spec_file": "docs/specs/2026-05-31-bulk-invoice-generation.md",
  "findings": [
    {
      "id": "bulk-invoice-generation-001",
      "severity": "P0",
      "type": "broken",
      "summary": "Inline generation SQL references non-existent time_entries.hourly_rate — crashes or zero-priced time lines",
      "spec_ref": "Invoice Content Rules / time-to-invoice grouping",
      "code_ref": "packages/db/src/queries/invoice-generation.ts:391",
      "evidence": "getCustomerBillableItems SELECT uses max(te.hourly_rate); packages/db/src/schema/time.ts has no hourly_rate column (rate lives on projects.hourly_rate per packages/db/src/schema/projects.ts:97).",
      "repro": "POST /api/invoices/bulk-generate with ≤20 customers and includeTime=true → SQL error column te.hourly_rate does not exist, or unitPrice=0 if query were coerced.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-002",
      "severity": "P0",
      "type": "broken",
      "summary": "Bulk-created invoices never mark time_entries/expenses billed — double-billing on re-run",
      "spec_ref": "Architecture Decisions / Invoice Content Rules; plan Task 4 atomic mark billed",
      "code_ref": "apps/zync-api/src/routes/invoices/generate.ts:154",
      "evidence": "Inline path calls createInvoice only; createInvoice ignores billedEntryIds and does not UPDATE time_entries/expenses (packages/db/src/queries/invoices.ts:451-535). No markTimeEntriesBilled / expense billed_at update in invoice-generation.ts.",
      "repro": "Generate invoices for a customer with billable time; re-run same period → same entries appear again and a second DRAFT invoice is created.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-003",
      "severity": "P0",
      "type": "broken",
      "summary": "Preview invoice_total omits time dollar amounts — staff sees wrong totals before generate",
      "spec_ref": "Step 2: Preview / POST bulk-generate/preview Returns invoice_total",
      "code_ref": "packages/db/src/queries/invoice-generation.ts:184",
      "evidence": "invoiceTotal = expenseTotal + milestoneTotal only; timeHours aggregated but never multiplied by hourly_rate. Comment at :185-186 admits time value omitted.",
      "repro": "POST /api/invoices/bulk-generate/preview with billable time only → customer row shows timeHours>0 but invoiceTotal=0.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-004",
      "severity": "P0",
      "type": "security",
      "summary": "POST generate does not reject customer_ids outside caller tenant",
      "spec_ref": "API tenant scoping / Requires admin",
      "code_ref": "apps/zync-api/src/routes/invoices/generate.ts:134",
      "evidence": "Loop iterates all parsed customerIds; nameMap filters names by tenant but missing IDs still processed. createInvoice inserts invoice.tenant_id=session.tid with arbitrary customerId (no tenant match check in packages/db/src/queries/invoices.ts:474).",
      "repro": "POST /api/invoices/bulk-generate with another tenant's customer UUID in customerIds → invoice row may link cross-tenant customer_id under attacker tenant_id.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-005",
      "severity": "P1",
      "type": "missing",
      "summary": "Queue consumer for invoice_generate / bulk_action absent — jobs never process",
      "spec_ref": "Large Batches / Queue for >20 customers",
      "code_ref": "apps/zync-api/src/index.ts:117",
      "evidence": "generate.ts QUEUE.send({ type:'bulk_action', action:'invoice_generate', ... }) at :110-117. queue() handler routes comms-inbound, ai-*, audit, expense, realtime, lead.score_recalc, uniform-format only — no bulk_action or invoice_generate branch. Grep apps/zync-api for handleInvoiceGenerate → NONE.",
      "repro": "POST /api/invoices/bulk-generate with 21+ customerIds → { jobId } returned; import_jobs stays pending; GET jobs/:jobId never reaches completed.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-006",
      "severity": "P1",
      "type": "missing",
      "summary": "Wizard and job-status UI pages exist but are not registered in invoices module router",
      "spec_ref": "Route /invoices/generate; Job-Status Screen /invoices/generate/jobs/:jobId",
      "code_ref": "apps/zync-app/src/modules/invoices.tsx:22",
      "evidence": "Module Routes: index, reconcile, :id only. Page files at apps/zync-app/src/routes/invoices/generate/index.tsx and jobs.$jobId.tsx are never imported.",
      "repro": "Navigate to /invoices/generate → caught by :id route or 404, wizard does not render.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-007",
      "severity": "P1",
      "type": "missing",
      "summary": "Invoices list lacks admin [Generate invoices] entry point",
      "spec_ref": "Entry Points — Invoices list header [Generate invoices] button",
      "code_ref": "apps/zync-app/src/pages/invoices/InvoicePage.tsx:1",
      "evidence": "InvoicePage toolbar has Create invoice only (Plus icon); grep Generate|/invoices/generate in pages/invoices → no matches.",
      "repro": "Open /invoices as admin — no link to billing-run wizard.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-008",
      "severity": "P1",
      "type": "missing",
      "summary": "GET /api/invoices has no job_id batch filter",
      "spec_ref": "Step 3 redirect; Job-Status Go to invoices filters status=DRAFT&job_id=:jobId; plan Task 11",
      "code_ref": "packages/db/src/queries/invoices.ts:82",
      "evidence": "listInvoicesSchema has cursor, limit, status, customerId, projectId, dateFrom, dateTo, source — no jobId. listInvoices WHERE clause has no import_job_results join.",
      "repro": "GET /api/invoices?status=DRAFT&job_id=<uuid> ignores job_id; returns all drafts.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-009",
      "severity": "P1",
      "type": "missing",
      "summary": "Completion notification on background job finish not implemented",
      "spec_ref": "Large Batches — Notification on completion",
      "code_ref": "apps/zync-api/src/routes/invoices/generate.ts:1",
      "evidence": "grep createNotification|deliverNotification in generate.ts and apps/zync-api/src/queues → no invoice_generate handler file. Plan Task 7 requires notification with deep link.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-010",
      "severity": "P1",
      "type": "missing",
      "summary": "Expense aggregation uses invalid status='approved' — billable expenses never included",
      "spec_ref": "Include Approved expenses; plan Task 3 status=COMPLETED",
      "code_ref": "packages/db/src/queries/invoice-generation.ts:144",
      "evidence": "SQL filters status='approved'. expenses.status CHECK is PENDING|PROCESSING|COMPLETED|FAILED|NEEDS_REVIEW (packages/db/src/schema/expenses.ts:150). Billable completed expenses use status COMPLETED per expense-to-invoice spec.",
      "repro": "POST preview/generate with includeExpenses=true and COMPLETED billable expenses → expense_total always 0.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-011",
      "severity": "P1",
      "type": "missing",
      "summary": "Planned service layer, types package, queue consumer, and tests absent",
      "spec_ref": "plan Tasks 2–4, 7, 12",
      "code_ref": "NONE",
      "evidence": "Glob apps/zync-api/src/services/bulk-invoice/** → 0 files. Glob packages/types/src/bulk-invoice.ts → 0. Glob apps/zync-api/src/queues/bulk-invoice-generate.ts → 0. Glob **/bulk-generate.test.ts → 0.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-012",
      "severity": "P1",
      "type": "missing",
      "summary": "import_jobs migration delta (meta JSONB, nullable file columns) from plan Task 1 not present",
      "spec_ref": "Large Batches meta.action=invoice_generate; plan Task 1",
      "code_ref": "packages/db/src/schema/data-import.ts:68",
      "evidence": "grep bulk_invoice_import_jobs in packages/db/migrations → 0 SQL files. importJobs.r2Key and originalFilename remain NOT NULL in Drizzle schema; createBulkGenerationJob uses sentinel '' filename (invoice-generation.ts:246-247) instead of nullable columns + meta JSONB.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-013",
      "severity": "P2",
      "type": "divergent",
      "summary": "Milestones toggle accepted but never queried or invoiced",
      "spec_ref": "Step 1 Include Open milestones; Invoice Content Rules Milestones",
      "code_ref": "packages/db/src/queries/invoice-generation.ts:85",
      "evidence": "getBulkGeneratePreview destructures only includeTime, includeExpenses; milestoneTotal hardcoded 0 at :183. getCustomerBillableItems has no milestone branch. includeMilestones stored on job meta but unused in inline path (:137-145).",
      "repro": "Enable milestones in wizard → preview and generate never include project_milestones rows.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-014",
      "severity": "P2",
      "type": "divergent",
      "summary": "Time lines grouped by project only, not project→task per time-to-invoice rules",
      "spec_ref": "Invoice Content Rules — Time entries grouped by project → task",
      "code_ref": "packages/db/src/queries/invoice-generation.ts:400",
      "evidence": "GROUP BY p.id, p.name — no task_id/task_name in SELECT or GROUP BY. Description is Time — {project_name} only.",
      "repro": "Customer with time on two tasks in same project → single combined line instead of per-task lines.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-015",
      "severity": "P2",
      "type": "divergent",
      "summary": "has_open_invoice detection omits TAX_ISSUED and created_at fallback",
      "spec_ref": "Smart pre-deselection; plan getCustomersWithOpenInvoiceInPeriod",
      "code_ref": "packages/db/src/queries/invoice-generation.ts:168",
      "evidence": "Status IN ('DRAFT','SENT','APPROVED','PARTIALLY_PAID') — missing TAX_ISSUED. Filters issueDate only; no COALESCE(issue_date, created_at) when issue_date NULL.",
      "repro": "Customer with TAX_ISSUED invoice in period → hasOpenInvoice false; not pre-deselected.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-016",
      "severity": "P2",
      "type": "divergent",
      "summary": "Time period filter uses stopped_at not started_at; missing stopped_at IS NOT NULL",
      "spec_ref": "plan Task 3 getBillableTimeByCustomer",
      "code_ref": "packages/db/src/queries/invoice-generation.ts:118",
      "evidence": "Preview and inline queries filter te.stopped_at between period bounds. Plan requires started_at within period and stopped_at IS NOT NULL.",
      "repro": "Entry started in period but stopped next month → included/excluded incorrectly vs plan.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-017",
      "severity": "P2",
      "type": "divergent",
      "summary": "Generated invoices use source hourly_auto not bulk_generate; expense lines non-taxable",
      "spec_ref": "plan Task 4 source=bulk_generate; line taxable=true",
      "code_ref": "apps/zync-api/src/routes/invoices/generate.ts:161",
      "evidence": "createInvoice called with source:'hourly_auto'. Expense LineItemDraft taxable:false at invoice-generation.ts:446 while time lines taxable:true.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-018",
      "severity": "P2",
      "type": "divergent",
      "summary": "API contract uses camelCase fields; spec documents snake_case",
      "spec_ref": "API POST preview/generate body period_start, customer_ids, job_id",
      "code_ref": "packages/db/src/queries/invoice-generation.ts:37",
      "evidence": "bulkGeneratePreviewSchema uses periodStart, periodEnd, includeTime. Routes return { jobId } not job_id (generate.ts:119). UI sends camelCase (generate/index.tsx:70).",
      "repro": "Client following spec JSON with period_start receives 400 validation failed.",
      "fix_direction": "spec_review"
    },
    {
      "id": "bulk-invoice-generation-019",
      "severity": "P2",
      "type": "divergent",
      "summary": "Preview lacks lines_preview expand data; UI has no per-row breakdown",
      "spec_ref": "Step 2 Preview [▾ Expand] line-item breakdown",
      "code_ref": "packages/db/src/queries/invoice-generation.ts:54",
      "evidence": "CustomerBillingPreview type has no linesPreview field. Step 2 table shows Hours/Expenses/Total only — no expand control (generate/index.tsx:316-385).",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-020",
      "severity": "P2",
      "type": "divergent",
      "summary": "Job status endpoint drops skip/error reasons",
      "spec_ref": "GET jobs/:jobId results reason field",
      "code_ref": "packages/db/src/queries/invoice-generation.ts:317",
      "evidence": "getBulkGenerationJobStatus sets reason from errs?.[0]?.message but errs is hardcoded null; import_job_results.message never mapped.",
      "repro": "Job with skipped customer → results[].reason undefined despite message column.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-021",
      "severity": "P2",
      "type": "divergent",
      "summary": "Inline completion UI does not redirect to DRAFT batch filter; skip count field mismatch",
      "spec_ref": "Step 3 redirect status=DRAFT&created_at>=today; generate response",
      "code_ref": "apps/zync-app/src/routes/invoices/generate/index.tsx:482",
      "evidence": "Go to invoices navigates to /invoices only. API returns skippedCount (generate.ts:185); UI reads generateResult.skipped (index.tsx:453) — always undefined.",
      "repro": "Inline generate with skips → UI shows 0 skipped; list not filtered to today's DRAFT batch.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-022",
      "severity": "P2",
      "type": "divergent",
      "summary": "Expense query omits project_id IS NOT NULL gate from plan",
      "spec_ref": "plan Task 3 getBillableExpensesByCustomer",
      "code_ref": "packages/db/src/queries/invoice-generation.ts:141",
      "evidence": "WHERE uses customer_id IS NOT NULL on expenses directly; no project_id IS NOT NULL filter per expense-to-invoice billable gate.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "bulk-invoice-generation-023",
      "severity": "P3",
      "type": "divergent",
      "summary": "Step 3 spec mockup shows INV numbers during generation; implementation correctly leaves DRAFT numbers null",
      "spec_ref": "Step 3 Generating — INV-0048 created",
      "code_ref": "packages/db/src/queries/invoices.ts:481",
      "evidence": "createInvoice sets status DRAFT with no nextInvoiceNumber call. UI shows inv.number ?? 'Draft' (generate/index.tsx:476). Plan explicitly: DRAFT invoices carry no number.",
      "repro": "",
      "fix_direction": "spec_review"
    },
    {
      "id": "bulk-invoice-generation-024",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Preview table missing Milestones column from spec wireframe",
      "spec_ref": "Step 2 Preview table columns",
      "code_ref": "apps/zync-app/src/routes/invoices/generate/index.tsx:330",
      "evidence": "Header row: Customer, Hours, Expenses, Total — no Milestones column.",
      "repro": "",
      "fix_direction": "code_to_spec"
    }
  ],
  "summary": {
    "P0": 4,
    "P1": 8,
    "P2": 10,
    "P3": 2,
    "total": 24
  }
}
