diff --git a/.claude/agent-memory/e2e-test-engineer/known-flakes-and-regressions.md b/.claude/agent-memory/e2e-test-engineer/known-flakes-and-regressions.md index d5c8ea9d7..c5faf4e2f 100644 --- a/.claude/agent-memory/e2e-test-engineer/known-flakes-and-regressions.md +++ b/.claude/agent-memory/e2e-test-engineer/known-flakes-and-regressions.md @@ -20,12 +20,15 @@ metadata: ## Resolved / fixed +- **`orientations.spec.ts:389` "ManagePage now has 5 tabs" (2026-07-29, full-matrix shards 6/11/15, all viewports)**: stale tab-count assertion — Story #1877 (merged to `beta`) added the Household tab as the first ManagePage tab, making 6, but this spec's tab-count test wasn't updated when `settings-manage.spec.ts`'s equivalent assertion was. Fixed: `toHaveCount(5)` → `toHaveCount(6)`, test title updated to list Household first. No per-tab-name iteration array existed elsewhere in this file needing updates — the only per-tab-name check is the separate "Clicking Orientations tab..." test, unaffected by the count change. + **IMPORTANT — ManagePage tab-count assertions live in TWO spec files that must both be updated whenever ManagePage tabs change**: `e2e/tests/navigation/settings-manage.spec.ts` (line ~895, `toHaveCount(6)`, the authoritative/comprehensive one — already correct) and `e2e/tests/orientations.spec.ts` (line ~389, a secondary/narrower assertion added when the Orientations tab was introduced, tag `@responsive`, comment says "extends settings-manage.spec.ts coverage"). Grep both for `toHaveCount(` + tab-count when adding/removing a ManagePage tab. + - **PR #1883 full-E2E matrix (2026-07-29, shards 5/6/11/15 of run 30467895559)**: 4 distinct test-bug fixes, all test-only (no production regression): 1. `settings-manage.spec.ts` lines 162, 250, 917 — `getByRole('tab', { name: 'Household' })` (default substring match) also matched "Household Item Categories" → strict-mode violation on `.click()`/`toHaveAttribute()`. Fixed with `{ name: 'Household', exact: true }`. Confirmed via CI logs across desktop/tablet/mobile shards — same 3 tests (line 156, 247, 904) failed identically on all three viewports. The 6-tab count assertion (line 895, `toHaveCount(6)`) was speculated as possibly-wrong in the initial diagnosis but was NOT actually failing in CI and is correct in `ManagePage.tsx` (Household/Areas/Trades/Orientations/Budget Categories/HI Categories, all unconditional — `isAdmin` only gates the separate `SubNav` links, not these `role="tab"` buttons) — no change needed. 2. `invoices.spec.ts:395` "Clicking an invoice row navigates to the invoice detail page" — `invoicesPage.goto()` (unfiltered) + filter-by-text on `[class*="invoiceLink"]:visible` can miss the row: default sort is `date desc` with `pageSize=25`, and with parallel workers all creating invoices concurrently, this test's fixed `date: '2026-01-10'` can rank past page 1. Fixed by using `invoicesPage.search(`${testPrefix}-ROW-001`)` (server-side `invoiceNumber` LIKE match) instead of `goto()`, scoping the table to just this test's own row regardless of concurrent invoice volume. 3. `invoices.spec.ts:841` "Effective Amount"/"Remaining Amount" column toggle test — root cause is `table.invoices.columns` being a per-user **server-side singleton preference** (`useColumnPreferences`/`usePreferences`, debounced 500ms save + optimistic re-sync effect), not per-test-entity state. Attempt 1 failed with `getColumnCellText` unable to find "Remaining Amount" header milliseconds after a `toBeVisible()` assertion on that same header passed (transient re-render from the debounce/resync effect landing mid-test); the retry then failed immediately because attempt 1's debounced save had persisted "Effective Amount"=visible to the DB before teardown, and retries share the same app/DB (only the browser context resets). Fixed two ways: (a) delete the preference via `page.request.delete('/api/users/me/preferences/table.invoices.columns')` both defensively-before (right before the baseline "not visible" assertions) and in `finally` (so it never leaks into a retry or into a later test); (b) made `InvoicesPage.getColumnCellText()`'s header-scan retry via `expect(async () => {...}).toPass({ timeout: 3_000 })` so a transient render race self-heals instead of throwing on a single unretried read. Same class of bug as the WebKit post-click-read races already documented above ("read immediately post-click on WebKit") — general lesson: never do a single unretried DOM read immediately after a state-changing click when the state is persisted through a debounced/async side-channel. - Dashboard `dashboard.spec.ts:566` "Customize button appears when card dismissed" also failed in the same run (shard 5) — this is the **already-documented** preferences-race known flake (see entry below, originally "fixed" PR #1445 but still recurs) — left untouched per existing triage, no new action. - Note: `invoices.spec.ts` itself has **zero diff vs `origin/beta`** — these are pre-existing latent bugs from an earlier story (#1876), not introduced by #1877; they just happened to surface in PR #1883's E2E run because full E2E runs on every PR regardless of which files changed. + Dashboard `dashboard.spec.ts:566` "Customize button appears when card dismissed" also failed in the same run (shard 5) — this is the **already-documented** preferences-race known flake (see entry below, originally "fixed" PR #1445 but still recurs) — left untouched per existing triage, no new action. + Note: `invoices.spec.ts` itself has **zero diff vs `origin/beta`** — these are pre-existing latent bugs from an earlier story (#1876), not introduced by #1877; they just happened to surface in PR #1883's E2E run because full E2E runs on every PR regardless of which files changed. - **Issue #1829 (2026-07-08)** shard-3 diary flakes on main-targeted PRs: `diary-drafts.spec.ts:854` (Scenario 14) was genuinely failing on both attempt + retry — `test.slow()`'s 45s total test budget was being exhausted by 4 oversized nested step timeouts (30-45s each) before reaching cleanup, surfacing as `apiRequestContext.delete: Test timeout exceeded`. Fixed via `test.setTimeout(60_000)` + proportionate 15s step ceilings + `testInfo.setTimeout(testInfo.timeout + 15_000)` guaranteed-cleanup-time pattern in `finally`. `diary-r2-uat.spec.ts:599` (Scenario 10) had a genuine secondary race (always-empty mock defeats `waitForLoaded()`, letting a stale prior response satisfy the next `waitForResponse`) — fixed by awaiting each transition's own response explicitly and reading straight off the resolved `Response` object. `document-linking.spec.ts:369` was NOT an independent bug — confirmed via job logs it was cancelled mid-flight (1.6s runtime) as `maxFailures:1` collateral once drafts:854 exhausted its retries; no test-logic fix needed, just added the guaranteed-cleanup-time pattern defensively. Full root cause + `maxFailures`/retry-accounting proof (Playwright source citation) in [bug-1829-shard3-flakes.md](bug-1829-shard3-flakes.md). AC #3 (fail-fast retry tolerance) was already satisfied by Playwright's built-in behavior — no config change needed, only documented. - Diary Scenario 14 (`diary-drafts.spec.ts` draft-card-click-navigates) — was a persistent flake even after an earlier partial fix (PR #1671); root-caused and properly fixed via `fix/diary-scenario14-e2e-flake` (register `waitForResponse` before the click, not after). Recurred 2026-07-07 with a DIFFERENT failure signature (teardown-timeout, not nav-timing) — see #1829 entry above for the follow-up fix. diff --git a/.claude/agent-memory/qa-integration-tester/environment-setup.md b/.claude/agent-memory/qa-integration-tester/environment-setup.md index bb010e0d4..edce12fdc 100644 --- a/.claude/agent-memory/qa-integration-tester/environment-setup.md +++ b/.claude/agent-memory/qa-integration-tester/environment-setup.md @@ -70,3 +70,22 @@ NODE_PATH=/path/to/cornerstone/server/node_modules:/path/to/cornerstone/client/n → After changing `shared/src/types/`, rebuild main project's dist OR copy worktree dist files: `cp -r /worktree/shared/dist /path/to/cornerstone/shared/` - Server tests (better-sqlite3 native binary) may SIGKILL on ARM64 sandbox — validate via CI if needed + +## Haste Map Collision When Running Jest From the Main Repo Dir (Not a Worktree Issue) + +When running Jest from the **main project directory** (not a worktree) and `.claude/worktrees/` contains +several stale worktrees, jest-haste-map scans them too and throws `The name '@cornerstone/shared' was +looked up in the Haste module map... several different files... provide a module for that particular +name` — because each worktree has its own `shared/package.json`. This is intermittent/order-dependent: +one test file in a multi-file jest invocation may fail this way while another in the same run succeeds. + +**Fix**: add `--modulePathIgnorePatterns='/\.claude/worktrees'` to the jest CLI invocation: + +```bash +NODE_OPTIONS=--experimental-vm-modules npx jest --maxWorkers=1 \ + --modulePathIgnorePatterns='/\.claude/worktrees' +``` + +Confirmed working on Story #1878 follow-up (2026-07-29): `sourceReports.test.ts` failed with the haste +error until this flag was added; `sourceReportService.test.ts` (run first in the same invocation) was +unaffected. Not a jest.config.ts change — CLI flag only, keeps other suites unaffected. diff --git a/.claude/agent-memory/qa-integration-tester/story-1878-source-report-backend.md b/.claude/agent-memory/qa-integration-tester/story-1878-source-report-backend.md new file mode 100644 index 000000000..d4f9b7768 --- /dev/null +++ b/.claude/agent-memory/qa-integration-tester/story-1878-source-report-backend.md @@ -0,0 +1,178 @@ +--- +name: story-1878-source-report-backend +description: Story #1878 source report backend testing — 6 confirmed production bugs (sql.join misuse, db.transaction double-invocation, missing await, wrong field name, missing date side-effect branch, Map-iteration/wrong-field ASN-title bug) plus reusable technique discoveries (modulePathIgnorePatterns worktree fix, invoice_budget_lines UNIQUE constraint, async-fix-follow-up async/await test conversion) +metadata: + type: project +--- + +## Follow-up: async-fix test restoration (2026-07-29, after bugs #1/#3/#4 fixed upstream) + +Backend fixed bugs #1 (`sql.join`) and #3 (missing `await`, `getSourceReport` now genuinely +`async`/`Promise`). Task: convert `sourceReportService.test.ts` to +`await`/`async` throughout (all `getSourceReport(...)` calls + enclosing `it()`s), replace the +sync `expect(() => ...).toThrow(NotFoundError)` for the unknown-sourceId case with +`await expect(...).rejects.toThrow(...)`, and remove the now-stale +`process.on('unhandledRejection', ...)` workaround (`swallowedRejections` array, +`beforeAll`/`afterAll`) — it was pure dead weight once the missing-`await` bug was fixed +upstream, since there's no more orphaned promise to crash the worker. + +**Restoring the previously-omitted "Paperless reachable" scenario (17) uncovered a THIRD, +previously-masked bug — filed as GitHub issue #1884 (not fixed, per protocol):** +`getSourceReport` does `for (const doc of docs)` over the `Map` +returned by `paperlessService.getDocuments()`. Iterating a `Map` with a bare `for...of` yields +`[key, value]` **tuples**, not the value objects — so `doc.documentId` (also wrong: the field is +`.id` on `PaperlessDocument`, not `.documentId`) and `doc.archiveSerialNumber`/`doc.title` are all +`undefined`. Result: `paperlessDocMap` ends up keyed by `undefined`, so the real document id never +matches, and ASN/title **silently degrade to `null` even when Paperless is reachable and returns +valid data** — no exception, no test crash, just permanently-broken enrichment. This is distinct +from and layered underneath bug #4 above (`link.documentId` vs `link.paperlessDocumentId` — that +one was already fixed by the time this session ran; #1884 is the map-iteration issue, found only +once the first two blocking bugs were cleared and the success path became reachable for the first +time). Fix for backend-developer: `for (const doc of docs.values())` (or destructure the tuple), +and use `doc.id` not `doc.documentId`. + +**Handling a real bug discovered via a "must be fully green" test-fix task**: per the +test-failure-debugging protocol (don't weaken a correct test to fit buggy code, don't silently +drop it either), the new scenario-17 test asserts the *correct*/spec-conformant behavior, is left +in the file `it.skip`'d (not deleted, not inverted to assert `null`) with a comment pointing at +#1884, and the story's memory + a filed GitHub issue carry the paper trail. Scenario 18 +("getDocuments throws → degrades to null") is unaffected by this bug (the catch block leaves the +map empty via a different path) and was restored as a normal passing test. + +**Branch-coverage ceiling after all legitimately-reachable gaps are closed**: final state is +98.29% stmts / 90.72% branch / 100% funcs / 98.24% lines on `sourceReportService.ts` (32 pass, 1 +skip, 0 fail). Branch coverage cannot reach 95% here without either fixing #1884 or writing +contrived tests against type-system-guaranteed-unreachable defensive code — after adding 4 small +legitimate branch tests (deposit status outside the target slice; null `invoiceNumber` → `'N/A'` +diary fallback; already-claimed/non-transitionable deposit alongside a directly-claimable invoice), +the remaining 9 uncovered branches split into: 4 blocked by #1884 (the `paperlessEntry?.x ?? null` +truthy branches), and 5 that are dead code by construction — `ALLOWED_TRANSITIONS: Record` guarantees every valid status key is present, so its `?.`/`?? false` fallbacks can never +trigger with valid enum data (same reasoning applies to `isSplitMap.get(id) ?? false`, and to +`fetchPaperless`'s `err instanceof Error` check always being true because `fetchPaperless` itself +catches raw fetch rejections and always rethrows an `AppError`, an `Error` subclass — confirmed by +writing a `mockRejectedValue('boom')` (non-Error) test and observing it still hit the +`instanceof Error === true` branch, then deleting that test since it added no coverage). **Lesson:** +when chasing the last few % of branch coverage, check whether the "uncovered" branch is reachable +at all given the codebase's own type guarantees (`Record` types, wrapper functions +that always normalize to a single error shape) before writing a test for it — some optional-chain +fallbacks are unreachable-by-design, not coverage gaps. + +## Story #1878 outcome (2026-07-29) + +Wrote/extended 6 test files per QA spec (43 scenarios): `depositAggregateUtils.test.ts` +(+7 scenarios, 100%/96.8% cov), `sourceReportService.test.ts` (new, 93.91%/81.44% cov, 8 +pass/20 fail), `routes/sourceReports.test.ts` (new, 71.42%/50% cov, 9 pass/5 fail), +`paperlessService.test.ts` (+6 scenarios, 97.31%/91.22% cov, all pass), `routes/paperless.test.ts` +(+8 scenarios, 89.01%/74.46% cov of whole file — new code fully covered, all pass), +`invoiceDepositService.test.ts` (fixed 2 obsolete tests + 5 new, 97.22%/89.83% cov, 80 pass/1 fail). + +**5 real production bugs found, all reported (not fixed) per protocol** — see PR/issue for full +bug reports: + +1. **`sourceReportService.ts` line ~146/284: `sql.join(arr)` missing separator + unwrapped raw + strings.** Correct usage (see `budgetServiceFactory.ts`): `sql.join(idItems.map(id => sql\`${id}\`), sql\`, \`)`. +The buggy version `sql.join(Array.from(targetStatuses))`throws`SqliteError: near "?": syntax + error`for any array with 2+ elements; **accidentally "works" for exactly 0 or 1 elements** +(no separator needed) — this false-negative masked the bug in naive single-invoice tests. +Net effect:`getSourceReport`throws on virtually every real invocation (any report type with +2+ target statuses — i.e.`claim`/`budget-overview`— crashes unconditionally;`proof-of-funds`crashes once more than 1 invoice matches a source). This is the dominant, blocking finding — +it explains ~20/28 test failures in`sourceReportService.test.ts` by itself. +2. **`sourceReportService.ts` `markInvoicesClaimed`: `db.transaction((tx) => {...})()` — extra + trailing `()`.** Drizzle's `db.transaction(cb)` executes `cb` immediately and returns its + result directly (see correct pattern in `milestoneService.ts`/`budgetLineAssignService.ts`: + `const result = db.transaction(() => {...});`, no trailing call). Since the callback has no + `return` statement, `db.transaction(cb)` evaluates to `undefined`, and the extra `()` then + throws `TypeError: db.transaction(...) is not a function` — but ONLY on the success path, + since exceptions thrown _inside_ the callback (409 InvoicesNotClaimableError, ValidationError) + propagate before ever reaching the trailing `()`. Confirmed with a 5-line repro against a real + better-sqlite3 db before writing the full suite — recommended technique for any `db.transaction` + review. +3. **`sourceReportService.ts` `getSourceReport`: calls the async `paperlessService.getDocuments()` + without `await`**, then does `for (const doc of docs)` over the still-pending Promise — + `TypeError: docs is not iterable`, caught by the surrounding try/catch (masks the real bug: ASN/ + title enrichment can never succeed). The orphaned, never-awaited `getDocuments()` promise + continues in the background; if it later **rejects**, it becomes a Node unhandled promise + rejection that reliably **crashed the entire Jest worker process** in a full-file run ("Jest + worker encountered 4 child process exceptions, exceeding retry limit") — not just failed one + test. Do NOT commit a test that exercises a rejecting mock through this path; see mitigation + notes below. Also: even if `await` were added, iterating a `Map` with `for...of` yields + `[key, value]` tuples, and `PaperlessDocument`'s id field is `.id`, not `.documentId` — bug #4 + below is a second, independent defect layered on the same block. +4. **`sourceReportService.ts`: `link.documentId` should be `link.paperlessDocumentId`.** The + `documentLinks` schema column is `paperlessDocumentId` (see `documentLinkService.ts` for the + correct field name). `link.documentId` is `undefined` on the actual Drizzle row type — confirmed + via both `tsc --noEmit` (TS2339) and a runtime assertion (`documents[0].documentId` is `4242` + in a real fixture but the code as written would never produce that). Every document object in + every report response has `documentId: undefined` regardless of Paperless configuration. +5. **`invoiceDepositService.ts` `updateDeposit`: the "Apply date side-effects" if/else chain has + no branch for the newly-widened `pending → claimed` transition.** It handles + `pending→paid`/`paid→claimed`/`paid→pending`/`claimed→paid` explicitly but falls through + silently for `pending→claimed`, leaving `claimedDate` as `null` instead of auto-setting it to + `today()` (inconsistent with both the `paid→claimed` branch and `createDeposit`'s + target-status-claimed handling, which DOES set both dates). Confirmed via a direct + `updateDeposit(db, invoiceId, id, { status: 'claimed' })` call from a pending deposit — this is + the ONLY genuinely-new-behavior test failure in `invoiceDepositService.test.ts` (80/81 pass). + +**Verified per orchestrator's specific ask**: `MarkClaimedResponse` in +`shared/src/types/sourceReport.ts` matches the spec exactly (`claimedInvoiceIds`/ +`claimedDepositIds` arrays) — **no deviation** from the QA spec here, despite the widespread bugs +elsewhere in the same story. + +Also flagged (non-blocking, lower severity): TS compile errors from `tsc -p server/tsconfig.json +--noEmit` not caught by any of the above runtime tests — `routes/sourceReports.ts:40` (`sourceId: +string | undefined` passed where `string` required), `paperlessService.ts:588` (`new +Blob([Buffer])` — `Buffer` not assignable to `BlobPart`, no pre-existing pattern +for this in the codebase to confirm it's a false positive), `sourceReportService.ts:128-131` (base +row query's extra joined columns — `vendor_id`/`vendor_name`/`invoice_number`/`invoice_date` — +aren't declared on the narrower `DepositAwareRow` type used for `db.all`; works at +runtime since JS doesn't enforce the type, but fails `tsc` and thus CI's typecheck gate). **Always +run `tsc -p server/tsconfig.json --noEmit` (after `cd shared && npx tsc` to refresh dist) on new +service files before writing tests** — it surfaced bugs #3/#4 and the row-typing issue well before +any test run did. + +## Reusable techniques discovered this story + +- **`sql.join` review checklist**: correct usage is always `sql.join(arrayOfSqlChunks, separator)` + — e.g. `sql.join(ids.map(id => sql\`${id}\`), sql\`, \`)`. A bare `sql.join(plainStringArray)`(no separator, unwrapped values) throws`SqliteError: near "?": syntax error`for 2+ elements but +silently "succeeds" for 0-1 elements — write at least one test case with 2+ array elements for +ANY new`sql.join` usage, or a single-element happy-path test will falsely validate broken code. +- **`db.transaction` review checklist**: correct pattern is `db.transaction((tx) => { ...; return +x; })` — no trailing `()`. If you see `db.transaction(cb)()`, that's a bug; a 5-line + better-sqlite3+drizzle repro script (see above) confirms in seconds without needing the full test + suite. +- **Missing-`await` on an async call inside a sync function, feeding a `for...of`**: throws + synchronously (caught locally if there's a try/catch) but leaves the real async call orphaned. + Never mock a _rejecting_ response through such a path in a committed test — the rejection can + surface as a Node unhandled-rejection _after_ the current test's teardown (registering + `process.on('unhandledRejection', ...)` in `beforeAll`/`afterAll` reduced but did NOT reliably + prevent a full worker crash in a multi-test file; Jest circus's OWN unhandled-rejection handling + only cleanly attributes the failure to "the current test" when the rejection lands within that + test's own synchronous+microtask window — a multi-hop async chain (tags fetch + doc fetch + + correspondent/type resolution, each a separate `await`) can outlive that window). Safest fix: + don't exercise the crash-inducing path with a rejection at all; use a resolving mock, or omit the + scenario with a clear comment citing the bug + a plan to add it back once the missing-`await` is + fixed upstream. +- **`invoice_budget_lines.work_item_budget_id` has a UNIQUE index** (nullable-partial: + `.where(isNotNull(...))`) — one invoice per budgeted line item. A fixture loop that creates N + invoices all pointing at the SAME `workItemBudgetId` violates this and throws `SqliteError: +UNIQUE constraint failed` — looked like a production bug at first glance but was my own fixture + mistake. Always create a fresh budget line per invoice in loop-based multi-invoice fixtures (see + `budgetSourceService.test.ts`'s `insertRawWorkItemWithSource` called once per invoice, never + reused). +- **`server/src/routes/*.test.ts` files that import `buildApp` from `app.ts` can fail with a hard + `jest-haste-map: Haste module naming collision` / `ModuleMap._assertNoDuplicates` error** (not + the usual soft "duplicate manual mock" warning for konva/react-konva) when many `.claude/ +worktrees/*` directories exist, each with their own `package.json` declaring the same package + name (`@cornerstone/shared`, `@cornerstone/server`, etc.). Confirmed this is NOT introduced by + any test changes — reproduces identically on a completely untouched pre-existing file + (`photos.test.ts`). **Fix: pass `--modulePathIgnorePatterns='/\.claude/worktrees'` on + the jest CLI invocation** (no need to touch the committed `jest.config.ts`) — this resolved it + every time in this session. Smaller test files that don't import `app.ts` (service-level unit + tests) did not hit this, so try without the flag first and only add it if you see this specific + hard error (not the soft konva/react-konva warnings, which are always safe to ignore). +- **Coverage measures line execution, not assertion success.** A test file with mostly-FAILING + assertions can still show high statement/line coverage (93.91% in `sourceReportService.test.ts` + with 20/28 tests failing) because Istanbul instruments code that _ran and then threw_, not code + that _passed its assertions_. Don't infer test health from the coverage percentage alone — always + cross-check the pass/fail count. diff --git a/e2e/tests/orientations.spec.ts b/e2e/tests/orientations.spec.ts index a5625ccc1..78eaedbf7 100644 --- a/e2e/tests/orientations.spec.ts +++ b/e2e/tests/orientations.spec.ts @@ -377,7 +377,7 @@ test.describe('ManagePage — Orientations tab navigation', { tag: '@responsive' await expect(orientationsPage.createFormHeading).toBeVisible(); }); - test('ManagePage now has 5 tabs (Areas, Trades, Orientations, Budget Categories, HI Categories)', async ({ + test('ManagePage now has 6 tabs (Household, Areas, Trades, Orientations, Budget Categories, HI Categories)', async ({ page, }) => { await page.goto(MANAGE_ROUTE); @@ -386,7 +386,7 @@ test.describe('ManagePage — Orientations tab navigation', { tag: '@responsive' }); const tabs = page.getByRole('tab'); - await expect(tabs).toHaveCount(5); + await expect(tabs).toHaveCount(6); // Verify Orientations tab exists await expect(page.getByRole('tab', { name: 'Orientations', exact: true })).toBeVisible(); diff --git a/server/src/app.ts b/server/src/app.ts index c61b785e3..59203548b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -41,6 +41,7 @@ import workItemSubsidyRoutes from './routes/workItemSubsidies.js'; import workItemSubsidyPaybackRoutes from './routes/workItemSubsidyPayback.js'; import workItemBudgetRoutes from './routes/workItemBudgets.js'; import budgetOverviewRoutes from './routes/budgetOverview.js'; +import sourceReportRoutes from './routes/sourceReports.js'; import milestoneRoutes from './routes/milestones.js'; import workItemMilestoneRoutes from './routes/workItemMilestones.js'; import scheduleRoutes from './routes/schedule.js'; @@ -207,6 +208,9 @@ export async function buildApp(): Promise { // Budget overview (aggregation dashboard endpoint) await app.register(budgetOverviewRoutes, { prefix: '/api/budget' }); + // Source report routes (EPIC-20: Creditor Reporting) + await app.register(sourceReportRoutes, { prefix: '/api/source-reports' }); + // Milestone routes (EPIC-06: Timeline, Gantt Chart & Dependency Management) await app.register(milestoneRoutes, { prefix: '/api/milestones' }); diff --git a/server/src/errors/AppError.ts b/server/src/errors/AppError.ts index 4134717bd..8be0fe592 100644 --- a/server/src/errors/AppError.ts +++ b/server/src/errors/AppError.ts @@ -289,6 +289,16 @@ export class StaleOwnershipError extends AppError { } } +export class InvoicesNotClaimableError extends AppError { + constructor( + message = 'One or more invoices are not in a claimable state', + details?: Record, + ) { + super('INVOICES_NOT_CLAIMABLE', 409, message, details); + this.name = 'InvoicesNotClaimableError'; + } +} + export class DepositsExceedInvoiceTotalError extends AppError { constructor( message = 'Sum of deposit amounts would exceed the invoice total', diff --git a/server/src/routes/invoiceDeposits.test.ts b/server/src/routes/invoiceDeposits.test.ts index c73fd6ceb..0c33b479c 100644 --- a/server/src/routes/invoiceDeposits.test.ts +++ b/server/src/routes/invoiceDeposits.test.ts @@ -479,13 +479,16 @@ describe('Invoice Deposit Routes', () => { ); const vendorId = createTestVendor(); const invoiceId = createTestInvoice(vendorId, 1000); - const depositId = createTestDeposit(invoiceId, userId, 300, 'pending'); + // Story #1878 widened ALLOWED_TRANSITIONS.pending to ['paid', 'claimed'], so pending → + // claimed is now valid (see scenario 47b below). claimed → pending remains disallowed + // (ALLOWED_TRANSITIONS.claimed = ['paid']), so exercise that transition here instead. + const depositId = createTestDeposit(invoiceId, userId, 300, 'claimed'); const response = await app.inject({ method: 'PATCH', url: `/api/invoices/${invoiceId}/deposits/${depositId}`, headers: { cookie }, - payload: { status: 'claimed' }, // pending → claimed is disallowed + payload: { status: 'pending' }, // claimed → pending is disallowed }); expect(response.statusCode).toBe(400); @@ -493,6 +496,33 @@ describe('Invoice Deposit Routes', () => { expect(body.error.code).toBe('INVALID_DEPOSIT_STATUS_TRANSITION'); }); + it('scenario 47b: 200 pending → claimed now succeeds (Story #1878); both paidDate and claimedDate auto-set to today', async () => { + const { userId, cookie } = await createUserWithSession( + 'user7b@test.com', + 'Test User', + 'password123', + ); + const vendorId = createTestVendor(); + const invoiceId = createTestInvoice(vendorId, 1000); + const depositId = createTestDeposit(invoiceId, userId, 300, 'pending'); + const today = new Date().toLocaleDateString('en-CA'); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/invoices/${invoiceId}/deposits/${depositId}`, + headers: { cookie }, + payload: { status: 'claimed' }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json<{ + deposit: { status: string; paidDate: string | null; claimedDate: string | null }; + }>(); + expect(body.deposit.status).toBe('claimed'); + expect(body.deposit.paidDate).toBe(today); + expect(body.deposit.claimedDate).toBe(today); + }); + it('scenario 48: 404 deposit not found', async () => { const { cookie } = await createUserWithSession('user8@test.com', 'Test User', 'password123'); const vendorId = createTestVendor(); diff --git a/server/src/routes/paperless.test.ts b/server/src/routes/paperless.test.ts index 6050b72d9..9b1bf5e65 100644 --- a/server/src/routes/paperless.test.ts +++ b/server/src/routes/paperless.test.ts @@ -997,4 +997,199 @@ describe('Paperless Routes', () => { expect(body.error.code).toBe('PAPERLESS_UNREACHABLE'); }); }); + + // ─── POST /api/paperless/documents ───────────────────────────────────────── + + describe('POST /api/paperless/documents', () => { + /** + * Build a multipart/form-data body from parts (mirrors photos.test.ts's helper). + */ + function buildMultipartBody( + parts: Array<{ + name: string; + value: string | Buffer; + filename?: string; + contentType?: string; + }>, + ): { body: Buffer; contentType: string } { + const boundary = 'test-boundary-paperless-upload'; + const CRLF = '\r\n'; + const chunks: Buffer[] = []; + + for (const part of parts) { + let header = `--${boundary}${CRLF}`; + if (part.filename) { + header += `Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"${CRLF}`; + header += `Content-Type: ${part.contentType ?? 'application/octet-stream'}${CRLF}`; + } else { + header += `Content-Disposition: form-data; name="${part.name}"${CRLF}`; + } + header += CRLF; + + chunks.push(Buffer.from(header)); + chunks.push(typeof part.value === 'string' ? Buffer.from(part.value) : part.value); + chunks.push(Buffer.from(CRLF)); + } + + chunks.push(Buffer.from(`--${boundary}--${CRLF}`)); + + return { + body: Buffer.concat(chunks), + contentType: `multipart/form-data; boundary=${boundary}`, + }; + } + + const PDF_PART = { + name: 'document', + value: Buffer.from('%PDF-1.4 fake pdf content'), + filename: 'invoice.pdf', + contentType: 'application/pdf', + }; + + it('scenario 40: returns 401 when not authenticated', async () => { + const { body, contentType } = buildMultipartBody([ + PDF_PART, + { name: 'title', value: 'Invoice' }, + ]); + + const response = await app.inject({ + method: 'POST', + url: '/api/paperless/documents', + headers: { 'content-type': contentType }, + payload: body, + }); + + expect(response.statusCode).toBe(401); + }); + + it('scenario 36: returns 503 PAPERLESS_NOT_CONFIGURED when not configured', async () => { + const { cookie } = await createUserWithSession(); + const { body, contentType } = buildMultipartBody([ + PDF_PART, + { name: 'title', value: 'Invoice' }, + ]); + + const response = await app.inject({ + method: 'POST', + url: '/api/paperless/documents', + headers: { cookie, 'content-type': contentType }, + payload: body, + }); + + expect(response.statusCode).toBe(503); + const responseBody = response.json(); + expect(responseBody.error.code).toBe('PAPERLESS_NOT_CONFIGURED'); + }); + + it('scenario 35: valid PDF + title, configured → 201 with taskId', async () => { + await rebuildAppWithPaperless(); + const { cookie } = await createUserWithSession(); + const { body, contentType } = buildMultipartBody([ + PDF_PART, + { name: 'title', value: 'Invoice from Builder Co' }, + ]); + + mockFetch.mockResolvedValueOnce(mockJsonResponse('task-uuid-123')); + + const response = await app.inject({ + method: 'POST', + url: '/api/paperless/documents', + headers: { cookie, 'content-type': contentType }, + payload: body, + }); + + expect(response.statusCode).toBe(201); + const responseBody = response.json<{ taskId: string }>(); + expect(responseBody.taskId).toBe('task-uuid-123'); + }); + + it('scenario 37: non-PDF file → 400, upload never attempted', async () => { + await rebuildAppWithPaperless(); + const { cookie } = await createUserWithSession(); + const { body, contentType } = buildMultipartBody([ + { + name: 'document', + value: Buffer.from('not a pdf'), + filename: 'invoice.txt', + contentType: 'text/plain', + }, + { name: 'title', value: 'Invoice' }, + ]); + + const response = await app.inject({ + method: 'POST', + url: '/api/paperless/documents', + headers: { cookie, 'content-type': contentType }, + payload: body, + }); + + expect(response.statusCode).toBe(400); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('scenario 38: missing title field → 400', async () => { + await rebuildAppWithPaperless(); + const { cookie } = await createUserWithSession(); + const { body, contentType } = buildMultipartBody([PDF_PART]); + + const response = await app.inject({ + method: 'POST', + url: '/api/paperless/documents', + headers: { cookie, 'content-type': contentType }, + payload: body, + }); + + expect(response.statusCode).toBe(400); + }); + + it('scenario 39: no file uploaded → 400', async () => { + await rebuildAppWithPaperless(); + const { cookie } = await createUserWithSession(); + const { body, contentType } = buildMultipartBody([{ name: 'title', value: 'Invoice' }]); + + const response = await app.inject({ + method: 'POST', + url: '/api/paperless/documents', + headers: { cookie, 'content-type': contentType }, + payload: body, + }); + + expect(response.statusCode).toBe(400); + }); + + it('unresolvable filter tag configured → upload still succeeds untagged (201)', async () => { + await rebuildAppWithPaperless(); + process.env.PAPERLESS_FILTER_TAG = 'nonexistent-tag'; + await app.close(); + app = await buildApp(); + const { cookie } = await createUserWithSession(); + const { body, contentType } = buildMultipartBody([ + PDF_PART, + { name: 'title', value: 'Invoice' }, + ]); + + // 1. resolveFilterTagId's tag lookup returns no match + mockFetch.mockResolvedValueOnce(mockJsonResponse({ count: 0, results: [] })); + // 2. the upload itself proceeds untagged + mockFetch.mockResolvedValueOnce(mockJsonResponse('task-untagged')); + + const response = await app.inject({ + method: 'POST', + url: '/api/paperless/documents', + headers: { cookie, 'content-type': contentType }, + payload: body, + }); + + expect(response.statusCode).toBe(201); + const responseBody = response.json<{ taskId: string }>(); + expect(responseBody.taskId).toBe('task-untagged'); + + delete process.env.PAPERLESS_FILTER_TAG; + }); + + // scenario 41 (oversized → 413) is covered by errorHandler.test.ts's existing + // FST_REQ_FILE_TOO_LARGE → 413 mapping test (server/src/plugins/errorHandler.test.ts) — + // that test already exercises the same global 50MB multipart cap (app.ts) this route + // relies on; duplicating it here would test Fastify's multipart plugin, not this route. + }); }); diff --git a/server/src/routes/paperless.ts b/server/src/routes/paperless.ts index c7b7576e5..d59a8159e 100644 --- a/server/src/routes/paperless.ts +++ b/server/src/routes/paperless.ts @@ -13,7 +13,7 @@ */ import type { FastifyInstance } from 'fastify'; -import { AppError, UnauthorizedError } from '../errors/AppError.js'; +import { AppError, UnauthorizedError, ValidationError } from '../errors/AppError.js'; import * as paperlessService from '../services/paperlessService.js'; import type { PaperlessDocumentListQuery } from '@cornerstone/shared'; @@ -298,4 +298,53 @@ export default async function paperlessRoutes(fastify: FastifyInstance) { const result = await paperlessService.listCorrespondents(baseUrl, token); return reply.status(200).send(result); }); + + /** + * POST /api/paperless/documents + * + * Upload a PDF document to Paperless-ngx's consumption pipeline. + * Multipart form: document (file, required), title (string, required) + * Returns: { taskId: string } — the Paperless consumption task UUID + * File is validated to be PDF only; errors for missing file, non-PDF, or title. + * + * Auth required: Yes + */ + fastify.post('/documents', async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + const { baseUrl, token } = requirePaperless(fastify); + + // Request multipart form + const file = await request.file(); + if (!file) { + throw new ValidationError('No file uploaded'); + } + + // MIME check BEFORE toBuffer — reuse exactly with `application/pdf` + if (file.mimetype !== 'application/pdf') { + throw new ValidationError('Only PDF documents can be uploaded'); + } + + const buffer = await file.toBuffer(); + + // Extract title from form fields + const fields = file.fields as Record; + const titleField = fields['title']; + if (!titleField?.value) { + throw new ValidationError('Missing required field: title'); + } + const title = titleField.value; + + // Upload to Paperless + const result = await paperlessService.uploadDocument(baseUrl, token, { + buffer, + filename: file.filename, + title, + filterTagName: fastify.config.paperlessFilterTag, + }); + + return reply.status(201).send(result); + }); } diff --git a/server/src/routes/sourceReports.test.ts b/server/src/routes/sourceReports.test.ts new file mode 100644 index 000000000..f545ea3d6 --- /dev/null +++ b/server/src/routes/sourceReports.test.ts @@ -0,0 +1,383 @@ +/** + * Integration tests for source report routes. + * + * Story #1878 — Source report backend: report data, mark-claimed & Paperless upload proxy + * + * Tests cover: + * - GET /api/source-reports (auth, query validation, 404 unknown source, 200 success shape) + * - POST /api/source-reports/mark-claimed (auth, body validation, 409 with details, 200 success) + */ + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { buildApp } from '../app.js'; +import * as userService from '../services/userService.js'; +import * as sessionService from '../services/sessionService.js'; +import * as schema from '../db/schema.js'; +import type { FastifyInstance } from 'fastify'; +import type { + ApiErrorResponse, + SourceReportResponse, + MarkClaimedResponse, +} from '@cornerstone/shared'; + +describe('Source Report Routes', () => { + let app: FastifyInstance; + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + let counter = 0; + + beforeEach(async () => { + originalEnv = { ...process.env }; + + tempDir = mkdtempSync(join(tmpdir(), 'cornerstone-source-reports-test-')); + process.env.DATABASE_URL = join(tempDir, 'test.db'); + process.env.SECURE_COOKIES = 'false'; + + app = await buildApp(); + counter = 0; + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + process.env = originalEnv; + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + async function createUserWithSession( + email = 'user@example.com', + role: 'admin' | 'member' = 'member', + ): Promise<{ cookie: string }> { + const user = await userService.createLocalUser(app.db, email, 'Test User', 'password', role); + const token = sessionService.createSession(app.db, user.id, 3600); + return { cookie: `cornerstone_session=${token}` }; + } + + function ts(): string { + return new Date(Date.now() + counter++).toISOString(); + } + + function insertSource(): string { + const id = `src-route-${++counter}`; + const now = ts(); + app.db + .insert(schema.budgetSources) + .values({ + id, + name: 'Test Source', + sourceType: 'bank_loan', + totalAmount: 100000, + isDiscretionary: false, + status: 'active', + createdAt: now, + updatedAt: now, + }) + .run(); + return id; + } + + function insertVendor(): string { + const id = `vendor-route-${++counter}`; + const now = ts(); + app.db + .insert(schema.vendors) + .values({ id, name: 'Test Vendor', createdAt: now, updatedAt: now }) + .run(); + return id; + } + + function insertWorkItemBudget(sourceId: string): string { + const wiId = `wi-route-${++counter}`; + const budgetId = `wib-route-${counter}`; + const now = ts(); + app.db + .insert(schema.workItems) + .values({ + id: wiId, + title: `WI ${counter}`, + status: 'not_started', + createdAt: now, + updatedAt: now, + }) + .run(); + app.db + .insert(schema.workItemBudgets) + .values({ + id: budgetId, + workItemId: wiId, + budgetSourceId: sourceId, + plannedAmount: 0, + confidence: 'own_estimate', + createdAt: now, + updatedAt: now, + }) + .run(); + return budgetId; + } + + function insertInvoice( + vendorId: string, + overrides: Partial = {}, + ): string { + const id = overrides.id ?? `inv-route-${++counter}`; + const now = ts(); + app.db + .insert(schema.invoices) + .values({ + vendorId, + amount: 1000, + date: '2026-01-15', + status: 'pending', + invoiceNumber: `INV-${counter}`, + createdAt: now, + updatedAt: now, + ...overrides, + id, + }) + .run(); + return id; + } + + function insertInvoiceBudgetLine( + invoiceId: string, + budgetId: string, + itemizedAmount: number, + ): void { + const now = ts(); + app.db + .insert(schema.invoiceBudgetLines) + .values({ + id: randomUUID(), + invoiceId, + workItemBudgetId: budgetId, + itemizedAmount, + createdAt: now, + updatedAt: now, + }) + .run(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // GET /api/source-reports + // ═══════════════════════════════════════════════════════════════════════ + + describe('GET /api/source-reports', () => { + it('returns 401 when not authenticated', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/source-reports?type=claim&sourceId=whatever', + }); + + expect(response.statusCode).toBe(401); + }); + + it('returns 400 when type is missing', async () => { + const { cookie } = await createUserWithSession(); + const sourceId = insertSource(); + + const response = await app.inject({ + method: 'GET', + url: `/api/source-reports?sourceId=${sourceId}`, + headers: { cookie }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('returns 400 when sourceId is missing', async () => { + const { cookie } = await createUserWithSession(); + + const response = await app.inject({ + method: 'GET', + url: '/api/source-reports?type=claim', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('returns 400 for an invalid type value', async () => { + const { cookie } = await createUserWithSession(); + const sourceId = insertSource(); + + const response = await app.inject({ + method: 'GET', + url: `/api/source-reports?type=not-a-real-type&sourceId=${sourceId}`, + headers: { cookie }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('returns 404 for an unknown sourceId', async () => { + const { cookie } = await createUserWithSession(); + + const response = await app.inject({ + method: 'GET', + url: '/api/source-reports?type=claim&sourceId=does-not-exist', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(404); + const body = response.json(); + expect(body.error.code).toBe('NOT_FOUND'); + }); + + it('returns 200 with the source report for a source with no matched invoices', async () => { + const { cookie } = await createUserWithSession(); + const sourceId = insertSource(); + + const response = await app.inject({ + method: 'GET', + url: `/api/source-reports?type=budget-overview&sourceId=${sourceId}`, + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json<{ report: SourceReportResponse }>(); + expect(body.report.type).toBe('budget-overview'); + expect(body.report.source.id).toBe(sourceId); + expect(body.report.invoices).toEqual([]); + expect(body.report.unallocatedInvoices).toEqual([]); + }); + + it('returns 200 with populated invoices for a source with matched invoices', async () => { + const { cookie } = await createUserWithSession(); + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetId = insertWorkItemBudget(sourceId); + const invId = insertInvoice(vendorId, { status: 'paid', amount: 500 }); + insertInvoiceBudgetLine(invId, budgetId, 500); + + const response = await app.inject({ + method: 'GET', + url: `/api/source-reports?type=claim&sourceId=${sourceId}`, + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json<{ report: SourceReportResponse }>(); + expect(body.report.invoices).toHaveLength(1); + expect(body.report.invoices[0]!.invoiceId).toBe(invId); + expect(body.report.totalAmount).toBeCloseTo(500); + }); + + it('silently strips unknown query parameters (Fastify/AJV removeAdditional default) rather than rejecting with 400', async () => { + // @fastify/ajv-compiler defaults to removeAdditional: true — additionalProperties: + // false STRIPS unknown properties instead of returning 400. See qa-integration-tester + // memory: "Fastify AJV Default: removeAdditional=true". + const { cookie } = await createUserWithSession(); + const sourceId = insertSource(); + + const response = await app.inject({ + method: 'GET', + url: `/api/source-reports?type=budget-overview&sourceId=${sourceId}&bogus=1`, + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // POST /api/source-reports/mark-claimed + // ═══════════════════════════════════════════════════════════════════════ + + describe('POST /api/source-reports/mark-claimed', () => { + it('returns 401 when not authenticated', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/source-reports/mark-claimed', + payload: { invoiceIds: ['inv-1'] }, + }); + + expect(response.statusCode).toBe(401); + }); + + it('returns 400 when invoiceIds is missing', async () => { + const { cookie } = await createUserWithSession(); + + const response = await app.inject({ + method: 'POST', + url: '/api/source-reports/mark-claimed', + headers: { cookie }, + payload: {}, + }); + + expect(response.statusCode).toBe(400); + }); + + it('returns 400 when invoiceIds is an empty array (minItems: 1)', async () => { + const { cookie } = await createUserWithSession(); + + const response = await app.inject({ + method: 'POST', + url: '/api/source-reports/mark-claimed', + headers: { cookie }, + payload: { invoiceIds: [] }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('returns 409 INVOICES_NOT_CLAIMABLE with offending ids when an invoice is not claimable', async () => { + const { cookie } = await createUserWithSession(); + const vendorId = insertVendor(); + const invId = insertInvoice(vendorId, { status: 'quotation' }); + + const response = await app.inject({ + method: 'POST', + url: '/api/source-reports/mark-claimed', + headers: { cookie }, + payload: { invoiceIds: [invId] }, + }); + + expect(response.statusCode).toBe(409); + const body = response.json(); + expect(body.error.code).toBe('INVOICES_NOT_CLAIMABLE'); + expect(body.error.details).toMatchObject({ invoiceIds: [invId] }); + }); + + it('returns 200 with claimedInvoiceIds/claimedDepositIds for a valid batch', async () => { + const { cookie } = await createUserWithSession(); + const vendorId = insertVendor(); + const invId = insertInvoice(vendorId, { status: 'pending' }); + + const response = await app.inject({ + method: 'POST', + url: '/api/source-reports/mark-claimed', + headers: { cookie }, + payload: { invoiceIds: [invId] }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.claimedInvoiceIds).toEqual([invId]); + expect(body.claimedDepositIds).toEqual([]); + }); + + it('silently strips unknown body properties (Fastify/AJV removeAdditional default) and still processes the request', async () => { + const { cookie } = await createUserWithSession(); + const vendorId = insertVendor(); + const invId = insertInvoice(vendorId, { status: 'pending' }); + + const response = await app.inject({ + method: 'POST', + url: '/api/source-reports/mark-claimed', + headers: { cookie }, + payload: { invoiceIds: [invId], bogus: 'field' }, + }); + + expect(response.statusCode).toBe(200); + }); + }); +}); diff --git a/server/src/routes/sourceReports.ts b/server/src/routes/sourceReports.ts new file mode 100644 index 000000000..fe2ca3d98 --- /dev/null +++ b/server/src/routes/sourceReports.ts @@ -0,0 +1,94 @@ +import type { FastifyInstance } from 'fastify'; +import { UnauthorizedError } from '../errors/AppError.js'; +import { getSourceReport, markInvoicesClaimed } from '../services/sourceReportService.js'; +import type { SourceReportType, MarkClaimedRequest } from '@cornerstone/shared'; + +export default async function sourceReportRoutes(fastify: FastifyInstance) { + /** + * GET /api/source-reports + * Returns a source report for a given budget source and report type. + * Query params: type (budget-overview|claim|proof-of-funds), sourceId (required) + * Auth required: Yes (both admin and member) + */ + fastify.get<{ + Querystring: { + type?: string; + sourceId?: string; + }; + }>( + '/', + { + schema: { + querystring: { + type: 'object', + properties: { + type: { type: 'string', enum: ['budget-overview', 'claim', 'proof-of-funds'] }, + sourceId: { type: 'string' }, + }, + required: ['type', 'sourceId'], + additionalProperties: false, + }, + }, + }, + async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + const { type, sourceId } = request.query; + + const report = await getSourceReport( + fastify.db, + type as SourceReportType, + sourceId as string, + { + paperlessEnabled: fastify.config.paperlessEnabled, + paperlessUrl: fastify.config.paperlessUrl, + paperlessApiToken: fastify.config.paperlessApiToken, + }, + ); + + return reply.status(200).send({ report }); + }, + ); + + /** + * POST /api/source-reports/mark-claimed + * Mark a batch of invoices as claimed. + * Body: { invoiceIds: string[] } + * Returns: { claimedInvoiceIds: string[], claimedDepositIds: string[] } + * Auth required: Yes (both admin and member) + */ + fastify.post<{ + Body: MarkClaimedRequest; + }>( + '/mark-claimed', + { + schema: { + body: { + type: 'object', + properties: { + invoiceIds: { + type: 'array', + items: { type: 'string' }, + minItems: 1, + }, + }, + required: ['invoiceIds'], + additionalProperties: false, + }, + }, + }, + async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + const { invoiceIds } = request.body; + + const response = markInvoicesClaimed(fastify.db, invoiceIds, fastify.config.diaryAutoEvents); + + return reply.status(200).send(response); + }, + ); +} diff --git a/server/src/services/invoiceDepositService.test.ts b/server/src/services/invoiceDepositService.test.ts index 5eb8300f2..cebc6ce20 100644 --- a/server/src/services/invoiceDepositService.test.ts +++ b/server/src/services/invoiceDepositService.test.ts @@ -385,30 +385,87 @@ describe('invoiceDepositService', () => { // ─── State machine — disallowed transitions ───────────────────────────────── describe('State machine — disallowed transitions', () => { - it('scenario 12: pending → claimed throws InvalidDepositStatusTransitionError with correct details', () => { + // Story #1878 widened ALLOWED_TRANSITIONS.pending to ['paid', 'claimed'] (deposits can + // now be claimed directly via the source-report mark-claimed flow, or via a direct PATCH). + // Scenario 12 (originally "pending → claimed throws") is therefore superseded — see the + // "State machine — pending → claimed (Story #1878)" describe block below for the new, + // now-valid behavior. + + it('scenario 13: claimed → pending throws InvalidDepositStatusTransitionError with correct details', () => { const { userId, invoiceId } = setup(); - const deposit = createDeposit(db, invoiceId, { amount: 300, dueDate: '2026-02-01' }, userId); + const deposit = createDeposit( + db, + invoiceId, + { amount: 300, dueDate: '2026-02-01', status: 'paid' }, + userId, + ); + const claimed = updateDeposit(db, invoiceId, deposit.id, { status: 'claimed' }); + expect(claimed.status).toBe('claimed'); expect(() => { - updateDeposit(db, invoiceId, deposit.id, { status: 'claimed' }); + updateDeposit(db, invoiceId, deposit.id, { status: 'pending' }); }).toThrow(InvalidDepositStatusTransitionError); try { - updateDeposit(db, invoiceId, deposit.id, { status: 'claimed' }); + updateDeposit(db, invoiceId, deposit.id, { status: 'pending' }); } catch (e) { const err = e as InvalidDepositStatusTransitionError; expect(err.details).toMatchObject({ - from: 'pending', - to: 'claimed', + from: 'claimed', + to: 'pending', allowedTargets: ['paid'], }); } }); + }); - it('scenario 13: claimed → pending throws InvalidDepositStatusTransitionError with correct details', () => { + // ─── State machine — pending → claimed (Story #1878) ───────────────────────── + + describe('State machine — pending → claimed (Story #1878)', () => { + it('scenario 42: PATCH pending → claimed now succeeds (no longer throws)', () => { const { userId, invoiceId } = setup(); + const deposit = createDeposit(db, invoiceId, { amount: 300, dueDate: '2026-02-01' }, userId); + expect(deposit.status).toBe('pending'); + + const updated = updateDeposit(db, invoiceId, deposit.id, { status: 'claimed' }); + + expect(updated.status).toBe('claimed'); + }); + + it('scenario 42b: PATCH pending → claimed auto-sets claimedDate to today, consistent with the paid → claimed and create-with-status=claimed paths', () => { + // AC-14's date side-effect table auto-sets claimedDate on every arrival at 'claimed' + // (see the paid → claimed branch in updateDeposit, and createDeposit's target-status + // handling for 'claimed'). This asserts the newly-reachable pending → claimed + // transition follows the same rule. + const { userId, invoiceId } = setup(); + const today = new Date().toLocaleDateString('en-CA'); + const deposit = createDeposit(db, invoiceId, { amount: 300, dueDate: '2026-02-01' }, userId); + + const updated = updateDeposit(db, invoiceId, deposit.id, { status: 'claimed' }); + + expect(updated.claimedDate).toBe(today); + }); + + it('scenario 43 (regression): pending → paid, paid → claimed, paid → pending, claimed → paid all remain valid', () => { + const { userId, invoiceId } = setup(); + const deposit = createDeposit(db, invoiceId, { amount: 300, dueDate: '2026-02-01' }, userId); + + const paid = updateDeposit(db, invoiceId, deposit.id, { status: 'paid' }); + expect(paid.status).toBe('paid'); + + const claimed = updateDeposit(db, invoiceId, deposit.id, { status: 'claimed' }); + expect(claimed.status).toBe('claimed'); + const backToPaid = updateDeposit(db, invoiceId, deposit.id, { status: 'paid' }); + expect(backToPaid.status).toBe('paid'); + + const backToPending = updateDeposit(db, invoiceId, deposit.id, { status: 'pending' }); + expect(backToPending.status).toBe('pending'); + }); + + it('scenario 43b (regression): claimed → pending remains disallowed', () => { + const { userId, invoiceId } = setup(); const deposit = createDeposit( db, invoiceId, @@ -421,17 +478,6 @@ describe('invoiceDepositService', () => { expect(() => { updateDeposit(db, invoiceId, deposit.id, { status: 'pending' }); }).toThrow(InvalidDepositStatusTransitionError); - - try { - updateDeposit(db, invoiceId, deposit.id, { status: 'pending' }); - } catch (e) { - const err = e as InvalidDepositStatusTransitionError; - expect(err.details).toMatchObject({ - from: 'claimed', - to: 'pending', - allowedTargets: ['paid'], - }); - } }); }); @@ -615,33 +661,22 @@ describe('invoiceDepositService', () => { expect(deposit.paidDate).toBe(today); }); - it('scenario 25: create with status = claimed throws InvalidDepositStatusTransitionError (pending → claimed disallowed)', () => { + it('scenario 25 (Story #1878): create with status = claimed now succeeds; both paidDate and claimedDate auto-set', () => { + // ALLOWED_TRANSITIONS.pending now includes 'claimed' — createDeposit's initial-status + // check (`allowedFromPending.includes(targetStatus)`) allows this directly. const { userId, invoiceId } = setup(); + const today = new Date().toLocaleDateString('en-CA'); - expect(() => { - createDeposit( - db, - invoiceId, - { amount: 300, dueDate: '2026-02-01', status: 'claimed' }, - userId, - ); - }).toThrow(InvalidDepositStatusTransitionError); + const deposit = createDeposit( + db, + invoiceId, + { amount: 300, dueDate: '2026-02-01', status: 'claimed' }, + userId, + ); - try { - createDeposit( - db, - invoiceId, - { amount: 300, dueDate: '2026-02-01', status: 'claimed' }, - userId, - ); - } catch (e) { - const err = e as InvalidDepositStatusTransitionError; - expect(err.details).toMatchObject({ - from: 'pending', - to: 'claimed', - allowedTargets: ['paid'], - }); - } + expect(deposit.status).toBe('claimed'); + expect(deposit.paidDate).toBe(today); + expect(deposit.claimedDate).toBe(today); }); }); diff --git a/server/src/services/invoiceDepositService.ts b/server/src/services/invoiceDepositService.ts index 8efffad0b..8af61006d 100644 --- a/server/src/services/invoiceDepositService.ts +++ b/server/src/services/invoiceDepositService.ts @@ -61,8 +61,8 @@ function toUserSummary(user: typeof users.$inferSelect | null | undefined): User /** * Allowed status transitions for deposits. */ -const ALLOWED_TRANSITIONS: Record = { - pending: ['paid'], +export const ALLOWED_TRANSITIONS: Record = { + pending: ['paid', 'claimed'], paid: ['claimed', 'pending'], claimed: ['paid'], }; @@ -379,6 +379,10 @@ export function updateDeposit( // pending → paid: auto-set paid_date = today unless paidDate supplied effectiveNewPaidDate = data.paidDate ?? today(); effectiveNewClaimedDate = null; + } else if (oldStatus === 'pending' && newStatus === 'claimed') { + // pending → claimed: auto-set both paid_date and claimed_date = today unless supplied + effectiveNewPaidDate = data.paidDate ?? today(); + effectiveNewClaimedDate = data.claimedDate ?? today(); } else if (oldStatus === 'paid' && newStatus === 'claimed') { // paid → claimed: auto-set claimed_date = today unless claimedDate supplied effectiveNewClaimedDate = data.claimedDate ?? today(); diff --git a/server/src/services/paperlessService.test.ts b/server/src/services/paperlessService.test.ts index 1caa5804b..2b4d180d5 100644 --- a/server/src/services/paperlessService.test.ts +++ b/server/src/services/paperlessService.test.ts @@ -1065,3 +1065,89 @@ describe('Error codes and status codes', () => { } }); }); + +// ─── uploadDocument() ────────────────────────────────────────────────────────── + +describe('uploadDocument()', () => { + const INPUT = { + buffer: Buffer.from('%PDF-1.4 fake pdf bytes'), + filename: 'invoice.pdf', + title: 'Invoice from Builder Co', + }; + + function lastFetchBody(): FormData { + const lastCall = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!; + return lastCall[1]!.body as FormData; + } + + it('scenario 31: resolvable filter tag → tags field included in the multipart body, returns taskId', async () => { + // 1. resolveFilterTagId's tag lookup + mockFetch.mockResolvedValueOnce(mockJsonResponse(RAW_TAGS_RESPONSE)); + // 2. the upload itself — post_document returns the task UUID as a raw JSON string + mockFetch.mockResolvedValueOnce(mockJsonResponse('11111111-2222-3333-4444-555555555555')); + + const result = await paperlessService.uploadDocument(BASE_URL, TOKEN, { + ...INPUT, + filterTagName: 'invoice', + }); + + expect(result).toEqual({ taskId: '11111111-2222-3333-4444-555555555555' }); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const body = lastFetchBody(); + expect(body.has('tags')).toBe(true); + expect(body.get('tags')).toBe(String(RAW_TAG_1.id)); + expect(body.get('title')).toBe(INPUT.title); + + const [uploadUrl, uploadInit] = mockFetch.mock.calls[1]!; + expect(String(uploadUrl)).toBe(`${BASE_URL}/api/documents/post_document/`); + expect(uploadInit!.method).toBe('POST'); + expect((uploadInit!.headers as Record)['Authorization']).toBe(`Token ${TOKEN}`); + }); + + it('scenario 32: unresolvable filter tag → no tags field, upload still succeeds', async () => { + // 1. resolveFilterTagId's tag lookup returns no matching tag + mockFetch.mockResolvedValueOnce(mockJsonResponse({ count: 0, results: [] })); + // 2. the upload itself proceeds untagged + mockFetch.mockResolvedValueOnce(mockJsonResponse('task-untagged-id')); + + const result = await paperlessService.uploadDocument(BASE_URL, TOKEN, { + ...INPUT, + filterTagName: 'does-not-exist', + }); + + expect(result).toEqual({ taskId: 'task-untagged-id' }); + const body = lastFetchBody(); + expect(body.has('tags')).toBe(false); + }); + + it('no filterTagName provided → skips tag resolution entirely (single fetch call, no tags field)', async () => { + mockFetch.mockResolvedValueOnce(mockJsonResponse('task-no-tag')); + + const result = await paperlessService.uploadDocument(BASE_URL, TOKEN, INPUT); + + expect(result).toEqual({ taskId: 'task-no-tag' }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const body = lastFetchBody(); + expect(body.has('tags')).toBe(false); + expect(body.get('title')).toBe(INPUT.title); + }); + + it('scenario 33: network failure during upload → PAPERLESS_UNREACHABLE 502', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + await expect(paperlessService.uploadDocument(BASE_URL, TOKEN, INPUT)).rejects.toMatchObject({ + code: 'PAPERLESS_UNREACHABLE', + statusCode: 502, + }); + }); + + it('scenario 34: non-2xx response from Paperless → PAPERLESS_ERROR 502', async () => { + mockFetch.mockResolvedValueOnce(mockJsonResponse({ detail: 'Bad Request' }, 400)); + + await expect(paperlessService.uploadDocument(BASE_URL, TOKEN, INPUT)).rejects.toMatchObject({ + code: 'PAPERLESS_ERROR', + statusCode: 502, + }); + }); +}); diff --git a/server/src/services/paperlessService.ts b/server/src/services/paperlessService.ts index f7f49d2c1..feb677bbe 100644 --- a/server/src/services/paperlessService.ts +++ b/server/src/services/paperlessService.ts @@ -19,6 +19,7 @@ import type { PaperlessTagListResponse, PaperlessCorrespondentListResponse, PaperlessStatusResponse, + PaperlessUploadResponse, } from '@cornerstone/shared'; import { AppError } from '../errors/AppError.js'; @@ -283,7 +284,7 @@ function sanitizeErrorMessage(message: string): string { * Results are cached module-level to avoid repeated lookups. * Returns null if the tag is not found or if an error occurs. */ -async function resolveFilterTagId( +export async function resolveFilterTagId( baseUrl: string, token: string, tagName: string, @@ -561,6 +562,71 @@ export async function getDocuments( return result; } +export interface UploadDocumentInput { + buffer: Buffer; + filename: string; + title: string; + filterTagName?: string; +} + +/** + * Upload a PDF to Paperless-ngx's consumption pipeline. + * Returns the consumption task UUID (post_document does NOT return a document ID — + * uploads process asynchronously; no document_link is created — Paperless is the archive of record). + */ +export async function uploadDocument( + baseUrl: string, + token: string, + input: UploadDocumentInput, +): Promise { + let tagId: number | null = null; + if (input.filterTagName) { + tagId = await resolveFilterTagId(baseUrl, token, input.filterTagName); + // resolveFilterTagId already warns+returns null on failure — upload proceeds untagged. + } + + const form = new FormData(); + form.append( + 'document', + new Blob([new Uint8Array(input.buffer)], { type: 'application/pdf' }), + input.filename, + ); + form.append('title', input.title); + if (tagId !== null) { + form.append('tags', String(tagId)); + } + + let response: Response; + try { + response = await fetch(`${baseUrl}/api/documents/post_document/`, { + method: 'POST', + headers: { Authorization: `Token ${token}` }, + body: form, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new AppError('PAPERLESS_UNREACHABLE', 502, `Cannot connect to Paperless-ngx: ${message}`); + } + + if (!response.ok) { + throw new AppError( + 'PAPERLESS_ERROR', + 502, + `Paperless-ngx returned ${response.status}: ${response.statusText}`, + ); + } + + const taskId = await response.json(); + if (typeof taskId !== 'string') { + throw new AppError( + 'PAPERLESS_ERROR', + 502, + 'Unexpected response from Paperless-ngx: taskId is not a string', + ); + } + return { taskId }; +} + /** * List all tags available in Paperless-ngx, sorted by ID ascending. */ diff --git a/server/src/services/shared/depositAggregateUtils.test.ts b/server/src/services/shared/depositAggregateUtils.test.ts index f6ef2bdf7..140c00ad4 100644 --- a/server/src/services/shared/depositAggregateUtils.test.ts +++ b/server/src/services/shared/depositAggregateUtils.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from '@jest/globals'; import { computeDepositAwareAggregates, computeStatusContribution, + computeStatusContributionByInvoice, aggregateInvoiceStatusBreakdown, splitByDeposits, computeFinalPaymentAmount, @@ -563,6 +564,88 @@ describe('computeStatusContribution', () => { }); }); +// ─── computeStatusContributionByInvoice ─────────────────────────────────────── + +describe('computeStatusContributionByInvoice', () => { + it('scenario 1: no deposits, status in target → full itemized amount', () => { + const rows = [makeRow('ibl-1', 400, 'inv-1', 400, 'paid')]; + const result = computeStatusContributionByInvoice(rows, new Set(['pending', 'paid'])); + expect(result.get('inv-1')).toBeCloseTo(400); + }); + + it('scenario 2: no deposits, status NOT in target → invoice present in map with value 0', () => { + const rows = [makeRow('ibl-1', 400, 'inv-1', 400, 'claimed')]; + const result = computeStatusContributionByInvoice(rows, new Set(['pending', 'paid'])); + expect(result.has('inv-1')).toBe(true); + expect(result.get('inv-1')).toBe(0); + }); + + it('scenario 3: deposit paid + residual paid, target {paid} → both portions summed', () => { + // invoice=1000, ibl=1000, deposit 300 paid, residual (700) also paid (parent status = paid) + const rows = [ + makeRow('ibl-1', 1000, 'inv-1', 1000, 'paid', { id: 'd-1', amount: 300, status: 'paid' }), + ]; + const result = computeStatusContributionByInvoice(rows, new Set(['paid'])); + // deposit fraction 300/1000=0.3 → 1000*0.3=300; residual fraction 700/1000=0.7 → 1000*0.7=700 + expect(result.get('inv-1')).toBeCloseTo(1000); + }); + + it('scenario 4: deposit claimed + residual pending, target {pending,paid} → only residual counted', () => { + // invoice=1000, ibl=1000, deposit 400 claimed, residual 600 pending + const rows = [ + makeRow('ibl-1', 1000, 'inv-1', 1000, 'pending', { + id: 'd-1', + amount: 400, + status: 'claimed', + }), + ]; + const result = computeStatusContributionByInvoice(rows, new Set(['pending', 'paid'])); + // deposit claimed not in target → excluded; residual pending fraction 600/1000=0.6 → 1000*0.6=600 + expect(result.get('inv-1')).toBeCloseTo(600); + }); + + it('scenario 5: refund entry with status in target → negative contribution', () => { + // invoice=1000, ibl=1000, refund 300 claimed, no deposit; parent status pending + const rows = [ + makeRow('ibl-1', 1000, 'inv-1', 1000, 'pending', { + id: 'r-1', + amount: 300, + status: 'claimed', + entryType: 'refund', + }), + ]; + const result = computeStatusContributionByInvoice(rows, new Set(['claimed'])); + // residual pending not in target → 0; refund fraction -300/1000 → -300 + expect(result.get('inv-1')).toBeCloseTo(-300); + }); + + it('scenario 6: multiple ibl rows same invoice (same source) → accumulate per invoice', () => { + const rows = [ + makeRow('ibl-A', 300, 'inv-1', 1000, 'paid', { id: 'd-1', amount: 500, status: 'claimed' }), + makeRow('ibl-B', 200, 'inv-1', 1000, 'paid', { id: 'd-1', amount: 500, status: 'claimed' }), + ]; + const result = computeStatusContributionByInvoice(rows, new Set(['claimed'])); + // ibl-A: claimed deposit fraction 0.5 → 300*0.5=150; ibl-B: 200*0.5=100 → total 250 + expect(result.get('inv-1')).toBeCloseTo(250); + }); + + it('scenario 6b: two distinct invoices are tracked independently in the same map', () => { + const rows = [ + makeRow('ibl-1', 400, 'inv-1', 400, 'paid'), + makeRow('ibl-2', 600, 'inv-2', 600, 'pending'), + ]; + const result = computeStatusContributionByInvoice(rows, new Set(['paid'])); + expect(result.get('inv-1')).toBeCloseTo(400); + expect(result.get('inv-2')).toBe(0); + expect(result.size).toBe(2); + }); + + it('scenario 7: empty rows → empty map', () => { + const result = computeStatusContributionByInvoice([], new Set(['claimed'])); + expect(result.size).toBe(0); + }); +}); + // ─── aggregateInvoiceStatusBreakdown ────────────────────────────────────────── /** diff --git a/server/src/services/shared/depositAggregateUtils.ts b/server/src/services/shared/depositAggregateUtils.ts index 50e3a32fc..53b26441b 100644 --- a/server/src/services/shared/depositAggregateUtils.ts +++ b/server/src/services/shared/depositAggregateUtils.ts @@ -216,6 +216,61 @@ export function computeDepositAwareAggregates(rows: DepositAwareRow[]): { }; } +/** + * Per-invoice variant of computeStatusContribution: computes each invoice's net + * contribution across a SET of target statuses. Used by sourceReportService, where a + * report's status slice (e.g. claim = pending+paid) must accumulate BOTH the invoice's + * own residual status AND every deposit/refund status in the slice, into one net figure + * per invoice. + * + * Does NOT drop zero or negative results — callers decide rounding/dropping/refund-adjustment + * classification themselves (rounding happens after aggregation at 2dp, so "zero" must be + * evaluated post-rounding by the caller). + */ +export function computeStatusContributionByInvoice( + rows: Array<{ + ibl_id: string; + itemized_amount: number; + invoice_id: string; + invoice_amount: number; + invoice_status: string; + deposit_id: string | null; + deposit_amount: number | null; + deposit_status: string | null; + deposit_entry_type: string | null; + }>, + targetStatuses: Set, +): Map { + const result = new Map(); + if (rows.length === 0) return result; + + const iblMap = new Map(); + for (const row of rows) { + if (!iblMap.has(row.ibl_id)) { + iblMap.set(row.ibl_id, { itemizedAmount: row.itemized_amount, invoiceId: row.invoice_id }); + } + } + + const splitsByInvoiceId = splitByDeposits(rows); + + for (const [_iblId, ibl] of iblMap) { + const split = splitsByInvoiceId.get(ibl.invoiceId)!; + let contribution = result.get(ibl.invoiceId) ?? 0; + + if (targetStatuses.has(split.invoiceStatus)) { + contribution += ibl.itemizedAmount * split.residualFraction; + } + for (const df of split.depositFractions) { + if (targetStatuses.has(df.depositStatus)) { + contribution += ibl.itemizedAmount * df.fraction; + } + } + result.set(ibl.invoiceId, contribution); + } + + return result; +} + /** * Computes the sum of contributions that match a specific status (for budgetSourceService). * Used for claimed/unclaimed/paid amount calculations. diff --git a/server/src/services/sourceReportService.test.ts b/server/src/services/sourceReportService.test.ts new file mode 100644 index 000000000..8da311f63 --- /dev/null +++ b/server/src/services/sourceReportService.test.ts @@ -0,0 +1,793 @@ +/** + * Unit tests for sourceReportService.ts + * + * Story #1878 — Source report backend: report data, mark-claimed & Paperless upload proxy + * + * Covers: + * - getSourceReport: status-slice selection per report type, split-invoice detection, + * drop-on-zero / refund-adjustment classification, document stage tagging, Paperless + * ASN/title resolution (reachable / throws / unconfigured), unallocated invoices, + * totalAmount rounding. + * - markInvoicesClaimed: transactional batch claim of invoices + deposits, claimability + * rules, 409 rollback-on-any-offending, diary event side effects. + */ + +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { randomUUID } from 'node:crypto'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { runMigrations } from '../db/migrate.js'; +import * as schema from '../db/schema.js'; +import { getSourceReport, markInvoicesClaimed } from './sourceReportService.js'; +import { NotFoundError, ValidationError, InvoicesNotClaimableError } from '../errors/AppError.js'; + +// ─── Paperless mocking (mirrors documentLinkService.test.ts / paperlessService.test.ts) ── + +const mockFetch = jest.fn(); +let originalFetch: typeof fetch; + +function mockJsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: () => Promise.resolve(body), + headers: { get: (_key: string) => null }, + } as unknown as Response; +} + +const RAW_TAGS_RESPONSE = { count: 0, results: [] }; + +const PAPERLESS_DISABLED = { paperlessEnabled: false } as const; + +const PAPERLESS_CONFIG_ENABLED = { + paperlessEnabled: true, + paperlessUrl: 'http://paperless:8000', + paperlessApiToken: 'test-token', +} as const; + +describe('sourceReportService', () => { + let sqlite: Database.Database; + let db: BetterSQLite3Database; + let counter = 0; + + beforeEach(() => { + originalFetch = global.fetch; + global.fetch = mockFetch as unknown as typeof fetch; + mockFetch.mockReset(); + // Persistent fallback (not *Once*) so any test that doesn't care about the Paperless + // response shape still gets something well-formed back. + mockFetch.mockResolvedValue(mockJsonResponse(RAW_TAGS_RESPONSE)); + + sqlite = new Database(':memory:'); + sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('foreign_keys = ON'); + runMigrations(sqlite); + db = drizzle(sqlite, { schema }); + counter = 0; + }); + + afterEach(() => { + global.fetch = originalFetch; + sqlite.close(); + }); + + function ts(): string { + return new Date(Date.now() + counter++).toISOString(); + } + + // ─── Fixture helpers ──────────────────────────────────────────────────── + + function insertSource(overrides: Partial = {}): string { + const id = overrides.id ?? `src-${++counter}`; + const now = ts(); + db.insert(schema.budgetSources) + .values({ + name: 'Test Source', + sourceType: 'bank_loan', + totalAmount: 100000, + isDiscretionary: false, + status: 'active', + reference: null, + contactAddress: null, + createdAt: now, + updatedAt: now, + ...overrides, + id, + }) + .run(); + return id; + } + + function insertVendor(name = 'Test Vendor'): string { + const id = `vendor-${++counter}`; + const now = ts(); + db.insert(schema.vendors).values({ id, name, createdAt: now, updatedAt: now }).run(); + return id; + } + + function insertWorkItemBudget(sourceId: string | null): string { + const wiId = `wi-${++counter}`; + const budgetId = `wib-${counter}`; + const now = ts(); + db.insert(schema.workItems) + .values({ + id: wiId, + title: `WI ${counter}`, + status: 'not_started', + createdAt: now, + updatedAt: now, + }) + .run(); + db.insert(schema.workItemBudgets) + .values({ + id: budgetId, + workItemId: wiId, + budgetSourceId: sourceId, + plannedAmount: 0, + confidence: 'own_estimate', + createdAt: now, + updatedAt: now, + }) + .run(); + return budgetId; + } + + function insertInvoice( + vendorId: string, + overrides: Partial = {}, + ): string { + const id = overrides.id ?? `inv-${++counter}`; + const now = ts(); + db.insert(schema.invoices) + .values({ + vendorId, + amount: 1000, + date: '2026-01-15', + status: 'pending', + invoiceNumber: `INV-${counter}`, + createdAt: now, + updatedAt: now, + ...overrides, + id, + }) + .run(); + return id; + } + + function insertInvoiceBudgetLine( + invoiceId: string, + budgetId: string, + itemizedAmount: number, + ): string { + const id = randomUUID(); + const now = ts(); + db.insert(schema.invoiceBudgetLines) + .values({ + id, + invoiceId, + workItemBudgetId: budgetId, + itemizedAmount, + createdAt: now, + updatedAt: now, + }) + .run(); + return id; + } + + function insertDeposit( + invoiceId: string, + overrides: Partial = {}, + ): string { + const id = overrides.id ?? `dep-${++counter}`; + const now = ts(); + db.insert(schema.invoiceDeposits) + .values({ + invoiceId, + amount: 100, + dueDate: '2026-01-01', + status: 'pending', + entryType: 'deposit', + createdAt: now, + updatedAt: now, + ...overrides, + id, + }) + .run(); + return id; + } + + function insertDocumentLink( + entityId: string, + paperlessDocumentId: number, + attachmentType: 'quotation' | 'deposit' | 'invoice' | null = null, + ): void { + db.insert(schema.documentLinks) + .values({ + id: randomUUID(), + entityType: 'invoice', + entityId, + paperlessDocumentId, + attachmentType, + createdAt: ts(), + }) + .run(); + } + + function diaryEntryCount(): number { + return db.select().from(schema.diaryEntries).all().length; + } + + // ═══════════════════════════════════════════════════════════════════════ + // getSourceReport + // ═══════════════════════════════════════════════════════════════════════ + + describe('getSourceReport', () => { + it('scenario 20: unknown sourceId throws NotFoundError', async () => { + await expect( + getSourceReport(db, 'budget-overview', 'does-not-exist', PAPERLESS_DISABLED), + ).rejects.toThrow(NotFoundError); + }); + + it('scenario 8: budget-overview includes all 4 statuses incl. quotation', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + + for (const status of ['quotation', 'pending', 'paid', 'claimed'] as const) { + const invId = insertInvoice(vendorId, { status }); + // Each invoice needs its own budget line — invoice_budget_lines has a UNIQUE + // index on work_item_budget_id (one invoice per budgeted line item). + const budgetId = insertWorkItemBudget(sourceId); + insertInvoiceBudgetLine(invId, budgetId, 200); + } + + const result = await getSourceReport(db, 'budget-overview', sourceId, PAPERLESS_DISABLED); + expect(result.invoices).toHaveLength(4); + expect(result.invoices.map((i) => i.status).sort()).toEqual( + ['claimed', 'paid', 'pending', 'quotation'].sort(), + ); + }); + + it('scenario 9: claim report excludes quotation and claimed statuses', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + + for (const status of ['quotation', 'pending', 'paid', 'claimed'] as const) { + const invId = insertInvoice(vendorId, { status }); + // Each invoice needs its own budget line — invoice_budget_lines has a UNIQUE + // index on work_item_budget_id (one invoice per budgeted line item). + const budgetId = insertWorkItemBudget(sourceId); + insertInvoiceBudgetLine(invId, budgetId, 200); + } + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_DISABLED); + expect(result.invoices).toHaveLength(2); + expect(result.invoices.map((i) => i.status).sort()).toEqual(['paid', 'pending']); + }); + + it('scenario 10: proof-of-funds report includes only claimed status', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + + for (const status of ['quotation', 'pending', 'paid', 'claimed'] as const) { + const invId = insertInvoice(vendorId, { status }); + // Each invoice needs its own budget line — invoice_budget_lines has a UNIQUE + // index on work_item_budget_id (one invoice per budgeted line item). + const budgetId = insertWorkItemBudget(sourceId); + insertInvoiceBudgetLine(invId, budgetId, 200); + } + + const result = await getSourceReport(db, 'proof-of-funds', sourceId, PAPERLESS_DISABLED); + expect(result.invoices).toHaveLength(1); + expect(result.invoices[0]!.status).toBe('claimed'); + }); + + it('scenario 11: invoice split across two sources → isSplit true, partial allocatedAmount', async () => { + const sourceA = insertSource({ name: 'Source A' }); + const sourceB = insertSource({ name: 'Source B' }); + const vendorId = insertVendor(); + const budgetA = insertWorkItemBudget(sourceA); + const budgetB = insertWorkItemBudget(sourceB); + const invId = insertInvoice(vendorId, { status: 'paid', amount: 1000 }); + insertInvoiceBudgetLine(invId, budgetA, 600); + insertInvoiceBudgetLine(invId, budgetB, 400); + + const result = await getSourceReport(db, 'claim', sourceA, PAPERLESS_DISABLED); + expect(result.invoices).toHaveLength(1); + expect(result.invoices[0]!.isSplit).toBe(true); + expect(result.invoices[0]!.allocatedAmount).toBeCloseTo(600); + }); + + it('scenario 12a: single-source invoice → isSplit false', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetId = insertWorkItemBudget(sourceId); + const invId = insertInvoice(vendorId, { status: 'paid', amount: 500 }); + insertInvoiceBudgetLine(invId, budgetId, 500); + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_DISABLED); + expect(result.invoices[0]!.isSplit).toBe(false); + }); + + it('scenario 12b: real source + null-source line (COUNT DISTINCT NULL exclusion) → isSplit false', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetReal = insertWorkItemBudget(sourceId); + const budgetNull = insertWorkItemBudget(null); // unassigned budget line + const invId = insertInvoice(vendorId, { status: 'paid', amount: 700 }); + insertInvoiceBudgetLine(invId, budgetReal, 500); + insertInvoiceBudgetLine(invId, budgetNull, 200); + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_DISABLED); + expect(result.invoices).toHaveLength(1); + expect(result.invoices[0]!.isSplit).toBe(false); + expect(result.invoices[0]!.allocatedAmount).toBeCloseTo(500); + }); + + it('scenario 13: exactly-zero net contribution is dropped entirely (not in invoices, not in unallocated)', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetId = insertWorkItemBudget(sourceId); + // invoice residual status pending (not in {claimed} target); deposit 400 claimed + // offset by refund 400 claimed → net 0 in the claimed slice. + const invId = insertInvoice(vendorId, { status: 'pending', amount: 1000 }); + insertInvoiceBudgetLine(invId, budgetId, 1000); + insertDeposit(invId, { amount: 400, status: 'claimed', entryType: 'deposit' }); + insertDeposit(invId, { amount: 400, status: 'claimed', entryType: 'refund' }); + + const result = await getSourceReport(db, 'proof-of-funds', sourceId, PAPERLESS_DISABLED); + expect(result.invoices).toHaveLength(0); + expect(result.unallocatedInvoices).toHaveLength(0); + expect(result.totalAmount).toBe(0); + }); + + it('scenario 14: negative net contribution → refund-adjustment line with negative allocatedAmount', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetId = insertWorkItemBudget(sourceId); + // invoice residual status pending (not in {claimed} target); refund 300 claimed only. + const invId = insertInvoice(vendorId, { status: 'pending', amount: 1000 }); + insertInvoiceBudgetLine(invId, budgetId, 1000); + insertDeposit(invId, { amount: 300, status: 'claimed', entryType: 'refund' }); + + const result = await getSourceReport(db, 'proof-of-funds', sourceId, PAPERLESS_DISABLED); + expect(result.invoices).toHaveLength(1); + expect(result.invoices[0]!.lineKind).toBe('refund-adjustment'); + expect(result.invoices[0]!.allocatedAmount).toBeCloseTo(-300); + }); + + it('scenario 15: matching-status invoice with zero budget lines → unallocatedInvoices only', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + insertInvoice(vendorId, { status: 'pending' }); // no invoice_budget_lines at all + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_DISABLED); + expect(result.invoices).toHaveLength(0); + expect(result.unallocatedInvoices).toHaveLength(1); + expect(result.unallocatedInvoices[0]!.status).toBe('pending'); + }); + + it('scenario 15b: unallocated invoice with a status outside the target slice is excluded entirely', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + insertInvoice(vendorId, { status: 'claimed' }); // no budget lines + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_DISABLED); // claim excludes claimed + expect(result.unallocatedInvoices).toHaveLength(0); + }); + + it('scenario 16a: quotation-status invoice tags only the quotation document stage', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetId = insertWorkItemBudget(sourceId); + const invId = insertInvoice(vendorId, { status: 'quotation', amount: 500 }); + insertInvoiceBudgetLine(invId, budgetId, 500); + insertDocumentLink(invId, 1, 'quotation'); + insertDocumentLink(invId, 2, 'invoice'); + insertDocumentLink(invId, 3, 'deposit'); + insertDocumentLink(invId, 4, null); // untagged always kept + + const result = await getSourceReport(db, 'budget-overview', sourceId, PAPERLESS_DISABLED); + expect(result.invoices).toHaveLength(1); + const ids = result.invoices[0]!.documents.map((d) => d.attachmentType); + expect(ids.sort()).toEqual([null, 'quotation'].sort()); + }); + + it('scenario 16b: paid-status invoice (no deposits) tags the invoice document stage', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetId = insertWorkItemBudget(sourceId); + const invId = insertInvoice(vendorId, { status: 'paid', amount: 500 }); + insertInvoiceBudgetLine(invId, budgetId, 500); + insertDocumentLink(invId, 1, 'quotation'); + insertDocumentLink(invId, 2, 'invoice'); + insertDocumentLink(invId, 3, 'deposit'); + insertDocumentLink(invId, 4, null); + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_DISABLED); + const tags = result.invoices[0]!.documents.map((d) => d.attachmentType); + expect(tags.sort()).toEqual([null, 'invoice'].sort()); + }); + + it('scenario 16c: deposit whose status is in the slice tags the deposit document stage', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetId = insertWorkItemBudget(sourceId); + const invId = insertInvoice(vendorId, { status: 'pending', amount: 1000 }); + insertInvoiceBudgetLine(invId, budgetId, 1000); + insertDeposit(invId, { amount: 300, status: 'paid', entryType: 'deposit' }); + insertDocumentLink(invId, 1, 'quotation'); + insertDocumentLink(invId, 2, 'invoice'); + insertDocumentLink(invId, 3, 'deposit'); + insertDocumentLink(invId, 4, null); + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_DISABLED); // claim = {pending,paid} + const tags = result.invoices[0]!.documents.map((d) => d.attachmentType); + // residual (pending, 700/1000) tags 'invoice'; deposit (paid, 300/1000) tags 'deposit'; untagged always kept + expect(tags.sort()).toEqual([null, 'deposit', 'invoice'].sort()); + }); + + it('scenario 16e: deposit whose status is outside the slice does not tag the deposit stage', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetId = insertWorkItemBudget(sourceId); + const invId = insertInvoice(vendorId, { status: 'pending', amount: 1000 }); + insertInvoiceBudgetLine(invId, budgetId, 1000); + // 'claimed' is not part of the 'claim' report's target statuses ({pending, paid}). + insertDeposit(invId, { amount: 100, status: 'claimed', entryType: 'deposit' }); + insertDocumentLink(invId, 1, 'deposit'); + insertDocumentLink(invId, 2, null); + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_DISABLED); + const tags = result.invoices[0]!.documents.map((d) => d.attachmentType); + // Deposit stage is never activated (its only status is out-of-slice), so the + // 'deposit'-tagged link is filtered out — only the untagged link survives. + expect(tags).toEqual([null]); + }); + + it('scenario 16d: surviving document objects carry the real Paperless document id', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetId = insertWorkItemBudget(sourceId); + const invId = insertInvoice(vendorId, { status: 'paid', amount: 500 }); + insertInvoiceBudgetLine(invId, budgetId, 500); + insertDocumentLink(invId, 4242, null); + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_DISABLED); + expect(result.invoices[0]!.documents).toHaveLength(1); + expect(result.invoices[0]!.documents[0]!.documentId).toBe(4242); + }); + + // Scenarios 17 ("Paperless reachable → ASN/title populated") and 18 ("getDocuments + // throws → degrade to null") were previously omitted because two production bugs + // (missing `await` on paperlessService.getDocuments, and a `sql.join(...)` crash) made + // this code path unreachable. Both are fixed, and scenario 18 is restored below and + // passes. Restoring scenario 17 uncovered a THIRD, previously-masked production bug — + // see GitHub issue #1884: getSourceReport does `for (const doc of docs)` over the + // `Map` returned by paperlessService.getDocuments(), which + // iterates [key, value] tuples, not the document objects — so `doc.documentId` / + // `doc.archiveSerialNumber` / `doc.title` are all undefined and the ASN/title + // enrichment silently never populates, even when Paperless is reachable and returns + // valid data. Per the test-failure protocol, this correct test must not be weakened to + // match the buggy output — it is skipped (not deleted, not inverted) until #1884 is + // fixed, at which point it should pass as-is with no changes. + it('scenario 17: Paperless reachable → ASN/title populated on the matching document', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetId = insertWorkItemBudget(sourceId); + const invId = insertInvoice(vendorId, { status: 'paid', amount: 500 }); + insertInvoiceBudgetLine(invId, budgetId, 500); + insertDocumentLink(invId, 42, null); + + // Mock: tags first, then the bulk documents list (getDocuments' Promise.all order). + mockFetch.mockResolvedValueOnce(mockJsonResponse(RAW_TAGS_RESPONSE)).mockResolvedValueOnce( + mockJsonResponse({ + count: 1, + results: [ + { + id: 42, + title: 'Invoice from Builder Co', + content: 'Full text content here.', + tags: [], + created: '2026-01-15T00:00:00Z', + added: '2026-01-16T08:30:00Z', + modified: '2026-01-16T08:30:00Z', + correspondent: null, + document_type: null, + archive_serial_number: 1042, + original_file_name: 'invoice-2026-001.pdf', + page_count: 2, + }, + ], + }), + ); + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_CONFIG_ENABLED); + + const doc = result.invoices[0]!.documents[0]!; + expect(doc.documentId).toBe(42); + expect(doc.archiveSerialNumber).toBe(1042); + expect(doc.title).toBe('Invoice from Builder Co'); + }); + + it('scenario 18: getDocuments throws → degrades to null ASN/title without failing the report', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetId = insertWorkItemBudget(sourceId); + const invId = insertInvoice(vendorId, { status: 'paid', amount: 500 }); + insertInvoiceBudgetLine(invId, budgetId, 500); + insertDocumentLink(invId, 42, null); + + mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_CONFIG_ENABLED); + + expect(result.invoices).toHaveLength(1); + const doc = result.invoices[0]!.documents[0]!; + expect(doc.documentId).toBe(42); + expect(doc.archiveSerialNumber).toBeNull(); + expect(doc.title).toBeNull(); + }); + + it('scenario 19: Paperless unconfigured → degrades without calling fetch at all', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + const budgetId = insertWorkItemBudget(sourceId); + const invId = insertInvoice(vendorId, { status: 'paid', amount: 500 }); + insertInvoiceBudgetLine(invId, budgetId, 500); + insertDocumentLink(invId, 42, null); + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_DISABLED); + + expect(mockFetch).not.toHaveBeenCalled(); + const doc = result.invoices[0]!.documents[0]!; + expect(doc.archiveSerialNumber).toBeNull(); + expect(doc.title).toBeNull(); + }); + + it('scenario 21: totalAmount is the exact sum of decimal-noisy rounded lines', async () => { + const sourceId = insertSource(); + const vendorId = insertVendor(); + + const inv1 = insertInvoice(vendorId, { status: 'paid', amount: 332.85 }); + insertInvoiceBudgetLine(inv1, insertWorkItemBudget(sourceId), 332.85); + const inv2 = insertInvoice(vendorId, { status: 'paid', amount: 333.04 }); + insertInvoiceBudgetLine(inv2, insertWorkItemBudget(sourceId), 333.04); + const inv3 = insertInvoice(vendorId, { status: 'paid', amount: 334.11 }); + insertInvoiceBudgetLine(inv3, insertWorkItemBudget(sourceId), 334.11); + + const result = await getSourceReport(db, 'claim', sourceId, PAPERLESS_DISABLED); + expect(result.invoices).toHaveLength(3); + const sumOfLines = result.invoices.reduce((s, i) => s + i.allocatedAmount, 0); + expect(result.totalAmount).toBeCloseTo(sumOfLines, 10); + expect(result.totalAmount).toBeCloseTo(1000.0, 2); + }); + + it('source summary maps reference/contactAddress/sourceType from the budget source row', async () => { + const sourceId = insertSource({ + name: 'Bauspar Bank', + sourceType: 'credit_line', + reference: 'REF-123', + contactAddress: '123 Bank St', + }); + + const result = await getSourceReport(db, 'budget-overview', sourceId, PAPERLESS_DISABLED); + expect(result.source).toEqual({ + id: sourceId, + name: 'Bauspar Bank', + sourceType: 'credit_line', + reference: 'REF-123', + contactAddress: '123 Bank St', + }); + expect(result.type).toBe('budget-overview'); + expect(typeof result.generatedAt).toBe('string'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // markInvoicesClaimed + // ═══════════════════════════════════════════════════════════════════════ + + describe('markInvoicesClaimed', () => { + it('scenario 22: empty invoiceIds throws ValidationError', () => { + expect(() => markInvoicesClaimed(db, [], true)).toThrow(ValidationError); + }); + + it('scenario 23: all-pending invoices with no deposits flip to claimed and fire diary events', () => { + const vendorId = insertVendor(); + const inv1 = insertInvoice(vendorId, { status: 'pending', invoiceNumber: 'INV-1' }); + const inv2 = insertInvoice(vendorId, { status: 'pending', invoiceNumber: 'INV-2' }); + const before = diaryEntryCount(); + + const result = markInvoicesClaimed(db, [inv1, inv2], true); + + expect(result.claimedInvoiceIds.sort()).toEqual([inv1, inv2].sort()); + expect(result.claimedDepositIds).toEqual([]); + const claimedRows = db + .select() + .from(schema.invoices) + .all() + .filter((i) => i.id === inv1 || i.id === inv2); + expect(claimedRows.every((i) => i.status === 'claimed')).toBe(true); + expect(diaryEntryCount()).toBe(before + 2); + }); + + it('scenario 24: pending invoice + 2 pending deposits → all flip; claimedDate=today, paidDate stays null', () => { + const vendorId = insertVendor(); + const invId = insertInvoice(vendorId, { status: 'pending' }); + const dep1 = insertDeposit(invId, { status: 'pending', amount: 100 }); + const dep2 = insertDeposit(invId, { status: 'pending', amount: 200 }); + const today = new Date().toLocaleDateString('en-CA'); + + const result = markInvoicesClaimed(db, [invId], true); + + expect(result.claimedInvoiceIds).toEqual([invId]); + expect(result.claimedDepositIds.sort()).toEqual([dep1, dep2].sort()); + + const updatedDep1 = db + .select() + .from(schema.invoiceDeposits) + .all() + .find((d) => d.id === dep1)!; + expect(updatedDep1.status).toBe('claimed'); + expect(updatedDep1.claimedDate).toBe(today); + expect(updatedDep1.paidDate).toBeNull(); + }); + + it('scenario 25: paid invoice + paid deposit → both flip; paidDate preserved', () => { + const vendorId = insertVendor(); + const invId = insertInvoice(vendorId, { status: 'paid' }); + const depId = insertDeposit(invId, { status: 'paid', amount: 100, paidDate: '2026-01-10' }); + + const result = markInvoicesClaimed(db, [invId], true); + + expect(result.claimedInvoiceIds).toEqual([invId]); + expect(result.claimedDepositIds).toEqual([depId]); + + const updatedInv = db + .select() + .from(schema.invoices) + .all() + .find((i) => i.id === invId)!; + expect(updatedInv.status).toBe('claimed'); + + const updatedDep = db + .select() + .from(schema.invoiceDeposits) + .all() + .find((d) => d.id === depId)!; + expect(updatedDep.status).toBe('claimed'); + expect(updatedDep.paidDate).toBe('2026-01-10'); + }); + + it('scenario 26: already-claimed invoice with a pending refund → invoice untouched (no event, updatedAt unchanged), refund flips', () => { + const vendorId = insertVendor(); + const invId = insertInvoice(vendorId, { + status: 'claimed', + updatedAt: '2026-01-01T00:00:00.000Z', + }); + const refundId = insertDeposit(invId, { status: 'pending', entryType: 'refund', amount: 50 }); + const before = diaryEntryCount(); + + const result = markInvoicesClaimed(db, [invId], true); + + expect(result.claimedInvoiceIds).toEqual([]); // invoice already claimed — not re-flipped + expect(result.claimedDepositIds).toEqual([refundId]); + + const updatedInv = db + .select() + .from(schema.invoices) + .all() + .find((i) => i.id === invId)!; + expect(updatedInv.updatedAt).toBe('2026-01-01T00:00:00.000Z'); + // No diary event for the invoice itself, but the refund's own transition still fires one. + expect(diaryEntryCount()).toBe(before + 1); + }); + + it('scenario 27: batch with one quotation-status invoice among valid ones → 409, ZERO rows changed (rollback verified by re-query)', () => { + const vendorId = insertVendor(); + const validInv = insertInvoice(vendorId, { status: 'pending' }); + const badInv = insertInvoice(vendorId, { status: 'quotation' }); + + expect(() => markInvoicesClaimed(db, [validInv, badInv], true)).toThrow( + InvoicesNotClaimableError, + ); + + try { + markInvoicesClaimed(db, [validInv, badInv], true); + } catch (e) { + const err = e as InvoicesNotClaimableError; + expect(err.details).toMatchObject({ invoiceIds: [badInv] }); + } + + const reQueriedValid = db + .select() + .from(schema.invoices) + .all() + .find((i) => i.id === validInv)!; + expect(reQueriedValid.status).toBe('pending'); // untouched — rollback verified + }); + + it('scenario 28: batch with a non-existent invoice id → same 409 rollback behavior', () => { + const vendorId = insertVendor(); + const validInv = insertInvoice(vendorId, { status: 'pending' }); + + expect(() => markInvoicesClaimed(db, [validInv, 'does-not-exist'], true)).toThrow( + InvoicesNotClaimableError, + ); + + const reQueriedValid = db + .select() + .from(schema.invoices) + .all() + .find((i) => i.id === validInv)!; + expect(reQueriedValid.status).toBe('pending'); + }); + + it('scenario 29: already-claimed invoice with nothing sweepable → 409 offending', () => { + const vendorId = insertVendor(); + const invId = insertInvoice(vendorId, { status: 'claimed' }); // no deposits at all + + expect(() => markInvoicesClaimed(db, [invId], true)).toThrow(InvoicesNotClaimableError); + }); + + it('scenario 29b: already-claimed invoice with an already-claimed deposit (nothing sweepable) → 409', () => { + const vendorId = insertVendor(); + const invId = insertInvoice(vendorId, { status: 'claimed' }); + insertDeposit(invId, { status: 'claimed', amount: 100 }); // ALLOWED_TRANSITIONS.claimed = ['paid'], no 'claimed' target + + expect(() => markInvoicesClaimed(db, [invId], true)).toThrow(InvoicesNotClaimableError); + }); + + it('scenario 30: diaryAutoEvents=false suppresses diary entries but writes still happen', () => { + const vendorId = insertVendor(); + const invId = insertInvoice(vendorId, { status: 'pending' }); + const before = diaryEntryCount(); + + const result = markInvoicesClaimed(db, [invId], false); + + expect(result.claimedInvoiceIds).toEqual([invId]); + expect(diaryEntryCount()).toBe(before); + + const updatedInv = db + .select() + .from(schema.invoices) + .all() + .find((i) => i.id === invId)!; + expect(updatedInv.status).toBe('claimed'); + }); + + it('scenario 31: invoice with a null invoiceNumber falls back to N/A in diary events', () => { + const vendorId = insertVendor(); + const invId = insertInvoice(vendorId, { status: 'pending', invoiceNumber: null }); + const depId = insertDeposit(invId, { status: 'pending', amount: 100 }); + + const result = markInvoicesClaimed(db, [invId], true); + + expect(result.claimedInvoiceIds).toEqual([invId]); + expect(result.claimedDepositIds).toEqual([depId]); + // No assertion on diary event content here — onInvoiceStatusChanged/onDepositStatusChanged + // are exercised elsewhere; this scenario's purpose is exercising the `|| 'N/A'` fallback + // for a null invoiceNumber without throwing. + }); + + it('scenario 32: directly-claimable invoice with an already-claimed (non-transitionable) deposit → deposit left untouched', () => { + const vendorId = insertVendor(); + const invId = insertInvoice(vendorId, { status: 'pending' }); + const depId = insertDeposit(invId, { status: 'claimed', amount: 100 }); // ALLOWED_TRANSITIONS.claimed = ['paid'] only + + const result = markInvoicesClaimed(db, [invId], true); + + expect(result.claimedInvoiceIds).toEqual([invId]); + expect(result.claimedDepositIds).toEqual([]); // already-claimed deposit is not re-claimed + + const updatedDep = db + .select() + .from(schema.invoiceDeposits) + .all() + .find((d) => d.id === depId)!; + expect(updatedDep.status).toBe('claimed'); + }); + }); +}); diff --git a/server/src/services/sourceReportService.ts b/server/src/services/sourceReportService.ts new file mode 100644 index 000000000..46dcd907f --- /dev/null +++ b/server/src/services/sourceReportService.ts @@ -0,0 +1,493 @@ +import { eq, inArray, and, sql } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import type * as schemaTypes from '../db/schema.js'; +import { budgetSources, invoices, invoiceDeposits, documentLinks } from '../db/schema.js'; +import type { + SourceReportType, + SourceReportResponse, + SourceReportInvoice, + SourceReportUnallocatedInvoice, + SourceReportSourceSummary, + MarkClaimedResponse, + AttachmentType, + SourceReportDocument, +} from '@cornerstone/shared'; +import { NotFoundError, ValidationError, InvoicesNotClaimableError } from '../errors/AppError.js'; +import { toCents } from './shared/money.js'; +import { + computeStatusContributionByInvoice, + splitByDeposits, + type DepositAwareRow, +} from './shared/depositAggregateUtils.js'; +import { onInvoiceStatusChanged, onDepositStatusChanged } from './diaryAutoEventService.js'; +import * as paperlessService from './paperlessService.js'; +import { ALLOWED_TRANSITIONS } from './invoiceDepositService.js'; + +type DbType = BetterSQLite3Database; + +/** + * Get today's date in ISO YYYY-MM-DD format (server's local timezone). + */ +function today(): string { + return new Date().toLocaleDateString('en-CA'); +} + +/** + * Get a source report for a given budget source and report type. + * The report includes all invoices whose budget lines reference this source, + * filtered by the statuses specified for the report type. + */ +export async function getSourceReport( + db: DbType, + type: SourceReportType, + sourceId: string, + paperlessConfig: { + paperlessEnabled: boolean; + paperlessUrl?: string; + paperlessApiToken?: string; + }, +): Promise { + // 1. Fetch budget source + const source = db.select().from(budgetSources).where(eq(budgetSources.id, sourceId)).get(); + if (!source) { + throw new NotFoundError('Budget source not found'); + } + + // 2. Determine target statuses based on report type + const targetStatuses = new Set(); + switch (type) { + case 'budget-overview': + // All 4 statuses including quotation + targetStatuses.add('quotation'); + targetStatuses.add('pending'); + targetStatuses.add('paid'); + targetStatuses.add('claimed'); + break; + case 'claim': + // Only pending and paid + targetStatuses.add('pending'); + targetStatuses.add('paid'); + break; + case 'proof-of-funds': + // Only claimed + targetStatuses.add('claimed'); + break; + } + + // 3. Query rows (invoice + deposits) for this source + type SourceReportRow = DepositAwareRow & { + invoice_number: string | null; + invoice_date: string; + vendor_id: string; + vendor_name: string; + }; + + const rows = db.all( + sql`SELECT + ibl.id AS ibl_id, + ibl.itemized_amount AS itemized_amount, + i.id AS invoice_id, + i.amount AS invoice_amount, + i.status AS invoice_status, + i.invoice_number AS invoice_number, + i.date AS invoice_date, + i.vendor_id AS vendor_id, + v.name AS vendor_name, + d.id AS deposit_id, + d.amount AS deposit_amount, + d.status AS deposit_status, + d.entry_type AS deposit_entry_type + FROM invoice_budget_lines ibl + INNER JOIN invoices i ON i.id = ibl.invoice_id + INNER JOIN vendors v ON v.id = i.vendor_id + LEFT JOIN invoice_deposits d ON d.invoice_id = i.id + WHERE ( + (ibl.work_item_budget_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM work_item_budgets wib + WHERE wib.id = ibl.work_item_budget_id AND wib.budget_source_id = ${sourceId} + )) + OR + (ibl.household_item_budget_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM household_item_budgets hib + WHERE hib.id = ibl.household_item_budget_id AND hib.budget_source_id = ${sourceId} + )) + )`, + ); + + // 4. Compute contributions per invoice, then round to 2dp + const rawContributions = computeStatusContributionByInvoice(rows, targetStatuses); + + // Build a map of invoice metadata for quick lookup + const invoiceMetadata = new Map< + string, + { + vendorId: string; + vendorName: string; + invoiceNumber: string | null; + date: string; + status: string; + invoiceAmount: number; + } + >(); + for (const row of rows) { + if (!invoiceMetadata.has(row.invoice_id)) { + invoiceMetadata.set(row.invoice_id, { + vendorId: row.vendor_id, + vendorName: row.vendor_name, + invoiceNumber: row.invoice_number, + date: row.invoice_date, + status: row.invoice_status, + invoiceAmount: row.invoice_amount, + }); + } + } + + // 5. Compute isSplit for all report invoices in one query + const reportInvoiceIds = Array.from(invoiceMetadata.keys()); + const splitRows = db.all<{ invoice_id: string; source_count: number }>( + sql`SELECT ibl.invoice_id AS invoice_id, + COUNT(DISTINCT COALESCE(wib.budget_source_id, hib.budget_source_id)) AS source_count + FROM invoice_budget_lines ibl + LEFT JOIN work_item_budgets wib ON wib.id = ibl.work_item_budget_id + LEFT JOIN household_item_budgets hib ON hib.id = ibl.household_item_budget_id + WHERE ibl.invoice_id IN (${sql.join( + reportInvoiceIds.map((id) => sql`${id}`), + sql`, `, + )}) + GROUP BY ibl.invoice_id`, + ); + + const isSplitMap = new Map(); + for (const row of splitRows) { + isSplitMap.set(row.invoice_id, row.source_count > 1); + } + + // 6. Precompute deposit splits for all invoices + const splitsByInvoiceId = splitByDeposits(rows); + + // 7. Batch fetch all document links for all report invoices (once, not per-invoice) + const allDocumentLinks = db + .select() + .from(documentLinks) + .where( + and( + eq(documentLinks.entityType, 'invoice'), + inArray(documentLinks.entityId, reportInvoiceIds), + ), + ) + .all(); + + // Build a map: invoiceId → filtered document links (by stage rules) + const linksByInvoiceId = new Map(); + // Also collect all paperlessDocumentIds we'll need to fetch metadata for + const allPaperlessDocIds = new Set(); + + for (const invoiceId of reportInvoiceIds) { + const split = splitsByInvoiceId.get(invoiceId)!; + + // Determine which stages to include for this invoice + const stages = new Set(); + if (split.invoiceStatus === 'quotation' && targetStatuses.has('quotation')) { + stages.add('quotation'); + } + if ( + split.residualFraction > 0 && + split.invoiceStatus !== 'quotation' && + targetStatuses.has(split.invoiceStatus) + ) { + stages.add('invoice'); + } + for (const df of split.depositFractions) { + if (targetStatuses.has(df.depositStatus)) { + stages.add('deposit'); + } + } + + // Filter links for this invoice by stage, include untagged always + const invoiceLinks = allDocumentLinks + .filter( + (link) => + link.entityId === invoiceId && + (link.attachmentType === null || stages.has(link.attachmentType as AttachmentType)), + ) + .map((link) => { + allPaperlessDocIds.add(link.paperlessDocumentId); + return link; + }); + + linksByInvoiceId.set(invoiceId, invoiceLinks); + } + + // 8. Fetch metadata for ALL documents in one Paperless call (not per-invoice) + const paperlessDocMap = new Map< + number, + { archiveSerialNumber: number | null; title: string | null } + >(); + if ( + paperlessConfig.paperlessEnabled && + paperlessConfig.paperlessUrl && + paperlessConfig.paperlessApiToken && + allPaperlessDocIds.size > 0 + ) { + try { + const docs = await paperlessService.getDocuments( + paperlessConfig.paperlessUrl, + paperlessConfig.paperlessApiToken, + Array.from(allPaperlessDocIds), + ); + for (const doc of docs.values()) { + paperlessDocMap.set(doc.id, { + archiveSerialNumber: doc.archiveSerialNumber, + title: doc.title, + }); + } + } catch (err) { + // Degrade gracefully — getDocuments throws, we just use nulls + console.warn('[sourceReport] Paperless.getDocuments failed, degrading to null ASN/title', { + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // 9. Build report invoices + const reportInvoices: SourceReportInvoice[] = []; + for (const [invoiceId, rawAmount] of rawContributions) { + const metadata = invoiceMetadata.get(invoiceId)!; + const roundedAmount = toCents(rawAmount) / 100; + + // Drop exactly 0, otherwise include + if (roundedAmount === 0) continue; + + const lineKind = roundedAmount > 0 ? 'invoice' : 'refund-adjustment'; + + // Map filtered document links to response documents + const links = linksByInvoiceId.get(invoiceId) ?? []; + const documents: SourceReportDocument[] = links.map((link) => { + const paperlessEntry = paperlessDocMap.get(link.paperlessDocumentId); + return { + documentId: link.paperlessDocumentId, + archiveSerialNumber: paperlessEntry?.archiveSerialNumber ?? null, + title: paperlessEntry?.title ?? null, + attachmentType: link.attachmentType as AttachmentType | null, + }; + }); + + reportInvoices.push({ + invoiceId, + vendorId: metadata.vendorId, + vendorName: metadata.vendorName, + invoiceNumber: metadata.invoiceNumber, + date: metadata.date, + status: metadata.status as SourceReportInvoice['status'], + invoiceAmount: metadata.invoiceAmount, + allocatedAmount: roundedAmount, + lineKind, + isSplit: isSplitMap.get(invoiceId) ?? false, + documents, + }); + } + + // 10. Query unallocated invoices (no budget lines, but matching status) + const unallocatedInvoices: SourceReportUnallocatedInvoice[] = []; + const unallocRows = db.all<{ + invoice_id: string; + vendor_id: string; + vendor_name: string; + invoice_number: string | null; + invoice_date: string; + invoice_status: string; + invoice_amount: number; + }>( + sql`SELECT + i.id AS invoice_id, + i.vendor_id AS vendor_id, + v.name AS vendor_name, + i.invoice_number AS invoice_number, + i.date AS invoice_date, + i.status AS invoice_status, + i.amount AS invoice_amount + FROM invoices i + INNER JOIN vendors v ON v.id = i.vendor_id + WHERE i.status IN (${sql.join( + Array.from(targetStatuses).map((status) => sql`${status}`), + sql`, `, + )}) + AND NOT EXISTS (SELECT 1 FROM invoice_budget_lines ibl WHERE ibl.invoice_id = i.id)`, + ); + + for (const row of unallocRows) { + unallocatedInvoices.push({ + invoiceId: row.invoice_id, + vendorId: row.vendor_id, + vendorName: row.vendor_name, + invoiceNumber: row.invoice_number, + date: row.invoice_date, + status: row.invoice_status as SourceReportUnallocatedInvoice['status'], + invoiceAmount: row.invoice_amount, + }); + } + + // 11. Compute total amount from rounded lines + const totalAmount = + toCents(reportInvoices.reduce((sum, inv) => sum + inv.allocatedAmount, 0)) / 100; + + return { + type, + source: { + id: source.id, + name: source.name, + sourceType: source.sourceType as SourceReportSourceSummary['sourceType'], + reference: source.reference, + contactAddress: source.contactAddress, + }, + invoices: reportInvoices, + totalAmount, + unallocatedInvoices, + generatedAt: new Date().toISOString(), + }; +} + +/** + * Mark a batch of invoices as claimed, updating both invoices and their deposits. + * All work happens in a single transaction — any error rolls everything back. + * Returns the IDs of invoices and deposits that were actually modified. + * + * @throws ValidationError if invoiceIds is empty + * @throws InvoicesNotClaimableError if any invoice is not claimable (409 with offending IDs) + */ +export function markInvoicesClaimed( + db: DbType, + invoiceIds: string[], + diaryAutoEvents: boolean, +): MarkClaimedResponse { + // 1. Validate input + if (invoiceIds.length === 0) { + throw new ValidationError('At least one invoice ID must be provided'); + } + + const claimedInvoiceIds: string[] = []; + const claimedDepositIds: string[] = []; + + // 2. Transaction: validate, then write + db.transaction((tx: typeof db) => { + // Fetch all requested invoices + const invoiceRows = tx.select().from(invoices).where(inArray(invoices.id, invoiceIds)).all(); + + // Fetch all deposits for those invoices + const depositRows = tx + .select() + .from(invoiceDeposits) + .where(inArray(invoiceDeposits.invoiceId, invoiceIds)) + .all(); + + const invoiceById = new Map(invoiceRows.map((inv) => [inv.id, inv])); + const depositsByInvoiceId = new Map(); + for (const dep of depositRows) { + const deps = depositsByInvoiceId.get(dep.invoiceId) ?? []; + deps.push(dep); + depositsByInvoiceId.set(dep.invoiceId, deps); + } + + // 3. Check claimability + const offendingIds: string[] = []; + + for (const invoiceId of invoiceIds) { + const invoice = invoiceById.get(invoiceId); + if (!invoice) { + offendingIds.push(invoiceId); + continue; + } + + const invoiceStatus = invoice.status as string; + const invoiceDeps = depositsByInvoiceId.get(invoiceId) ?? []; + + // Claimable: status ∈ {pending, paid} OR (status === 'claimed' && has ≥1 deposit whose ALLOWED_TRANSITIONS[status].includes('claimed')) + const claimableDirectly = invoiceStatus === 'pending' || invoiceStatus === 'paid'; + const hasClaimableDeposits = + invoiceDeps.length > 0 && + invoiceDeps.some((dep) => { + const depStatus = dep.status as string; + return ( + ALLOWED_TRANSITIONS[depStatus as keyof typeof ALLOWED_TRANSITIONS]?.includes( + 'claimed', + ) ?? false + ); + }); + + const isClaimable = + claimableDirectly || (invoiceStatus === 'claimed' && hasClaimableDeposits); + if (!isClaimable) { + offendingIds.push(invoiceId); + } + } + + if (offendingIds.length > 0) { + throw new InvoicesNotClaimableError(undefined, { invoiceIds: offendingIds }); + } + + // 4. Write invoices + for (const invoiceId of invoiceIds) { + const invoice = invoiceById.get(invoiceId)!; + const invoiceStatus = invoice.status as string; + + if (invoiceStatus !== 'claimed') { + // Flip to claimed + tx.update(invoices) + .set({ + status: 'claimed', + updatedAt: new Date().toISOString(), + }) + .where(eq(invoices.id, invoiceId)) + .run(); + + claimedInvoiceIds.push(invoiceId); + + // Fire diary event + onInvoiceStatusChanged( + tx, + diaryAutoEvents, + invoiceId, + invoice.invoiceNumber || 'N/A', + invoiceStatus, + 'claimed', + ); + } + } + + // 5. Write deposits + const allDeposits = depositRows; + for (const deposit of allDeposits) { + const depStatus = deposit.status as string; + const allowedTransitions = ALLOWED_TRANSITIONS[depStatus as keyof typeof ALLOWED_TRANSITIONS]; + + if (allowedTransitions?.includes('claimed')) { + tx.update(invoiceDeposits) + .set({ + status: 'claimed', + claimedDate: today(), + updatedAt: new Date().toISOString(), + }) + .where(eq(invoiceDeposits.id, deposit.id)) + .run(); + + claimedDepositIds.push(deposit.id); + + // Fire diary event + const invoice = invoiceById.get(deposit.invoiceId)!; + onDepositStatusChanged( + tx, + diaryAutoEvents, + deposit.id, + invoice.invoiceNumber || 'N/A', + depStatus, + 'claimed', + ); + } + } + }); + + return { + claimedInvoiceIds, + claimedDepositIds, + }; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 724afee19..663a1a633 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -290,6 +290,7 @@ export type { export type { PaperlessCorrespondent, PaperlessCorrespondentListResponse, + PaperlessUploadResponse, } from './types/paperless.js'; // Documents (Paperless-ngx Integration) @@ -418,3 +419,15 @@ export type { UpdateHouseholdSettingsRequest, HouseholdSettingsResponse, } from './types/settings.js'; + +// Source Reports +export type { + SourceReportType, + SourceReportDocument, + SourceReportInvoice, + SourceReportUnallocatedInvoice, + SourceReportSourceSummary, + SourceReportResponse, + MarkClaimedRequest, + MarkClaimedResponse, +} from './types/sourceReport.js'; diff --git a/shared/src/types/errors.ts b/shared/src/types/errors.ts index 957682835..23b4b80f9 100644 --- a/shared/src/types/errors.ts +++ b/shared/src/types/errors.ts @@ -49,6 +49,7 @@ export type ErrorCode = | 'SAME_SOURCE' | 'EMPTY_SELECTION' | 'STALE_OWNERSHIP' + | 'INVOICES_NOT_CLAIMABLE' | 'DEPOSITS_EXCEED_INVOICE_TOTAL' | 'REFUND_EXCEEDS_INVOICE' | 'INVALID_DEPOSIT_STATUS_TRANSITION' diff --git a/shared/src/types/paperless.ts b/shared/src/types/paperless.ts index 546c0d9e5..728861245 100644 --- a/shared/src/types/paperless.ts +++ b/shared/src/types/paperless.ts @@ -19,3 +19,8 @@ export interface PaperlessCorrespondent { export interface PaperlessCorrespondentListResponse { correspondents: PaperlessCorrespondent[]; } + +/** Response for POST /api/paperless/documents — the Paperless-ngx consumption task UUID. */ +export interface PaperlessUploadResponse { + taskId: string; +} diff --git a/shared/src/types/sourceReport.ts b/shared/src/types/sourceReport.ts new file mode 100644 index 000000000..00cc91c77 --- /dev/null +++ b/shared/src/types/sourceReport.ts @@ -0,0 +1,69 @@ +import type { AttachmentType } from './document.js'; +import type { BudgetSourceType } from './budgetSource.js'; +import type { InvoiceStatus } from './invoice.js'; + +export type SourceReportType = 'budget-overview' | 'claim' | 'proof-of-funds'; + +/** A single Paperless-ngx document reference resolved for a report line. */ +export interface SourceReportDocument { + documentId: number; + /** null if Paperless is unreachable/unconfigured — degrade to ID-only, never omit the doc. */ + archiveSerialNumber: number | null; + title: string | null; + attachmentType: AttachmentType | null; +} + +/** One invoice's contribution to this source within the requested report's status slice. */ +export interface SourceReportInvoice { + invoiceId: string; + vendorId: string; + vendorName: string; + invoiceNumber: string | null; + date: string; + status: InvoiceStatus; + invoiceAmount: number; + /** Net contribution to this source for the report's status slice, rounded to 2dp. Negative for refund-adjustment lines. */ + allocatedAmount: number; + lineKind: 'invoice' | 'refund-adjustment'; + /** True iff this invoice's budget lines reference more than one distinct budget source. */ + isSplit: boolean; + documents: SourceReportDocument[]; +} + +/** Informational row: matching-status invoice with zero budget lines (not itemized to any source). */ +export interface SourceReportUnallocatedInvoice { + invoiceId: string; + vendorId: string; + vendorName: string; + invoiceNumber: string | null; + date: string; + status: InvoiceStatus; + invoiceAmount: number; +} + +export interface SourceReportSourceSummary { + id: string; + name: string; + sourceType: BudgetSourceType; + reference: string | null; + contactAddress: string | null; +} + +export interface SourceReportResponse { + type: SourceReportType; + source: SourceReportSourceSummary; + invoices: SourceReportInvoice[]; + /** Sum of invoices[].allocatedAmount, computed from the already-rounded per-line values. */ + totalAmount: number; + unallocatedInvoices: SourceReportUnallocatedInvoice[]; + generatedAt: string; +} + +export interface MarkClaimedRequest { + invoiceIds: string[]; +} + +export interface MarkClaimedResponse { + claimedInvoiceIds: string[]; + claimedDepositIds: string[]; +} diff --git a/wiki b/wiki index d508ff4ed..589167d52 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit d508ff4ed9740cac413f874ffd9e2f3d1c6ed3d2 +Subproject commit 589167d5240cd7285bd08aeb5d6bb24ce771bae1