From 2deea05d423aca86eaab51f881be43b4006c18d9 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:50:32 +0200 Subject: [PATCH] feat(import): attach underlag to SIE-migrated verifikat by filename (#1627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(documents): lift the SIE voucher-ref resolver into core The provider migration sweep resolved a source voucher reference to the verifikat it became with an in-memory (period, series, number) index built inside extensions/general/arcim-migration. The underlag filename import needs the identical resolution, and core must never import from @/extensions, so the index, its ambiguity handling and the two paged reads move to lib/documents/voucher-ref-resolver.ts. Behaviour-preserving for the extension: same index construction, same "drop both when one key repeats inside a fiscal year" rule, same dateTo-window resolution. The arcim tests pass unchanged. Two deliberate additions on top of the lift: - series comparison is now case-insensitive on both sides. SIE writes series uppercase in practice but the spec does not require it, and a filename is whatever the exporting tool produced. - byNumber and fetchVouchersForNumbers serve the filename flow, which resolves a handful of refs per request and must not pull every migrated entry into memory to do it. Co-Authored-By: Claude Opus 5 (1M context) * feat(import): attach underlag to SIE-migrated verifikat by filename A SIE file carries the ledger but not the underlag, so a migrating customer brings the receipts over separately and today has to open every verifikat and attach them by hand. Systems that export both name each receipt after its verifikat (A31_.pdf), and the SIE import already preserves that identity on every entry (source_voucher_series / source_voucher_number), so the pairing is a lookup, not an interpretation: no AI, no amount matching, no date windows. Separate optional import mode (/import?mode=underlag), NOT a step inside the SIE wizard: the receipts normally arrive later and from a different export, so a migration must never be blocked on having them ready. lib/documents/filename-voucher-ref.ts reads the ref out of a filename lib/documents/underlag-import.ts builds the plan (reads only) POST /api/import/documents/preview filenames in, match plan out POST /api/import/documents/attach one file, archived and linked components/import/UnderlagImportWizard review, adjust, run Guards, because a document linked to a posted verifikat is räkenskapsinformation and can never be re-pointed (BFL 7 kap): - Matching keys on the SOURCE voucher number, never our own. The importer renumbers per target series, so a file named after our number would land on the wrong verifikat exactly when the import skipped a voucher. - Nothing is uploaded until the whole plan has been shown: the preview sends filenames only, the bytes stay in the browser. - A ref that hits several migrated years is surfaced as a choice, never resolved by guessing. So is a filename with a number but no series, which is resolved but never pre-selected. - A date-named file (20240131.pdf) is refused outright rather than read as voucher 20240131. - A target in a closed or locked period is shown but not selectable: enforce_period_lock_documents would refuse the write anyway. - The attach route re-resolves the filename server-side and 409s when it does not name the target the client sent, so a stale plan cannot scatter underlag permanently. An explicit manual assignment opts out of that check and is flagged as such; company ownership of the entry is always verified. - Idempotent per (verifikat, content): a re-run converges on the same document row instead of archiving duplicates. tests/pg/underlag-attach-period-lock.pg.test.ts pins the period-lock contract the plan surface promises, including that the lock guards the LINK and still lets an unlinked document be archived. Co-Authored-By: Claude Opus 5 (1M context) * fix(import): scope underlag matching to a declared fiscal year Adversarial review of #1627 refuted the resolver: it looked a ref up company-wide and treated "exactly one candidate exists" as proof of identity. Source systems restart voucher numbering every year and a filename carries no year, so with a partial migration, or with that year's A31 among the vouchers the importer routinely skips (empty, single-line, unbalanced), a 2023 receipt was silently attached to a 2025 verifikat. Permanent under BFL 7 kap, and invisible afterwards. Cardinality is not identity. Every batch now declares its fiscal year and candidates outside it are dropped before the index is built, so no downstream branch can see, count or propose one. The attach route takes the year for its re-resolution from the TARGET entry, never from the client, so the check cannot be widened by naming a different year. Scoping cannot make the year inferable; it makes it asserted, and the confirm dialog reads it back because it is the one input the files cannot corroborate. Four further defects from the same review: - npm test went red: hoisting the column list into a VOUCHER_SELECT constant hid it from the no-phantom-columns AST scan (ceiling 377 -> 379) and dropped all eight journal_entries columns out of the guard on the one path that writes irreversible links. Both selects are inline again, and split: the provider sweep no longer fetches three display columns it never reads. - The date guard only caught zero-padded hyphenated dates, so `2024-1-31 kvitto.pdf`, `2024 01 31 ...`, `2024.1.31` and `24-01-31` all parsed as voucher 2024 or 24. Widened to unpadded components, two-digit years and space/slash separators; a bare year-shaped number is refused. - `Verifikation 31.pdf` parsed as series ION: the alternation matched `ifikat` and left `ion` for the series group. Reordering alone was not enough (the engine backtracks into it), so the prefix now requires the word to end. - The manual-reference box was an unguarded write path: typing a date got path-split down to a voucher number, marked the row selected, and posted with override, which skips both server checks, while the row still showed "Kan inte tolkas". Directory splitting is gone from the parser, the row status is updated on resolve, and picking a server-proposed candidate no longer counts as an override, which had disabled the filename check on exactly the ambiguous rows it exists to protect. Co-Authored-By: Claude Opus 5 (1M context) * fix(import): enforce the declared fiscal year on the server The second adversarial pass refuted the previous fix. The attach route took the year for its re-resolution from the TARGET entry, which is tautological: an entry is by construction inside its own fiscal_period_id, so the filter could never drop it and the year axis was unfalsifiable. Server-side year enforcement was zero; the declared year existed only as React state and was never sent. The regression test that "proved" otherwise passed only because the mock let one journal_entries row report two different fiscal_period_id values to two different reads, a state Postgres cannot produce. A test that could not fail. The attach request now carries the year the user actually reviewed, echoed back from the plan, and the route asserts it equals the target's own period BEFORE any other check and including overrides: an override is a statement about which verifikat, never about which year. Its test asserts that directly instead of a mock artifact. Also from the same pass, a UI race that made the confirm dialog lie: FyPicker stayed interactive while a preview of up to 2000 filenames was in flight, so the summary and the confirm text could read back a year the plan was not built from, and a manually resolved row could join the batch from another year entirely. The wizard snapshots the plan's year, every downstream read uses the snapshot, manual re-resolution goes through the server's own echoed plan.fiscal_period_id, and the picker is frozen while a preview runs. Parser, from the corpus pass (~360 realistic filenames plus 200k random uuids, no ReDoS found: 2000 hostile inputs in 26ms): - Day-first and US dates parsed as voucher numbers: `31.01.2024` became voucher 31, a number that always exists in the year. The guard now covers both orders. - `ver 31.pdf` parsed as series VER and came back auto-selectable, while every spelled-out `Verifikat 31.pdf` correctly yielded a series-less reference needing confirmation. Same filename, two trust levels, decided by an abbreviation. `ver` is no longer a series. Known residual, stated rather than papered over: a scanner's `A4.pdf` or a `K10.pdf` blankett in the receipts folder still matches verifikat A4 or K10 when that year has them. No parser can separate those from a genuine reference; they appear in the review table with the target's date and description. Co-Authored-By: Claude Opus 5 (1M context) * fix(import): make the user actually declare the fiscal year The third adversarial pass found that the central guarantee of the previous two commits was fiction. FyPicker auto-selects the newest fiscal period when nothing is stored, and the wizard passes a page-specific storage key, so that branch fired on every first use. A user migrating 2023 receipts who never opened the picker resolved them against the newest year; A31 exists in essentially every year, so those rows came back `matched`, pre-selected, with only the confirm dialog between them and permanent links. Every commit message and code comment claiming "the year the user named" described behaviour the UI did not have. FyPicker gains an opt-in `requireExplicitChoice` prop, default off so no other caller changes, and the wizard uses it. The picker starts empty and the batch cannot proceed until someone picks. A previously stored explicit choice for this surface is still restored, which is what makes a multi-batch migration bearable. Also: a company with zero fiscal periods hit a disabled picker and a disabled button with no explanation. There is now a line saying why. Co-Authored-By: Claude Opus 5 (1M context) * fix(import): close the restore-branch hole and demote collision-prone refs Round four of adversarial review, two findings, both fixed. 1. `requireExplicitChoice` gated only the newest-period fallback, not the localStorage restore branch above it, so the "user declares the year" guarantee held only for a user's first-ever batch. From the second on, the year was silently pre-filled from an earlier unrelated batch, and in a multi-year migration last-used is the worst possible default: the user is by definition moving to a different year each round. The prop now gates FyPicker's ENTIRE auto-selection block with one outer condition (restore, the ALL_YEARS-stored fallback, newest-period, preferLatestEnded), because a per-branch gate already missed one branch once. It also suppresses the localStorage write, which fired BEFORE onChange and so recorded picks the wizard had rejected mid-preview. The wizard drops its storage prefix entirely: within one sitting reset() carries the year in state, and nothing survives the session. 2. The filename parser pre-ticked `A4 scan.pdf` and `K10.pdf` while requiring a click for `31.pdf`, which carries MORE voucher evidence in a single-series company. Two independent review passes flagged the same inconsistency. Collision-famous refs (A0-A6 paper sizes, K2-K13/N1-N9/ T1-T2 blanketter, Q1-Q4 quarters) and three-letter series (IMG/DSC/DOC/ SCN are cameras; real SIE series are 1-2 chars) still parse and resolve but are never auto-selected. Demoted, not refused: verifikat A4 genuinely exists in every migrated ledger, and its real receipt costs one click. Residual documented: an existing short series plus a small number in an ad-hoc name (`B2 hyra.pdf`) is indistinguishable from a real ref by filename alone. Also: the attach route's multipart doc now names the required fiscal_period_id field, and the stale reset() comment describes the actual persistence model. Co-Authored-By: Claude Opus 5 (1M context) * fix(import): honor override only for unresolvable filenames + review round Resolution pass for the PR #1627 review reports (CodeRabbit, Swedish accounting review, compliance swarm). The one substantive finding (CodeRabbit, major): `override: true` skipped the filename consistency check entirely, so a crafted client could attach a cleanly-named file to any same-year verifikat. The resolver now runs on every request; an override is honored only when the filename is unresolvable in the declared year (no parse, or no candidate) or already resolves to the requested target. The shipped UI only overrides unresolvable rows, so nothing user-facing changes. planAcceptsTarget is renamed planPermitsAttach and carries the semantics in one place, with tests for both directions. The Swedish review finding (BFNAR 2013:2 systemdokumentation): the planPermitsAttach JSDoc still described the superseded derive-the-year-from- the-target design. It now states the actual control: the route asserts the caller-declared year equals the target's own period before this function runs. CodeRabbit minors and nitpicks: - underlag_confirm_body / underlag_run / underlag_locked_warning use ICU plural forms in both locales; "1 filer arkiveras" was wrong Swedish. - The attach and preview route tests mock @/lib/supabase/server per the repo test guideline. - fetchVouchersForNumbers narrows to the declared fiscal year at the DB; the in-memory filter in buildUnderlagPlan remains the enforced truth. - buildVoucherIndex appends into existing arrays instead of copying per row: the provider sweep indexes every migrated entry in the company and per-row copies made that O(n^2). - The pg test reuses its insertDocument helper instead of a duplicated INSERT; runAttach clears isLoading in a finally. Declined, with reasons in DECISIONS.md: message-regex classification of validateDocumentFile failures (established sibling pattern; validator contract change is out of scope). Co-Authored-By: Claude Opus 5 (1M context) * fix(import): attach only to posted or reversed verifikat Second review cycle on PR #1627: the Swedish accounting review's re-run found that nothing in the attach route verified the target entry's status. The SIE import RPC posts every entry inside its own transaction, so a draft carrying a source ref should be unobservable, but the link this route writes is irreversible räkenskapsinformation, and an invariant enforced in another file is not one this surface may lean on. Underlag references a verifikation (BFL 5 kap 6-7 §), so the target must BE one. Enforced twice: the route rejects non-posted targets with UNDERLAG_ENTRY_NOT_POSTED (overrides included), and the resolver reads filter to posted/reversed so a draft can never even become a candidate. Reversed stays attachable: a storno'd original remains räkenskapsinformation and its underlag belongs on it. Also recorded as confirmed-intentional (review note, no code change): with override and an unresolvable filename the endpoint links to any same-company, same-declared-year, posted verifikat, migrated or not, which mirrors the existing /api/documents/[id]/link capability. The period-lock error-string regex note restates a disposition already recorded in DECISIONS.md. The arcim test's Supabase double learns .in(), which the shared resolver read now uses for the status filter. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- DECISIONS.md | 16 + app/(dashboard)/import/page.tsx | 15 +- .../documents/attach/__tests__/route.test.ts | 342 +++++++++ app/api/import/documents/attach/route.ts | 212 ++++++ .../documents/preview/__tests__/route.test.ts | 180 +++++ app/api/import/documents/preview/route.ts | 54 ++ components/common/FyPicker.tsx | 34 +- components/import/UnderlagImportWizard.tsx | 676 ++++++++++++++++++ .../__tests__/import-documents.test.ts | 1 + .../arcim-migration/lib/import-documents.ts | 88 +-- lib/api/schemas.ts | 14 + .../__tests__/filename-voucher-ref.test.ts | 179 +++++ .../__tests__/underlag-import.test.ts | 354 +++++++++ .../__tests__/voucher-ref-resolver.test.ts | 193 +++++ lib/documents/filename-voucher-ref.ts | 174 +++++ lib/documents/underlag-import.ts | 286 ++++++++ lib/documents/voucher-ref-resolver.ts | 314 ++++++++ lib/errors/structured-errors.ts | 24 + messages/en.json | 42 ++ messages/sv.json | 42 ++ .../pg/underlag-attach-period-lock.pg.test.ts | 132 ++++ 21 files changed, 3289 insertions(+), 83 deletions(-) create mode 100644 app/api/import/documents/attach/__tests__/route.test.ts create mode 100644 app/api/import/documents/attach/route.ts create mode 100644 app/api/import/documents/preview/__tests__/route.test.ts create mode 100644 app/api/import/documents/preview/route.ts create mode 100644 components/import/UnderlagImportWizard.tsx create mode 100644 lib/documents/__tests__/filename-voucher-ref.test.ts create mode 100644 lib/documents/__tests__/underlag-import.test.ts create mode 100644 lib/documents/__tests__/voucher-ref-resolver.test.ts create mode 100644 lib/documents/filename-voucher-ref.ts create mode 100644 lib/documents/underlag-import.ts create mode 100644 lib/documents/voucher-ref-resolver.ts create mode 100644 tests/pg/underlag-attach-period-lock.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index c17fd0f7..904f1aea 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -993,3 +993,19 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-14] /sie-data validation stays newest-file-only (not per-file, not on the merged parse): preserves exactly which datasets are accepted today, and validateSIEFile assumes single-file invariants (balance yearIndexes relative to ONE current year) that mergeParsedSIEFiles deliberately does not preserve. Older files' problems still surface per-file at import time. [2026-08-14] SKV manual-verifikat deep link payload moved from URL params to single-use sessionStorage (supersedes same-day URL-params decision): compliance swarm flagged financial data in query strings landing in history/access logs/Referer (GDPR Art.5(1)(f), ISO A.8.12); URL now carries only the opaque row id. [2026-08-14] SKV prefill sessionStorage XSS window accepted as residual risk (ISO A.8.12 low, swarm PR #1621): script execution already implies full ledger read via authenticated APIs; a server-issued staging token adds a roundtrip, not protection. Documented in manual-verifikat-prefill.ts header. +[2026-08-15] Underlag import (attaching a folder of receipts to SIE-migrated verifikat) matches on journal_entries.source_voucher_series/number, never on our own voucher_number: the importer renumbers per target series, so a file named after our number would land on the wrong verifikat exactly when the import skipped an empty/unbalanced voucher. The (period, series, number) resolver was lifted out of extensions/general/arcim-migration into lib/documents/voucher-ref-resolver.ts and is now shared by the provider sweep and the filename flow, so both have one resolution truth. +[2026-08-15] Underlag import is a SEPARATE optional import mode (/import?mode=underlag), not a step inside the SIE wizard (founder call 2026-08-15): the receipts normally arrive later and from a different export, so a migration must never be blocked on having them ready. +[2026-08-15] A filename that parses to a number with no series (31.pdf) is resolved but NEVER auto-selected, even when the lookup returns exactly one candidate, and a date-shaped name is refused outright rather than read as a voucher number. Linking a document to a posted verifikat is irreversible räkenskapsinformation (BFL 7 kap), so the cost of a wrong parse is permanent and the cost of asking is one click. The date guard is deliberately looser than the parser (unpadded components, two-digit years, space and slash separators, and any bare four-digit year-shaped number): a false positive costs one manual assignment, a false negative costs a permanent wrong link. +[2026-08-15] Every underlag-import batch is scoped to a fiscal year the USER declares, and resolution filters candidates to that year before the index is built. Rejected the original design, which resolved company-wide and treated "exactly one candidate exists" as proof of identity: source systems restart voucher numbering annually and a filename carries no year, so with a partial migration (or with that year's A31 among the vouchers the importer routinely skips as empty/single-line/unbalanced) a 2023 receipt was silently and permanently attached to a 2025 verifikat. Cardinality is not identity. Scoping cannot make the year inferable, so it makes it asserted; the year is read back in the confirm dialog because it is the one input the files cannot corroborate. Found by the /skeptic pass on PR #1627, not by the tests. +[2026-08-15] SUPERSEDED same day, see the entry below: the attach route took the fiscal year for its re-resolution from the TARGET entry, never from the client. Picking among candidates the server itself proposed is NOT an override: only a filename the user resolved by hand sets that flag. Flagging candidate picks would have switched the consistency check off on exactly the ambiguous rows it exists to protect. +[2026-08-15] Column lists for the two voucher-ref reads are written inline at the call site instead of a shared VOUCHER_SELECT constant: tests/schema/no-phantom-columns.test.ts resolves .select() literals by AST scan, so a constant is opaque to it and hoisting dropped all eight journal_entries columns out of the phantom-column net (and pushed the unresolved ceiling from 377 to 379, failing npm test). The provider sweep and the plan read now select different lists anyway: the sweep resolves thousands of entries and needs none of the display columns. +[2026-08-15] SUPERSEDES the entry above: the attach route takes the declared fiscal year from the REQUEST (echoed back from the plan the user reviewed) and asserts it equals the target entry's own fiscal_period_id, before any other check and including overrides. Deriving the year from the target was tautological: an entry is by construction inside its own period, so the filter could never drop it and the year axis was unfalsifiable. Server-side year enforcement was zero; it existed only as React state. The regression test that "proved" otherwise passed only because the mock let one journal_entries row report two different fiscal_period_id values to two different reads, a state Postgres cannot produce. Found by the second /skeptic pass on PR #1627. +[2026-08-15] The wizard snapshots the plan's fiscal year and every downstream read (summary, confirm dialog, manual re-resolution, each attach request) uses that snapshot, never the live picker. The picker stays interactive while a preview of up to 2000 filenames is in flight, so a confirm dialog reading live state could read back a different year than the plan was built from, which is worse than no confirm dialog at all. +[2026-08-15] `ver` is excluded from being a voucher series in filename parsing. Without it `ver 31.pdf` parsed as series VER and came back auto-selectable, while the spelled-out `Verifikat 31.pdf` correctly yielded a series-less reference needing confirmation: the same filename got two trust levels decided by an abbreviation, and the loose one was the abbreviation. The date guard also covers day-first and US order (31.01.2024, 12-24-2024), where the day would otherwise become a voucher number that always exists in the year. +[2026-08-15] FyPicker gained an opt-in `requireExplicitChoice` prop and the underlag import uses it. Default behaviour (auto-select the newest period when nothing is stored) is right for a filter, where a sensible default beats an empty page, and wrong where the year is an ASSERTION the user is making: the third /skeptic pass showed the wizard silently pre-filled the newest year, so a user migrating 2023 receipts who never opened the picker would resolve them against 2026, get `matched` rows pre-selected, and have only the confirm dialog between that and permanent links. The whole "the user declares the year" guarantee was fiction until this. A previously stored explicit choice for the same surface is still restored; no other caller changes behaviour. +[2026-08-15] `requireExplicitChoice` gates FyPicker's ENTIRE auto-selection block with one outer condition, not individual branches, and also suppresses the localStorage write. The fourth /skeptic pass showed the per-branch gate had missed the restore branch, so the guarantee held only for a user's first-ever batch: from the second on, the year was silently pre-filled from an earlier unrelated batch, and in a multi-year migration last-used is the worst possible default since the user is by definition moving to a different year. The write suppression matters because FyPicker persisted BEFORE onChange, so a pick the wizard rejected mid-preview would still have been recorded. Within one sitting, the wizard's reset() carries the year across batches; nothing else does, deliberately. +[2026-08-15] Filename parsing demotes (never refuses) collision-famous refs from auto-selection: A0-A6 (paper sizes; every scanner emits A4.pdf), K2-K13/N1-N9/T1-T2 (Skatteverket blanketter), Q1-Q4 (quarters), and any three-letter series (IMG/DSC/DOC/SCN are cameras; real SIE series are 1-2 chars). Two independent /skeptic passes flagged the same inconsistency: `31.pdf` required confirmation while `A4 scan.pdf`, which carries LESS voucher evidence, was pre-ticked. Verifikat A4 exists in every migrated ledger, so refusing would orphan the genuine A4 receipt; demotion costs it one click. Residual accepted and documented: an existing 1-2 letter series plus a small number in an ad-hoc name (`B2 hyra.pdf`) is indistinguishable from a real ref by filename alone. +[2026-08-15] The attach route runs the filename resolver on EVERY request; `override` is honored only when the filename is unresolvable in the declared year (no parse, or zero candidates), never for a filename the resolver can place elsewhere (CodeRabbit major on PR #1627). The shipped UI only overrides rows whose filenames resolved to nothing, so the tightening costs it no capability; it closes the API path where a lying client set override=true and scattered cleanly-named underlag across arbitrary same-year verifikat. planAcceptsTarget was renamed planPermitsAttach to carry the override semantics in one place. +[2026-08-15] Declined (recorded per /resolve-pr triage): classifying validateDocumentFile failures by regex on the localized message in the attach route stays as-is; it mirrors the established pattern in app/api/documents/route.ts, and moving the validator to coded returns is a core-service contract change outside this PR. The Swedish-review notes on idempotency (content hash IS in deterministicDocumentId) and the period-lock error regex (DB trigger is the real guard) require no change. +[2026-08-15] Underlag may attach only to posted or reversed verifikat, enforced in the attach route AND in the resolver reads (Swedish-review round on PR #1627). The SIE import RPC posts every entry inside its own transaction, so a draft with a source ref should be unobservable; the enforcement exists because the link is irreversible and an invariant living in another file is not one this surface may lean on. Reversed stays attachable: a storno'd original remains räkenskapsinformation and its underlag belongs on it. +[2026-08-15] Confirmed intentional (Swedish-review note): with override=true and an unresolvable filename, the attach endpoint links a document to any same-company, same-declared-year, posted verifikat, migrated or not. This mirrors /api/documents/[id]/link, which imposes no filename check at all, so it introduces no new capability class; tenant, year and period-lock enforcement always apply. diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 5728c481..29c66c11 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -114,6 +114,7 @@ const AccountMappingStep = dynamic(() => import('@/components/import/AccountMapp const ImportReviewStep = dynamic(() => import('@/components/import/ImportReviewStep'), { loading: ImportStepLoading }) const ImportResultStep = dynamic(() => import('@/components/import/ImportResultStep'), { loading: ImportStepLoading }) const SIEImportHistory = dynamic(() => import('@/components/import/SIEImportHistory'), { loading: ImportStepLoading }) +const UnderlagImportWizard = dynamic(() => import('@/components/import/UnderlagImportWizard'), { loading: ImportStepLoading }) // ============================================================ // Bank File Import Wizard Steps @@ -2096,7 +2097,7 @@ const ShopifyPanel = getSettingsPanel('shopify') // Import Page with Selection Cards // ============================================================ -type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'shopify' | 'bank' | 'sie' | 'csv_data' | 'migration' +type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'shopify' | 'bank' | 'sie' | 'underlag' | 'csv_data' | 'migration' export default function ImportPage() { const { isSandbox } = useCompany() @@ -2129,8 +2130,8 @@ export default function ImportPage() { // third-party credentials, so their deep links are ignored in the sandbox. // Manual file-import modes (bank file, CSV/Excel, SIE) stay reachable. const allowedModes = isSandbox - ? ['bank', 'sie', 'csv_data'] - : ['psd2', 'stripe', 'woocommerce', 'shopify', 'bank', 'sie', 'csv_data', 'migration'] + ? ['bank', 'sie', 'underlag', 'csv_data'] + : ['psd2', 'stripe', 'woocommerce', 'shopify', 'bank', 'sie', 'underlag', 'csv_data', 'migration'] if (!isSandbox && searchParams.get('migration')) { setMode('migration') } else { @@ -2294,6 +2295,13 @@ export default function ImportPage() { sub={t('sie_description')} onClick={() => setMode('sie')} /> + {/* Optional follow-up to a SIE import, never a step inside it: + the receipts usually arrive later and from another export. */} + setMode('underlag')} + /> } {mode === 'sie' && } + {mode === 'underlag' && } {mode === 'csv_data' && } {mode === 'migration' && ( diff --git a/app/api/import/documents/attach/__tests__/route.test.ts b/app/api/import/documents/attach/__tests__/route.test.ts new file mode 100644 index 00000000..1a46eaa9 --- /dev/null +++ b/app/api/import/documents/attach/__tests__/route.test.ts @@ -0,0 +1,342 @@ +/** + * Tests for POST /api/import/documents/attach: archives one underlag file and + * links it to the SIE-migrated verifikat its filename names. + * + * The link is irreversible räkenskapsinformation (BFL 7 kap), so the guards + * matter more than the happy path: the target must belong to the company, and + * the automatic path must land where the preview said it would. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { parseJsonResponse } from '@/tests/helpers' + +const PERIOD_OPEN = '44444444-4444-4444-8444-444444444444' +const PERIOD_OTHER = '55555555-5555-4555-8555-555555555555' + +const PERIODS = [ + { + id: PERIOD_OPEN, + period_start: '2024-01-01', + period_end: '2024-12-31', + is_closed: false, + locked_at: null, + }, +] + +const TARGET_ID = '11111111-1111-4111-8111-111111111111' +const OTHER_ID = '22222222-2222-4222-8222-222222222222' + +const VOUCHER = { + id: TARGET_ID, + fiscal_period_id: PERIOD_OPEN, + entry_date: '2024-03-14', + description: 'Inköp kontorsmaterial', + voucher_series: 'A', + voucher_number: 47, + source_voucher_series: 'A', + source_voucher_number: 31, +} + +/** The row the tenant-ownership lookup finds, or null for "not this company". */ +let targetEntry: Record | null = null +/** What the ledger holds for the filename's ref, as the plan would see it. */ +let vouchers: Record[] = [] + +const supabase = { + from(table: string) { + let filteredByNumber = false + let single = false + + const result = () => { + if (table === 'fiscal_periods') return { data: PERIODS, error: null, count: PERIODS.length } + if (single) return { data: targetEntry, error: null } + return { data: filteredByNumber ? vouchers : [], error: null, count: vouchers.length } + } + + const chain: Record = {} + for (const method of ['select', 'eq', 'not', 'order', 'limit']) { + chain[method] = () => chain + } + chain.maybeSingle = () => { + single = true + return chain + } + chain.in = () => { + filteredByNumber = true + return chain + } + chain.range = () => Promise.resolve(result()) + chain.then = (onFulfilled: (value: unknown) => unknown) => + Promise.resolve(result()).then(onFulfilled) + return chain + }, +} + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +// Guideline mock: no real Supabase client may ever be constructed in a route +// test, even though this suite injects its double through requireAuth. +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(supabase), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const getCompanyRoleMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + getCompanyRole: (...args: unknown[]) => getCompanyRoleMock(...args), + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const uploadDocumentMock = vi.fn() +vi.mock('@/lib/core/documents/document-service', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + uploadDocument: (...args: unknown[]) => uploadDocumentMock(...args), + } +}) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } + +function makeRequest(options: { + fileName?: string + journalEntryId?: string | null + /** The year the plan was built against, as the wizard echoes it back. */ + declaredPeriodId?: string | null + override?: boolean + withFile?: boolean + mimeType?: string +}) { + const form = new FormData() + if (options.withFile !== false) { + form.append( + 'file', + new File(['%PDF-1.4 underlag'], options.fileName ?? 'A31_8c2db060.pdf', { + type: options.mimeType ?? 'application/pdf', + }), + ) + } + if (options.journalEntryId !== null) { + form.append('journal_entry_id', options.journalEntryId ?? TARGET_ID) + } + if (options.declaredPeriodId !== null) { + form.append('fiscal_period_id', options.declaredPeriodId ?? PERIOD_OPEN) + } + if (options.override) form.append('override', 'true') + + return new Request('http://localhost:3000/api/import/documents/attach', { + method: 'POST', + body: form, + }) +} + +describe('POST /api/import/documents/attach', () => { + beforeEach(() => { + vi.clearAllMocks() + targetEntry = { id: TARGET_ID, fiscal_period_id: PERIOD_OPEN, status: 'posted', source_voucher_series: 'A', source_voucher_number: 31 } + vouchers = [VOUCHER] + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + getCompanyRoleMock.mockResolvedValue({ ok: true, role: 'owner', companyId: 'company-1' }) + uploadDocumentMock.mockResolvedValue({ id: 'doc-1', file_name: 'A31_8c2db060.pdf' }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const res = await POST(makeRequest({}), emptyParams) + + expect(res.status).toBe(401) + expect(uploadDocumentMock).not.toHaveBeenCalled() + }) + + it('returns 400 when no file was sent', async () => { + const res = await POST(makeRequest({ withFile: false }), emptyParams) + + expect(res.status).toBe(400) + }) + + it('returns 400 when journal_entry_id is missing or not a uuid', async () => { + expect((await POST(makeRequest({ journalEntryId: null }), emptyParams)).status).toBe(400) + expect((await POST(makeRequest({ journalEntryId: 'A31' }), emptyParams)).status).toBe(400) + expect(uploadDocumentMock).not.toHaveBeenCalled() + }) + + it('returns 404 when the target entry belongs to another company', async () => { + targetEntry = null + + const res = await POST(makeRequest({}), emptyParams) + + expect(res.status).toBe(404) + expect(uploadDocumentMock).not.toHaveBeenCalled() + }) + + it('refuses a target the filename does not resolve to', async () => { + // The entry exists and is ours, but A31 belongs to a different verifikat: + // a stale plan in the browser must not scatter underlag permanently. + vouchers = [{ ...VOUCHER, id: OTHER_ID }] + + const res = await POST(makeRequest({}), emptyParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + + expect(status).toBe(409) + expect(body.error.code).toBe('UNDERLAG_REF_MISMATCH') + expect(uploadDocumentMock).not.toHaveBeenCalled() + }) + + it('returns 400 when the declared fiscal year is missing or not a uuid', async () => { + expect((await POST(makeRequest({ declaredPeriodId: null }), emptyParams)).status).toBe(400) + expect((await POST(makeRequest({ declaredPeriodId: '2024' }), emptyParams)).status).toBe(400) + expect(uploadDocumentMock).not.toHaveBeenCalled() + }) + + it('refuses a target outside the fiscal year the batch declared', async () => { + // The user reviewed a plan for one year; this target lives in another. + // Nothing about the filename can make that acceptable. + const res = await POST(makeRequest({ declaredPeriodId: PERIOD_OTHER }), emptyParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + + expect(status).toBe(409) + expect(body.error.code).toBe('UNDERLAG_PERIOD_MISMATCH') + expect(uploadDocumentMock).not.toHaveBeenCalled() + }) + + it('enforces the declared year even on a manual override', async () => { + // An override is a statement about WHICH verifikat, never about which + // year, so it must not widen the batch across fiscal years. + const res = await POST( + makeRequest({ declaredPeriodId: PERIOD_OTHER, override: true, fileName: 'kvitto.pdf' }), + emptyParams, + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + + expect(status).toBe(409) + expect(body.error.code).toBe('UNDERLAG_PERIOD_MISMATCH') + expect(uploadDocumentMock).not.toHaveBeenCalled() + }) + + it('explains a target that never came from a SIE import', async () => { + targetEntry = { id: TARGET_ID, fiscal_period_id: PERIOD_OPEN, status: 'posted', source_voucher_series: null, source_voucher_number: null } + + const res = await POST(makeRequest({}), emptyParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + + expect(status).toBe(400) + expect(body.error.code).toBe('UNDERLAG_ENTRY_NOT_MIGRATED') + expect(uploadDocumentMock).not.toHaveBeenCalled() + }) + + it('refuses a target that is not posted, overrides included', async () => { + // The SIE import posts entries inside its own transaction, so a draft with + // a source ref should be unreachable. This route does not lean on an + // invariant enforced in another file: underlag references a verifikation + // (BFL 5 kap 6-7 §), so the target must BE one. + targetEntry = { ...targetEntry!, status: 'draft' } + + const auto = await POST(makeRequest({}), emptyParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(auto) + expect(status).toBe(409) + expect(body.error.code).toBe('UNDERLAG_ENTRY_NOT_POSTED') + + const overridden = await POST( + makeRequest({ fileName: 'kvitto ica.pdf', override: true }), + emptyParams, + ) + expect(overridden.status).toBe(409) + expect(uploadDocumentMock).not.toHaveBeenCalled() + }) + + it('accepts a REVERSED target: a storno original keeps its underlag', async () => { + targetEntry = { ...targetEntry!, status: 'reversed' } + + const res = await POST(makeRequest({}), emptyParams) + + expect(res.status).toBe(200) + expect(uploadDocumentMock).toHaveBeenCalledOnce() + }) + + it('refuses an override for a filename that resolves to a DIFFERENT target', async () => { + // The server honors override only for filenames it cannot place. A31.pdf + // resolves cleanly to another verifikat, so a lying client cannot use + // override to scatter it. + vouchers = [{ ...VOUCHER, id: OTHER_ID }] + + const res = await POST(makeRequest({ override: true }), emptyParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + + expect(status).toBe(409) + expect(body.error.code).toBe('UNDERLAG_REF_MISMATCH') + expect(uploadDocumentMock).not.toHaveBeenCalled() + }) + + it('allows an explicit manual assignment to skip the filename check', async () => { + targetEntry = { id: TARGET_ID, fiscal_period_id: PERIOD_OPEN, status: 'posted', source_voucher_series: null, source_voucher_number: null } + + const res = await POST( + makeRequest({ fileName: 'kvitto ica.pdf', override: true }), + emptyParams, + ) + + expect(res.status).toBe(200) + expect(uploadDocumentMock).toHaveBeenCalledOnce() + }) + + it('archives and links the file, scoping idempotency to the verifikat', async () => { + const res = await POST(makeRequest({}), emptyParams) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(res) + + expect(status).toBe(200) + expect(body.data.id).toBe('doc-1') + + const [, userId, companyId, file, metadata] = uploadDocumentMock.mock.calls[0] + expect(userId).toBe('user-1') + expect(companyId).toBe('company-1') + expect(file).toMatchObject({ name: 'A31_8c2db060.pdf', type: 'application/pdf' }) + expect(metadata).toMatchObject({ + journal_entry_id: TARGET_ID, + // Same content on a different verifikat must archive separately, so the + // key is the entry, not the bytes. + idempotency_key: TARGET_ID, + upload_source: 'file_upload', + }) + }) + + it('maps the period-lock trigger to a usable error instead of a 500', async () => { + uploadDocumentMock.mockRejectedValue( + new Error('Cannot attach documents to entries in a locked/closed fiscal period'), + ) + + const res = await POST(makeRequest({}), emptyParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + + expect(status).toBe(400) + expect(body.error.code).toBe('DOC_UPLOAD_PERIOD_LOCKED') + }) + + it('does not leak storage internals when the archive write fails', async () => { + uploadDocumentMock.mockRejectedValue(new Error('bucket s3://internal-prod-bucket exploded')) + + const res = await POST(makeRequest({}), emptyParams) + const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>( + res, + ) + + expect(status).toBe(500) + expect(body.error.code).toBe('DOC_UPLOAD_STORAGE_FAILED') + expect(body.error.message).not.toContain('internal-prod-bucket') + }) +}) diff --git a/app/api/import/documents/attach/route.ts b/app/api/import/documents/attach/route.ts new file mode 100644 index 00000000..6f5f0b30 --- /dev/null +++ b/app/api/import/documents/attach/route.ts @@ -0,0 +1,212 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { uploadDocument, validateDocumentFile } from '@/lib/core/documents/document-service' +import { planPermitsAttach } from '@/lib/documents/underlag-import' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' + +ensureInitialized() + +const AttachFieldsSchema = z.object({ + journal_entry_id: z.string().uuid(), + /** + * The fiscal year the user reviewed this batch against, echoed back from the + * plan. Required, and checked against the target's own year on every request + * including overrides: it is the only thing that makes the year enforceable + * server-side. Deriving it from the target instead would be tautological, an + * entry is by construction inside its own period, and that is precisely the + * hole that let a 2023 receipt land on a 2025 verifikat. + */ + fiscal_period_id: z.string().uuid(), + /** + * Set ONLY when the user resolved this file by hand because its filename + * carries no usable reference. Honored server-side ONLY for filenames the + * resolver cannot place in the declared year: a resolvable filename must + * land where it points, override or not. Company ownership, the year + * assertion and the period lock are always enforced. + * + * Choosing among candidates the server itself proposed is NOT an override: + * those targets pass the check already, and flagging them would switch the + * guard off on exactly the ambiguous rows it exists for. + */ + override: z.boolean(), +}) + +/** + * POST /api/import/documents/attach: archive one underlag file and link it to a + * migrated verifikat. + * + * multipart/form-data: + * file: the underlag + * journal_entry_id: the target the user approved in the preview + * fiscal_period_id: the year the plan was built against (echoed back) + * override: 'true' when the target was chosen by hand + * + * One file per request on purpose: a folder migration is hundreds of files, the + * browser streams them one at a time with visible progress, and a failure on + * file 200 leaves the first 199 correctly attached instead of rolling back work + * that is legally irreversible anyway. + * + * Idempotent per (verifikat, content): re-running the same import converges on + * the same document row rather than archiving duplicates, via the deterministic + * document id that `idempotency_key` reserves. + */ +export const POST = withRouteContext( + 'import.documents.attach', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx + + const formData = await request.formData() + const file = formData.get('file') as File | null + + if (!file) { + return errorResponseFromCode('DOC_UPLOAD_NO_FILE', log, { requestId }) + } + + const fields = AttachFieldsSchema.safeParse({ + journal_entry_id: formData.get('journal_entry_id'), + fiscal_period_id: formData.get('fiscal_period_id'), + override: formData.get('override') === 'true', + }) + if (!fields.success) { + return errorResponseFromCode('VALIDATION_ERROR', log, { + requestId, + details: { + issues: fields.error.issues.map((i) => ({ + field: i.path.join('.'), + reason: i.message, + })), + }, + }) + } + const { + journal_entry_id: journalEntryId, + fiscal_period_id: fiscalPeriodId, + override, + } = fields.data + + const validationError = validateDocumentFile({ size: file.size, type: file.type }) + if (validationError) { + const code = /storlek|stor|MB/i.test(validationError) + ? 'DOC_UPLOAD_TOO_LARGE' + : 'DOC_UPLOAD_UNSUPPORTED_TYPE' + return errorResponseFromCode(code, log, { + requestId, + details: { reason: validationError, sizeBytes: file.size, mimeType: file.type }, + }) + } + + const opLog = log.child({ filename: file.name, journalEntryId }) + + // Tenant check first and explicitly: RLS covers the cookie session, but the + // link is irreversible, so the route never takes the client's word for which + // company an entry belongs to. + const { data: entry, error: entryError } = await supabase + .from('journal_entries') + .select('id, fiscal_period_id, status, source_voucher_series, source_voucher_number') + .eq('id', journalEntryId) + .eq('company_id', companyId!) + .maybeSingle() + + if (entryError) { + opLog.error('underlag attach target lookup failed', entryError) + return errorResponseFromCode('DOC_LINK_FAILED', opLog, { requestId }) + } + if (!entry) { + return errorResponseFromCode('DOC_LINK_ENTRY_NOT_FOUND', opLog, { requestId }) + } + + // Underlag references a verifikation (BFL 5 kap 6-7 §), so the target must + // BE one: posted, or reversed (a storno'd original keeps its underlag). The + // SIE import posts entries inside its own transaction, so a draft here + // should be unreachable, but this route writes irreversible links and does + // not lean on an invariant enforced in another file. + if (entry.status !== 'posted' && entry.status !== 'reversed') { + opLog.warn('underlag attach refused: target entry is not posted', { + entryStatus: entry.status, + }) + return errorResponseFromCode('UNDERLAG_ENTRY_NOT_POSTED', opLog, { requestId }) + } + + // The batch declared a fiscal year and the user reviewed the plan against + // it. The target must actually be in that year. Enforced FIRST and + // unconditionally, overrides included: a hand-resolved filename is a + // statement about which verifikat, never about which year, and this is the + // only check that makes the declared year mean anything on the server. + if (entry.fiscal_period_id !== fiscalPeriodId) { + opLog.warn('underlag attach refused: target sits in a different fiscal year', { + declaredFiscalPeriodId: fiscalPeriodId, + entryFiscalPeriodId: entry.fiscal_period_id, + }) + return errorResponseFromCode('UNDERLAG_PERIOD_MISMATCH', opLog, { requestId }) + } + + // The file must additionally land where the preview said it would. A stale + // plan in the browser (the user re-imported SIE in another tab, say) would + // otherwise scatter underlag across the wrong verifikat. The resolver runs + // on EVERY request: an override only relaxes it for filenames it cannot + // place at all, never for a filename that resolves elsewhere. + if (!override && entry.source_voucher_number == null) { + // Not a SIE-migrated verifikat, so no filename can ever resolve to it. + // Say that plainly instead of reporting a mismatch the user can't fix. + return errorResponseFromCode('UNDERLAG_ENTRY_NOT_MIGRATED', opLog, { requestId }) + } + const permitted = await planPermitsAttach( + supabase, + companyId!, + file.name, + journalEntryId, + fiscalPeriodId, + override, + ) + if (!permitted) { + opLog.warn('underlag attach refused: filename does not resolve to the target', { override }) + return errorResponseFromCode('UNDERLAG_REF_MISMATCH', opLog, { requestId }) + } + + try { + const buffer = await file.arrayBuffer() + + const document = await uploadDocument( + supabase, + user.id, + companyId!, + { name: file.name, buffer, type: file.type }, + { + upload_source: 'file_upload', + journal_entry_id: journalEntryId, + // Scope the deterministic id to the target verifikat: the same + // receipt may legitimately back several verifikat, so content alone + // must not dedupe across them. + idempotency_key: journalEntryId, + }, + ) + + return NextResponse.json({ data: document }) + } catch (err) { + const message = err instanceof Error ? err.message : 'unknown' + + // enforce_period_lock_documents: a migrated year is often closed by the + // time the receipts arrive, and the trigger blocks the link outright. + if (/locked\/closed fiscal period|Bokföringen är låst/i.test(message)) { + return errorResponseFromCode('DOC_UPLOAD_PERIOD_LOCKED', opLog, { + requestId, + details: { reason: getErrorMessage(err) }, + }) + } + if (/kunde inte verifieras|matchar inte den angivna filtypen/i.test(message)) { + opLog.warn('underlag attach rejected by content validation', { reason: message }) + return errorResponseFromCode('DOC_UPLOAD_INVALID_CONTENT', opLog, { + requestId, + details: { reason: getErrorMessage(err) }, + }) + } + + opLog.error('underlag attach failed', err as Error) + return errorResponseFromCode('DOC_UPLOAD_STORAGE_FAILED', opLog, { requestId }) + } + }, + { requireWrite: true }, +) diff --git a/app/api/import/documents/preview/__tests__/route.test.ts b/app/api/import/documents/preview/__tests__/route.test.ts new file mode 100644 index 00000000..7664af95 --- /dev/null +++ b/app/api/import/documents/preview/__tests__/route.test.ts @@ -0,0 +1,180 @@ +/** + * Tests for POST /api/import/documents/preview: the read-only match plan that + * pairs underlag filenames with SIE-migrated verifikat before anything is + * uploaded or linked. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' +import type { UnderlagPlan } from '@/lib/documents/underlag-import' + +const PERIOD_OPEN = '33333333-3333-4333-8333-333333333333' + +const PERIODS = [ + { + id: PERIOD_OPEN, + period_start: '2024-01-01', + period_end: '2024-12-31', + is_closed: false, + locked_at: null, + }, +] + +let vouchers: Record[] = [] +/** The row the fiscal-period ownership lookup finds, or null for "not ours". */ +let ownedPeriod: Record | null = null + +/** Shape-keyed double: the plan reads vouchers and periods concurrently. */ +const supabase = { + from(table: string) { + let filteredByNumber = false + let single = false + const result = () => { + if (table === 'fiscal_periods') { + return single + ? { data: ownedPeriod, error: null } + : { data: PERIODS, error: null, count: PERIODS.length } + } + return { + data: filteredByNumber ? vouchers : [], + error: null, + count: vouchers.length, + } + } + + const chain: Record = {} + for (const method of ['select', 'eq', 'not', 'order', 'limit', 'single']) { + chain[method] = () => chain + } + chain.maybeSingle = () => { + single = true + return chain + } + chain.in = () => { + filteredByNumber = true + return chain + } + chain.range = () => Promise.resolve(result()) + chain.then = (onFulfilled: (value: unknown) => unknown) => + Promise.resolve(result()).then(onFulfilled) + return chain + }, +} + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +// Guideline mock: no real Supabase client may ever be constructed in a route +// test, even though this suite injects its double through requireAuth. +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(supabase), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } + +function makeRequest(body: Record) { + return createMockRequest('/api/import/documents/preview', { + method: 'POST', + body: { fiscal_period_id: PERIOD_OPEN, ...body }, + }) +} + +describe('POST /api/import/documents/preview', () => { + beforeEach(() => { + vi.clearAllMocks() + vouchers = [ + { + id: 'je-1', + fiscal_period_id: PERIOD_OPEN, + entry_date: '2024-03-14', + description: 'Inköp kontorsmaterial', + voucher_series: 'A', + voucher_number: 47, + source_voucher_series: 'A', + source_voucher_number: 31, + }, + ] + ownedPeriod = { id: PERIOD_OPEN } + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const res = await POST(makeRequest({ file_names: ['A31.pdf'] }), emptyParams) + + expect(res.status).toBe(401) + }) + + it('returns 400 when file_names is missing or empty', async () => { + expect((await POST(makeRequest({}), emptyParams)).status).toBe(400) + expect((await POST(makeRequest({ file_names: [] }), emptyParams)).status).toBe(400) + }) + + it('returns 400 without a fiscal year: a filename alone cannot identify a verifikat', async () => { + const noYear = createMockRequest('/api/import/documents/preview', { + method: 'POST', + body: { file_names: ['A31.pdf'] }, + }) + expect((await POST(noYear, emptyParams)).status).toBe(400) + + const badYear = await POST( + makeRequest({ file_names: ['A31.pdf'], fiscal_period_id: 'not-a-uuid' }), + emptyParams, + ) + expect(badYear.status).toBe(400) + }) + + it("returns 404 for a fiscal year that is not this company's", async () => { + ownedPeriod = null + + const res = await POST(makeRequest({ file_names: ['A31.pdf'] }), emptyParams) + + expect(res.status).toBe(404) + }) + + it('returns 400 when the batch exceeds the 2000-file cap', async () => { + const oversized = Array.from({ length: 2001 }, (_, i) => `A${i + 1}.pdf`) + + const res = await POST(makeRequest({ file_names: oversized }), emptyParams) + + expect(res.status).toBe(400) + }) + + it('returns the match plan for the submitted filenames (happy path)', async () => { + const res = await POST( + makeRequest({ file_names: ['A31_8c2db060.pdf', 'kvitto.pdf'] }), + emptyParams, + ) + const { status, body } = await parseJsonResponse<{ data: UnderlagPlan }>(res) + + expect(status).toBe(200) + expect(body.data.rows).toHaveLength(2) + expect(body.data.rows[0]).toMatchObject({ status: 'matched', journal_entry_id: 'je-1' }) + expect(body.data.rows[1]).toMatchObject({ status: 'unparsed', journal_entry_id: null }) + expect(body.data.summary).toMatchObject({ total: 2, matched: 1, unparsed: 1 }) + }) + + it('never returns a target the filename does not name', async () => { + const res = await POST(makeRequest({ file_names: ['A99.pdf'] }), emptyParams) + const { body } = await parseJsonResponse<{ data: UnderlagPlan }>(res) + + expect(body.data.rows[0].status).toBe('no_match') + expect(body.data.rows[0].journal_entry_id).toBeNull() + }) +}) diff --git a/app/api/import/documents/preview/route.ts b/app/api/import/documents/preview/route.ts new file mode 100644 index 00000000..e5e290a6 --- /dev/null +++ b/app/api/import/documents/preview/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { UnderlagImportPreviewSchema } from '@/lib/api/schemas' +import { buildUnderlagPlan } from '@/lib/documents/underlag-import' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' + +ensureInitialized() + +/** + * POST /api/import/documents/preview: plan which verifikat each underlag file + * belongs to, from the voucher reference in its filename. + * + * Read-only by construction. Only the NAMES are sent, so a user can review the + * whole plan (including the misses) before a single byte is uploaded, and + * before a single irreversible link is written. + * + * Scoped to the fiscal year the caller declares: `A31` identifies a verifikat + * only inside a year, because source systems restart numbering annually. + */ +export const POST = withRouteContext('import.documents.preview', async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const validation = await validateBody(request, UnderlagImportPreviewSchema) + if (!validation.success) return validation.response + const { file_names: fileNames, fiscal_period_id: fiscalPeriodId } = validation.data + + // The period must belong to this company: buildUnderlagPlan filters entries + // by it, and an unowned id would silently produce an all-misses plan rather + // than an honest error. + const { data: period, error: periodError } = await supabase + .from('fiscal_periods') + .select('id') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId!) + .maybeSingle() + + if (periodError) { + log.error('underlag import preview period lookup failed', periodError) + return errorResponse(periodError, log, { requestId }) + } + if (!period) { + return errorResponseFromCode('FISCAL_PERIOD_NOT_FOUND', log, { requestId }) + } + + try { + const plan = await buildUnderlagPlan(supabase, companyId!, fileNames, fiscalPeriodId) + return NextResponse.json({ data: plan }) + } catch (err) { + log.error('underlag import preview failed', err as Error, { fileCount: fileNames.length }) + return errorResponse(err, log, { requestId }) + } +}) diff --git a/components/common/FyPicker.tsx b/components/common/FyPicker.tsx index 952f1111..4f669402 100644 --- a/components/common/FyPicker.tsx +++ b/components/common/FyPicker.tsx @@ -30,6 +30,23 @@ interface FyPickerProps { * wrong there. Manual picks still work and are still persisted. */ preferLatestEnded?: boolean + /** + * Never auto-select on load, from ANY source: not the newest-period + * fallback, and not a selection persisted by an earlier session. The picker + * stays empty until the user chooses, every session. + * + * The default behaviour (restore or pick the newest) is right for a filter, + * where a sensible default beats an empty page. It is wrong where the year + * is an ASSERTION the user is making rather than a view they are narrowing: + * the underlag import resolves voucher references inside the chosen year and + * writes irreversible links. A pre-filled newest year would let a 2023 batch + * land in 2026, and a restored LAST-USED year is aimed even worse: in a + * multi-year migration the user is by definition moving to a year other than + * last time. Within one sitting the caller carries the choice in its own + * state (the wizard's reset() keeps it), which covers multi-batch runs + * without any cross-session hazard. + */ + requireExplicitChoice?: boolean /** Fires once after the initial period load completes. */ onReady?: () => void /** Server-loaded periods for the first render, scoped to initialCompanyId. */ @@ -65,6 +82,7 @@ export function FyPicker({ includeAllOption = true, hideFuturePeriods = false, preferLatestEnded = false, + requireExplicitChoice = false, onReady, initialPeriods, initialCompanyId, @@ -108,7 +126,13 @@ export function FyPicker({ // Restore last selection (same key as FiscalYearSelector so pages keep // their scope when the picker swaps in). - if (value === null && typeof window !== 'undefined') { + // + // requireExplicitChoice gates this WHOLE block, not individual branches: + // every path in here ends in an unprompted onChange (restore, the + // ALL_YEARS-stored fallback, newest-period, preferLatestEnded), and a + // per-branch gate already missed one of them once. Nothing auto-fires; + // the picker stays empty until a human picks. + if (value === null && !requireExplicitChoice && typeof window !== 'undefined') { if (preferLatestEnded) { // Filing surfaces: ignore the shared scope memory and open on the // most recently ended period (fetched is sorted newest-first). @@ -136,11 +160,15 @@ export function FyPicker({ // onReady is a lifecycle callback: fire once per load, not on parent // re-renders that re-create it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [company?.id, hideFuturePeriods, includeAllOption, preferLatestEnded, initialCompanyId, initialPeriods, storageKeyPrefix]) + }, [company?.id, hideFuturePeriods, includeAllOption, preferLatestEnded, requireExplicitChoice, initialCompanyId, initialPeriods, storageKeyPrefix]) const handleChange = (id: string) => { const nextId = id === ALL_YEARS_VALUE ? null : id - if (company?.id && typeof window !== 'undefined') { + // A per-batch assertion is never restored, so persisting it would be a + // write nothing reads. Worse than useless: this write happens BEFORE + // onChange, so a pick the caller rejects (e.g. mid-preview) would still + // be recorded as if it had taken effect. + if (!requireExplicitChoice && company?.id && typeof window !== 'undefined') { window.localStorage.setItem(storageKeyPrefix + company.id, nextId ?? ALL_YEARS_VALUE) } onChange(nextId, nextId ? periods.find((p) => p.id === nextId) ?? null : null) diff --git a/components/import/UnderlagImportWizard.tsx b/components/import/UnderlagImportWizard.tsx new file mode 100644 index 00000000..fae57072 --- /dev/null +++ b/components/import/UnderlagImportWizard.tsx @@ -0,0 +1,676 @@ +'use client' + +import { useCallback, useMemo, useRef, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Card, CardContent } from '@/components/ui/card' +import { Progress } from '@/components/ui/progress' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { AttnLine } from '@/components/ui/attn-line' +import { EmptyState } from '@/components/ui/empty-state' +import { TD_CLASS, TH_CLASS } from '@/components/ui/dry-table' +import { useToast } from '@/components/ui/use-toast' +import { + DestructiveConfirmDialog, + useDestructiveConfirm, +} from '@/components/ui/destructive-confirm-dialog' +import { FyPicker } from '@/components/common/FyPicker' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { cn, formatDate } from '@/lib/utils' +import { FileUp, Loader2 } from 'lucide-react' +import type { FiscalPeriod } from '@/types' +import type { + UnderlagPlan, + UnderlagPlanCandidate, + UnderlagPlanRow, + UnderlagPlanStatus, +} from '@/lib/documents/underlag-import' + +// UnderlagImportWizard +// +// Attaches a folder of receipt files to verifikat that a SIE import already +// created, by reading the source voucher reference out of each filename +// (`A31_.pdf`). Deliberately NOT a step inside the SIE wizard: the receipts +// usually arrive later, from a different export, and a migration must not be +// blocked on having them ready. +// +// The plan is built from filenames alone and shown in full before anything is +// uploaded. Attaching a document to a posted verifikat makes it +// räkenskapsinformation, which cannot be re-pointed afterwards (BFL 7 kap), so +// nothing is ever attached without the user seeing exactly where it lands. +// +// The fiscal year is chosen first and every row resolves inside it. A filename +// carries no year and source systems restart voucher numbering annually, so +// `A31` names a verifikat only within a year. The year is the one piece of the +// mapping the files cannot supply, which is why the user supplies it. + +type Step = 'select' | 'review' | 'result' + +const ACCEPTED_TYPES = 'application/pdf,image/jpeg,image/png,image/webp' + +/** Message keys per status, spelled out so next-intl keeps checking them. */ +const STATUS_KEY: Record = { + matched: 'underlag_status_matched', + needs_confirmation: 'underlag_status_needs_confirmation', + ambiguous: 'underlag_status_ambiguous', + period_locked: 'underlag_status_period_locked', + no_match: 'underlag_status_no_match', + unparsed: 'underlag_status_unparsed', +} + +type Translate = (key: string, values?: Record) => string + +interface ReviewRow extends UnderlagPlanRow { + /** Position in the batch: two folders can contribute the same filename. */ + id: string + file: File + selected: boolean + targetId: string | null + /** The user picked this target by hand, so the server skips the name check. */ + manual: boolean + /** Free-text reference the user typed for a row the filename could not resolve. */ + manualRef: string + resolving: boolean +} + +interface AttachOutcome { + file_name: string + ok: boolean + message?: string +} + +/** Statuses whose single resolved target is safe to pre-select. */ +function isPreselected(status: UnderlagPlanStatus): boolean { + return status === 'matched' +} + +function badgeVariant(status: UnderlagPlanStatus): 'secondary' | 'warning' | 'destructive' { + if (status === 'ambiguous' || status === 'needs_confirmation') return 'warning' + if (status === 'period_locked') return 'destructive' + return 'secondary' +} + +export default function UnderlagImportWizard() { + const t = useTranslations('import') + const { toast } = useToast() + const { dialogProps, confirm } = useDestructiveConfirm() + const fileInputRef = useRef(null) + + const [step, setStep] = useState('select') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [fiscalPeriodId, setFiscalPeriodId] = useState(null) + const [fiscalPeriod, setFiscalPeriod] = useState(null) + /** + * The year the CURRENT plan was resolved against, snapshotted when the plan + * was requested. Everything downstream (summary, confirm text, manual + * re-resolution, the attach requests) reads this, never the live picker: the + * picker can move while a preview of 2000 filenames is in flight, and a + * confirm dialog that reads back a different year than the plan was built + * from is worse than no confirm dialog at all. + */ + const [planPeriod, setPlanPeriod] = useState(null) + const [plan, setPlan] = useState(null) + const [rows, setRows] = useState([]) + const [attached, setAttached] = useState(0) + const [outcomes, setOutcomes] = useState([]) + + const steps: Step[] = ['select', 'review', 'result'] + const stepLabels: Record = { + select: t('underlag_step_select'), + review: t('underlag_step_review'), + result: t('underlag_step_result'), + } + const currentStepIndex = steps.indexOf(step) + const progress = ((currentStepIndex + 1) / steps.length) * 100 + + const selectedRows = useMemo( + () => rows.filter((row) => row.selected && row.targetId), + [rows], + ) + + /** + * Resolve filenames server-side. Only names travel: the bytes stay here. + * The year is an explicit argument rather than read from state, so a caller + * cannot accidentally resolve against a year the user has since changed. + */ + const fetchPlan = useCallback( + async (fileNames: string[], periodId: string): Promise => { + const res = await fetch('/api/import/documents/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_names: fileNames, fiscal_period_id: periodId }), + }) + const data = await res.json() + if (!res.ok) { + setError(getErrorMessage(data, { statusCode: res.status })) + return null + } + return data.data as UnderlagPlan + }, + [], + ) + + const handleFilesSelected = useCallback( + async (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) return + if (!fiscalPeriodId) return + const files = Array.from(fileList) + // Snapshot the year for this batch up front. The picker stays on screen + // while the request is in flight, so state read afterwards may not be + // the year the plan was built from. + const batchPeriodId = fiscalPeriodId + const batchPeriod = fiscalPeriod + + setError(null) + setIsLoading(true) + try { + const nextPlan = await fetchPlan( + files.map((f) => f.name), + batchPeriodId, + ) + if (!nextPlan) return + + setPlan(nextPlan) + setPlanPeriod(batchPeriod) + setRows( + nextPlan.rows.map((row, index) => ({ + ...row, + id: `${index}:${row.file_name}`, + file: files[index], + selected: isPreselected(row.status), + // A resolved-but-locked target stays unselectable: the DB trigger + // would refuse it, so offering the checkbox would only mislead. + targetId: row.status === 'period_locked' ? null : row.journal_entry_id, + manual: false, + manualRef: '', + resolving: false, + })), + ) + setStep('review') + } catch (err) { + setError(getErrorMessage(err)) + } finally { + setIsLoading(false) + } + }, + [fetchPlan, fiscalPeriod, fiscalPeriodId], + ) + + const updateRow = useCallback((id: string, patch: Partial) => { + setRows((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row))) + }, []) + + /** + * Resolve a reference the user typed for a row whose filename said nothing. + * Goes through the same resolver as the automatic path: the user supplies the + * verifikat reference, never a free-choice target. + */ + const resolveManualRef = useCallback( + async (row: ReviewRow) => { + const ref = row.manualRef.trim() + if (!ref || !plan) return + + updateRow(row.id, { resolving: true }) + try { + // Resolve inside the year THIS PLAN was built against, straight from + // the server's own echo. Reading live state here would let a row join + // the batch from a different year than every other row in it. + const refPlan = await fetchPlan([ref], plan.fiscal_period_id) + const resolved = refPlan?.rows[0] + const candidates = resolved?.candidates ?? [] + + if (candidates.length === 0) { + toast({ + title: t('underlag_manual_not_found_title'), + description: t('underlag_manual_not_found_body', { ref }), + variant: 'destructive', + }) + updateRow(row.id, { resolving: false }) + return + } + + const single = candidates.length === 1 ? candidates[0] : null + updateRow(row.id, { + candidates, + resolving: false, + // Hand-resolved: the filename itself still says nothing, so the + // server cannot re-derive this target and the row carries an override. + manual: true, + targetId: single && !single.period_locked ? single.journal_entry_id : null, + selected: Boolean(single && !single.period_locked), + // Without this the row keeps rendering "Kan inte tolkas" while + // sitting checked and queued for an irreversible write. + status: single + ? single.period_locked + ? 'period_locked' + : 'needs_confirmation' + : 'ambiguous', + }) + } catch (err) { + updateRow(row.id, { resolving: false }) + toast({ title: t('underlag_manual_not_found_title'), description: getErrorMessage(err) }) + } + }, + [fetchPlan, plan, t, toast, updateRow], + ) + + const runAttach = useCallback(async () => { + if (!plan) return + // The year is named in the confirm text on purpose: it is the one input + // the files cannot corroborate, so it is the one worth reading back. + const ok = await confirm({ + title: t('underlag_confirm_title'), + description: t('underlag_confirm_body', { + count: selectedRows.length, + year: planPeriod?.name ?? '', + }), + confirmLabel: t('underlag_confirm_action'), + variant: 'warning', + }) + if (!ok) return + + setIsLoading(true) + setAttached(0) + const results: AttachOutcome[] = [] + + try { + // Sequential on purpose: hundreds of uploads in parallel would swamp the + // browser and the storage bucket, and a visible one-by-one count is what + // makes a long migration legible. + for (const row of selectedRows) { + const formData = new FormData() + formData.append('file', row.file) + formData.append('journal_entry_id', row.targetId as string) + // The year the plan was built against, echoed from the server. The + // route refuses any target outside it, overrides included. + formData.append('fiscal_period_id', plan.fiscal_period_id) + if (row.manual) formData.append('override', 'true') + + try { + const res = await fetch('/api/import/documents/attach', { + method: 'POST', + body: formData, + }) + if (!res.ok) { + const data = await res.json().catch(() => null) + results.push({ + file_name: row.file_name, + ok: false, + message: getErrorMessage(data, { statusCode: res.status }), + }) + } else { + results.push({ file_name: row.file_name, ok: true }) + } + } catch (err) { + results.push({ file_name: row.file_name, ok: false, message: getErrorMessage(err) }) + } + + setAttached((n) => n + 1) + } + } finally { + // Whatever happens above, the wizard must not stay stuck "loading": + // that state also freezes the year picker. + setIsLoading(false) + } + + setOutcomes(results) + setStep('result') + + const failed = results.filter((r) => !r.ok).length + toast({ + title: t('underlag_done_title'), + description: t('underlag_done_body', { + linked: results.length - failed, + failed, + }), + variant: failed > 0 ? 'destructive' : 'default', + }) + }, [confirm, plan, planPeriod, selectedRows, t, toast]) + + // The fiscal year survives a reset but never a session: within one sitting + // a migration is several batches from the same year's export, so in-state + // carry-over is the convenience. Cross-session persistence is the hazard + // (last-used is the wrong default for a user moving year by year), which is + // why the picker neither restores nor writes localStorage on this surface. + const reset = () => { + setStep('select') + setPlan(null) + setPlanPeriod(null) + setRows([]) + setOutcomes([]) + setAttached(0) + setError(null) + if (fileInputRef.current) fileInputRef.current.value = '' + } + + return ( +
+ + +
+
+ + {t('underlag_step_counter', { + current: currentStepIndex + 1, + total: steps.length, + label: stepLabels[step], + })} + + {steps.map((s, i) => ( + + {stepLabels[s]} + + ))} +
+ +
+
+
+ + {error && {error}} + + {step === 'select' && ( + + +
+

{t('underlag_intro')}

+

{t('underlag_intro_formats')}

+
+ +
+

{t('underlag_year_label')}

+ { + // Ignored while a preview is in flight: that request already + // captured a year and the plan must not disagree with the + // control the user is looking at. + if (isLoading) return + setFiscalPeriodId(id) + setFiscalPeriod(period ?? null) + }} + includeAllOption={false} + // The year is the user's assertion, so it must be the user who + // makes it, EVERY session. Defaulting to the newest year lets + // a 2023 batch resolve against 2026; restoring last-used is + // aimed even worse, since a multi-year migration by definition + // moves to a different year each round. Within one sitting, + // reset() carries the choice across batches; nothing else does. + requireExplicitChoice + className={isLoading ? 'pointer-events-none opacity-60' : undefined} + /> +

+ {t('underlag_year_help')} +

+
+ + handleFilesSelected(e.target.files)} + /> + + + + {/* Both the picker and the button are disabled until a year is + chosen, and if the company has no fiscal years at all the + picker never becomes usable. Say why rather than leave two + dead controls on screen. */} + {!fiscalPeriodId && ( +

+ {t('underlag_year_required')} +

+ )} +
+
+ )} + + {step === 'review' && plan && ( +
+ {plan.no_source_refs && {t('underlag_no_source_refs')}} + {!plan.no_source_refs && plan.summary.period_locked > 0 && ( + + {t('underlag_locked_warning', { count: plan.summary.period_locked })} + + )} + +

+ {t('underlag_summary', { + matched: plan.summary.matched, + total: plan.summary.total, + year: planPeriod?.name ?? '', + })} +

+ +
+ + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
+ {t('underlag_col_include')} + {t('underlag_col_file')}{t('underlag_col_ref')}{t('underlag_col_target')}
+ + updateRow(row.id, { selected: e.target.checked }) + } + /> + + {row.file_name} + + {row.parsed_ref + ? `${row.parsed_ref.series ?? ''}${row.parsed_ref.number}` + : {t('underlag_ref_none')}} + + + updateRow(row.id, { + targetId: candidate.journal_entry_id, + selected: !candidate.period_locked, + // Deliberately NOT `manual`: the server proposed + // this candidate itself, so it can re-derive it. + // Flagging it would switch the filename check off + // on exactly the rows it exists to protect. + }) + } + onManualRefChange={(value) => + updateRow(row.id, { manualRef: value }) + } + onManualRefSubmit={() => resolveManualRef(row)} + t={t} + /> +
+
+ +
+ + +
+
+ )} + + {step === 'result' && ( + + +

+ {t('underlag_done_body', { + linked: outcomes.filter((o) => o.ok).length, + failed: outcomes.filter((o) => !o.ok).length, + })} +

+ + {outcomes.some((o) => !o.ok) ? ( +
+ + + + + + + + + {outcomes + .filter((o) => !o.ok) + .map((o, index) => ( + + + + + ))} + +
{t('underlag_col_file')}{t('underlag_col_error')}
{o.file_name}{o.message}
+
+ ) : ( + + )} + + +
+
+ )} + + +
+ ) +} + +function TargetCell({ + row, + onPick, + onManualRefChange, + onManualRefSubmit, + t, +}: { + row: ReviewRow + onPick: (candidate: UnderlagPlanCandidate) => void + onManualRefChange: (value: string) => void + onManualRefSubmit: () => void + t: Translate +}) { + // A single candidate is shown even when it is not selectable (locked period): + // the user needs to see WHICH verifikat the file wanted before deciding + // whether to unlock the year. + if (row.candidates.length === 1) { + const only = row.candidates[0] + return ( +
+ {only.voucher_label} + {formatDate(only.entry_date)} + {row.status !== 'matched' && ( + + {t(STATUS_KEY[row.status])} + + )} +
+ ) + } + + if (row.candidates.length > 1) { + return ( +
+ + + {t('underlag_status_ambiguous')} + +
+ ) + } + + return ( +
+ onManualRefChange(e.target.value)} + onBlur={onManualRefSubmit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + onManualRefSubmit() + } + }} + /> + {row.resolving ? ( + + ) : ( + + {t(STATUS_KEY[row.status])} + + )} +
+ ) +} diff --git a/extensions/general/arcim-migration/__tests__/import-documents.test.ts b/extensions/general/arcim-migration/__tests__/import-documents.test.ts index 47a95133..563b6568 100644 --- a/extensions/general/arcim-migration/__tests__/import-documents.test.ts +++ b/extensions/general/arcim-migration/__tests__/import-documents.test.ts @@ -65,6 +65,7 @@ function rangeMockSupabase(byTable: Record): SupabaseClient { select: () => node, eq: () => node, not: () => node, + in: () => node, order: () => node, range: () => Promise.resolve({ data: byTable[table] ?? [], error: null }), } diff --git a/extensions/general/arcim-migration/lib/import-documents.ts b/extensions/general/arcim-migration/lib/import-documents.ts index b891d8eb..b4cef44f 100644 --- a/extensions/general/arcim-migration/lib/import-documents.ts +++ b/extensions/general/arcim-migration/lib/import-documents.ts @@ -47,6 +47,12 @@ import { detectFileMagic, ALLOWED_DOCUMENT_TYPES, } from '@/lib/core/documents/document-service' +import { + buildVoucherIndex, + fetchFiscalPeriods, + fetchSourceRefVouchers, + resolveDatedRef, +} from '@/lib/documents/voucher-ref-resolver' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { createLogger } from '@/lib/logger' @@ -87,20 +93,6 @@ export interface ImportDocumentsResult { unmatchedSamples: { uploadId: string; voucher: string; date: string }[] } -interface FiscalPeriodRow { - id: string - period_start: string - period_end: string -} - -interface VoucherRow { - id: string - fiscal_period_id: string - entry_date: string - source_voucher_series: string | null - source_voucher_number: number | null -} - interface ProviderAttachment { id: string fileName: string | null @@ -121,20 +113,6 @@ const EXTENSION_BY_TYPE: Record = { 'image/webp': 'webp', } -/** Find the fiscal period whose date range contains a given date. */ -function periodIdForDate(periods: FiscalPeriodRow[], date: string): string | null { - const period = periods.find((p) => p.period_start <= date && date <= p.period_end) - return period?.id ?? null -} - -/** - * In-memory key for a verifikat: fiscal period + series + number. Scoping by - * period is essential: providers may reuse voucher numbers across fiscal years. - */ -function voucherKey(periodId: string, series: string, number: number): string { - return `${periodId}|${series}|${number}` -} - /** Remove path/control characters while retaining a readable archive name. */ function sanitizeProviderFileName(fileName: string): string { return ( @@ -283,28 +261,11 @@ export async function importProviderDocuments( // ── Bulk reads (one round of paged requests each, no per-item N+1) ── const [attachments, periods, vouchers, existingAttachments] = await Promise.all([ source().list(), + fetchFiscalPeriods(supabase, companyId), + fetchSourceRefVouchers(supabase, companyId), // A stable `.order('id')` is required: fetchAllRows pages with `.range()`, // and PostgREST paging without a deterministic order can skip or repeat - // rows once a table exceeds one page (journal_entries crosses 1000 once - // several years are migrated), which would defeat both resolution and the - // hash dedup below. - fetchAllRows(({ from, to }) => - supabase - .from('fiscal_periods') - .select('id, period_start, period_end') - .eq('company_id', companyId) - .order('id', { ascending: true }) - .range(from, to), - ), - fetchAllRows(({ from, to }) => - supabase - .from('journal_entries') - .select('id, fiscal_period_id, entry_date, source_voucher_series, source_voucher_number') - .eq('company_id', companyId) - .not('source_voucher_number', 'is', null) - .order('id', { ascending: true }) - .range(from, to), - ), + // rows once a table exceeds one page, which would defeat the hash dedup. fetchAllRows<{ sha256_hash: string; journal_entry_id: string | null }>(({ from, to }) => supabase .from('document_attachments') @@ -316,24 +277,7 @@ export async function importProviderDocuments( ]) // Index gnubok verifikat by (period, series, number) for in-memory resolution. - const journalEntryByKey = new Map() - const journalEntriesBySourceRef = new Map() - const ambiguousVoucherKeys = new Set() - for (const v of vouchers) { - if (v.source_voucher_series == null || v.source_voucher_number == null) continue - const sourceRef = `${v.source_voucher_series}|${v.source_voucher_number}` - journalEntriesBySourceRef.set(sourceRef, [ - ...(journalEntriesBySourceRef.get(sourceRef) ?? []), - v, - ]) - const key = voucherKey(v.fiscal_period_id, v.source_voucher_series, v.source_voucher_number) - if (journalEntryByKey.has(key)) { - journalEntryByKey.delete(key) - ambiguousVoucherKeys.add(key) - } else if (!ambiguousVoucherKeys.has(key)) { - journalEntryByKey.set(key, v.id) - } - } + const voucherIndex = buildVoucherIndex(vouchers) // (content, verifikat) pairs already archived → idempotent skip set. Keyed // on hash + journal entry, NOT hash alone: the same content may back @@ -363,17 +307,7 @@ export async function importProviderDocuments( continue } - let journalEntryId: string | undefined - if (ref.dateTo) { - const candidates = (journalEntriesBySourceRef.get(`${ref.series}|${ref.number}`) ?? []) - .filter((voucher) => ref.date <= voucher.entry_date && voucher.entry_date <= ref.dateTo!) - journalEntryId = candidates.length === 1 ? candidates[0].id : undefined - } else { - const periodId = periodIdForDate(periods, ref.date) - journalEntryId = periodId - ? journalEntryByKey.get(voucherKey(periodId, ref.series, ref.number)) - : undefined - } + const journalEntryId = resolveDatedRef(voucherIndex, periods, ref) if (!journalEntryId) { recordUnmatched(attachment.id, `${ref.series}${ref.number}`, ref.date) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 67c212de..76776418 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -3191,6 +3191,20 @@ export const LinkDocumentSchema = z.object({ transaction_id: uuid.optional(), }) +/** + * Underlag import preview: filenames only, never file contents. The plan is + * built from the voucher reference in each name, so the bytes stay in the + * browser until the user has approved where each file will land. + * + * `fiscal_period_id` is required, not optional: a filename carries no year and + * source systems restart voucher numbering annually, so a plan with no declared + * year cannot identify a verifikat at all. + */ +export const UnderlagImportPreviewSchema = z.object({ + file_names: z.array(z.string().min(1).max(400)).min(1).max(2000), + fiscal_period_id: uuid, +}) + // ============================================================ // Shift-premium rules (OB-tillägg och övertid) // ============================================================ diff --git a/lib/documents/__tests__/filename-voucher-ref.test.ts b/lib/documents/__tests__/filename-voucher-ref.test.ts new file mode 100644 index 00000000..922f127d --- /dev/null +++ b/lib/documents/__tests__/filename-voucher-ref.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from 'vitest' +import { parseVoucherRefFromFileName } from '@/lib/documents/filename-voucher-ref' + +describe('parseVoucherRefFromFileName', () => { + it('parses the SpeedLedger prefix form (series + number + internal id)', () => { + expect(parseVoucherRefFromFileName('A31_8c2db060-79ba-4b6e-9f3d-4b0042aa5c52.pdf')).toEqual({ + series: 'A', + number: 31, + pattern: 'series_number', + autoSelectable: true, + }) + }) + + it('parses a bare series + number filename', () => { + expect(parseVoucherRefFromFileName('V123.pdf')).toMatchObject({ series: 'V', number: 123 }) + }) + + it.each([ + ['A-31 kvitto.pdf', 'A', 31], + ['A_31.jpg', 'A', 31], + ['A 31 leverantorsfaktura.png', 'A', 31], + ['2024-A-31.pdf', 'A', 31], + ['2024_A31_underlag.pdf', 'A', 31], + ['ver_A31.pdf', 'A', 31], + ['Verifikat A31.pdf', 'A', 31], + ['BC7.pdf', 'BC', 7], + ])('parses %s', (fileName, series, number) => { + expect(parseVoucherRefFromFileName(fileName)).toMatchObject({ series, number }) + }) + + it('uppercases the series so a lowercase export still joins', () => { + expect(parseVoucherRefFromFileName('a31_x.pdf')).toMatchObject({ series: 'A' }) + }) + + it.each([ + // Paper sizes: every scanner emits an A4.pdf. + 'A4.pdf', + 'A4 scan.pdf', + 'a4.pdf', + 'A3 ritning.pdf', + // A batch scanner's zero-padded counter normalizes onto the same refs. + 'A0004.pdf', + 'A001.pdf', + // Skatteverket blanketter and quarters. + 'K10.pdf', + 'K10 blankett 2024.pdf', + 'K4.pdf', + 'N9.pdf', + 'Q1 2024.pdf', + ])('parses %s but never pre-selects it: more often a document name than a ref', (fileName) => { + const parsed = parseVoucherRefFromFileName(fileName) + expect(parsed).not.toBeNull() + expect(parsed?.autoSelectable).toBe(false) + }) + + it.each(['IMG_0031.jpg', 'DSC00123.JPG', 'DOC001.pdf', 'SCN0007.pdf', 'Del 1 av 3.pdf'])( + 'parses %s but never pre-selects a three-letter series: cameras, not ledgers', + (fileName) => { + const parsed = parseVoucherRefFromFileName(fileName) + expect(parsed).not.toBeNull() + expect(parsed?.autoSelectable).toBe(false) + }, + ) + + it.each([ + ['A7.pdf', 'A', 7], + ['A31.pdf', 'A', 31], + ['K1.pdf', 'K', 1], + ['K14.pdf', 'K', 14], + ['LB2.pdf', 'LB', 2], + ])('keeps %s auto-selectable: just outside the collision list', (fileName, series, number) => { + expect(parseVoucherRefFromFileName(fileName)).toEqual({ + series, + number, + pattern: 'series_number', + autoSelectable: true, + }) + }) + + it.each([ + 'underlag/2024/A31_kvitto.pdf', + 'underlag\\A31.pdf', + // The manual-reference box feeds arbitrary typed text through this same + // parser. Splitting on the separator would turn a typed date into a + // voucher number and hand the user an irreversible link to approve. + '2024/01/31 kvitto.pdf', + '2024/01/31', + ])('does not strip a path component out of %s', (input) => { + expect(parseVoucherRefFromFileName(input)).toBeNull() + }) + + it.each([ + ['Verifikation 31.pdf', 31], + ['verifikation31.pdf', 31], + ['Verifikat 31.pdf', 31], + // `ver` is a prefix word, not a series: without that rule this one form + // came back auto-selectable while every spelled-out variant did not. + ['ver 31.pdf', 31], + ['ver31.pdf', 31], + ['VER-31.pdf', 31], + ['ver.31.pdf', 31], + ])('reads %s as a series-less reference, not a bogus series', (fileName, number) => { + expect(parseVoucherRefFromFileName(fileName)).toEqual({ + series: null, + number, + pattern: 'number_only', + autoSelectable: false, + }) + }) + + it('returns a series-less parse for a number-only name, never auto-selectable', () => { + expect(parseVoucherRefFromFileName('31.pdf')).toEqual({ + series: null, + number: 31, + pattern: 'number_only', + autoSelectable: false, + }) + expect(parseVoucherRefFromFileName('31_kvitto.pdf')).toMatchObject({ series: null, number: 31 }) + }) + + it.each([ + '20240131.pdf', + '20240131_kvitto.pdf', + '2024-01-31 kvitto.pdf', + '2024_01_31.pdf', + // Unpadded components, two-digit years and space separators are just as + // common in receipt exports and used to slip through as voucher 2024 / 24. + '2024-1-31 kvitto.pdf', + '2024_1_31.pdf', + '2024.1.31.pdf', + '2024 01 31 kvitto.pdf', + '24-01-31 kvitto.pdf', + '2024/01/31.pdf', + // Day-first and US order: the day would otherwise become a voucher number + // that always exists in the year. + '31.01.2024.pdf', + '31-01-2024.pdf', + '31_01_2024.pdf', + '31.1.2024.pdf', + '24.12.2024 julbord.pdf', + '03.04.2025 ICA.pdf', + '12.24.2024.pdf', + '01-31-2024.pdf', + '1-31-2024 receipt.pdf', + '31/1/2024.pdf', + ])('refuses the date-named file %s rather than reading it as a number', (fileName) => { + expect(parseVoucherRefFromFileName(fileName)).toBeNull() + }) + + it.each(['2024.pdf', '2024_kvitto.pdf', '1999.pdf'])( + 'refuses the year-shaped series-less name %s', + (fileName) => { + expect(parseVoucherRefFromFileName(fileName)).toBeNull() + }, + ) + + it.each([ + 'kvitto.pdf', + 'Faktura2024.pdf', + 'A31kvitto.pdf', + 'Version2_kvitto.pdf', + '', + '.pdf', + ])('returns null for %s instead of guessing', (fileName) => { + expect(parseVoucherRefFromFileName(fileName)).toBeNull() + }) + + it('rejects a zero voucher number', () => { + expect(parseVoucherRefFromFileName('A0.pdf')).toBeNull() + expect(parseVoucherRefFromFileName('0.pdf')).toBeNull() + }) + + it('handles a filename with no extension at all', () => { + expect(parseVoucherRefFromFileName('A31_8c2db060-79ba-4b6e-9f3d-4b0042aa5c52')).toMatchObject({ + series: 'A', + number: 31, + }) + }) +}) diff --git a/lib/documents/__tests__/underlag-import.test.ts b/lib/documents/__tests__/underlag-import.test.ts new file mode 100644 index 00000000..d520996e --- /dev/null +++ b/lib/documents/__tests__/underlag-import.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { buildUnderlagPlan, planPermitsAttach } from '@/lib/documents/underlag-import' +import type { FiscalPeriodRow, VoucherRow } from '@/lib/documents/voucher-ref-resolver' + +const PERIOD_OPEN = 'period-open' +const PERIOD_LOCKED = 'period-locked' + +const PERIODS: FiscalPeriodRow[] = [ + { + id: PERIOD_OPEN, + period_start: '2024-01-01', + period_end: '2024-12-31', + is_closed: false, + locked_at: null, + }, + { + id: PERIOD_LOCKED, + period_start: '2023-01-01', + period_end: '2023-12-31', + is_closed: true, + locked_at: null, + }, +] + +function makeVoucher(overrides: Partial & Pick): VoucherRow { + return { + fiscal_period_id: PERIOD_OPEN, + entry_date: '2024-03-14', + description: 'Inköp kontorsmaterial', + voucher_series: 'A', + voucher_number: 47, + source_voucher_series: 'A', + source_voucher_number: 31, + ...overrides, + } +} + +/** + * Minimal Supabase double keyed on the query SHAPE rather than call order: + * buildUnderlagPlan fires the voucher and period reads concurrently, so an + * order-sensitive queue would make these tests flaky for no benefit. + */ +function makeSupabase(opts: { + vouchers: VoucherRow[] + periods?: FiscalPeriodRow[] + /** Total migrated entries in the company, regardless of the number filter. */ + sourceRefCount?: number +}): SupabaseClient { + const periods = opts.periods ?? PERIODS + + const from = (table: string) => { + let filteredByNumber = false + + const result = () => { + if (table === 'fiscal_periods') return { data: periods, error: null, count: periods.length } + // Only the number-filtered read returns the voucher rows; the bare + // count read answers "does this ledger have ANY source refs at all". + return { + data: filteredByNumber ? opts.vouchers : [], + error: null, + count: opts.sourceRefCount ?? opts.vouchers.length, + } + } + + const chain: Record = {} + for (const method of ['select', 'eq', 'not', 'order', 'limit', 'maybeSingle', 'single']) { + chain[method] = () => chain + } + chain.in = () => { + filteredByNumber = true + return chain + } + chain.range = () => Promise.resolve(result()) + chain.then = (onFulfilled: (value: unknown) => unknown) => + Promise.resolve(result()).then(onFulfilled) + return chain + } + + return { from } as unknown as SupabaseClient +} + +describe('buildUnderlagPlan', () => { + it('matches a filename prefix to the migrated verifikat it names', async () => { + const supabase = makeSupabase({ vouchers: [makeVoucher({ id: 'je-1' })] }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', [ + 'A31_8c2db060-79ba-4b6e-9f3d-4b0042aa5c52.pdf', + ], PERIOD_OPEN) + + expect(plan.rows[0]).toMatchObject({ + file_name: 'A31_8c2db060-79ba-4b6e-9f3d-4b0042aa5c52.pdf', + status: 'matched', + parsed_ref: { series: 'A', number: 31 }, + journal_entry_id: 'je-1', + }) + expect(plan.rows[0].candidates[0]).toMatchObject({ + voucher_label: 'A47', + source_voucher_label: 'A31', + period_locked: false, + }) + expect(plan.summary).toMatchObject({ total: 1, matched: 1 }) + }) + + it('matches on the SOURCE number, not our renumbered one', async () => { + // The import renumbered source A31 to A47. A file named after our own + // number must NOT match: A47 does not exist in the source system. + const supabase = makeSupabase({ vouchers: [makeVoucher({ id: 'je-1' })] }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['A47_kvitto.pdf'], PERIOD_OPEN) + + expect(plan.rows[0].status).toBe('no_match') + }) + + it('reports a locked period instead of proposing a link the DB will refuse', async () => { + const supabase = makeSupabase({ + vouchers: [makeVoucher({ id: 'je-1', fiscal_period_id: PERIOD_LOCKED })], + }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['A31.pdf'], PERIOD_LOCKED) + + expect(plan.rows[0]).toMatchObject({ status: 'period_locked', journal_entry_id: 'je-1' }) + expect(plan.rows[0].candidates[0].period_locked).toBe(true) + expect(plan.summary.period_locked).toBe(1) + }) + + it('treats a period with locked_at set as locked even when not closed', async () => { + const supabase = makeSupabase({ + vouchers: [makeVoucher({ id: 'je-1' })], + periods: [{ ...PERIODS[0], locked_at: '2025-01-31T00:00:00Z' }], + }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['A31.pdf'], PERIOD_OPEN) + + expect(plan.rows[0].status).toBe('period_locked') + }) + + it('resolves ONLY inside the declared year when a ref exists in several', async () => { + const supabase = makeSupabase({ + vouchers: [ + makeVoucher({ id: 'je-2023', fiscal_period_id: PERIOD_LOCKED, entry_date: '2023-03-14' }), + makeVoucher({ id: 'je-2024' }), + ], + }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['A31.pdf'], PERIOD_OPEN) + + expect(plan.rows[0].status).toBe('matched') + expect(plan.rows[0].journal_entry_id).toBe('je-2024') + // The other year's A31 is not even offered as a candidate. + expect(plan.rows[0].candidates.map((c) => c.journal_entry_id)).toEqual(['je-2024']) + }) + + it('NEVER proposes a verifikat outside the declared year, even as the only one', async () => { + // The defect this scoping exists for: a partial migration, or a year whose + // A31 the importer skipped, leaves exactly one A31 in the whole ledger. + // Treating that single hit as identity attached a 2023 receipt to a 2025 + // verifikat, permanently and undetectably. + const supabase = makeSupabase({ + vouchers: [makeVoucher({ id: 'je-other-year', fiscal_period_id: PERIOD_LOCKED })], + }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['A31.pdf'], PERIOD_OPEN) + + expect(plan.rows[0].status).toBe('no_match') + expect(plan.rows[0].journal_entry_id).toBeNull() + expect(plan.rows[0].candidates).toEqual([]) + }) + + it('still hands back a choice when one ref repeats INSIDE the declared year', async () => { + const supabase = makeSupabase({ + vouchers: [ + makeVoucher({ id: 'je-a' }), + makeVoucher({ id: 'je-b', entry_date: '2024-09-02' }), + ], + }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['A31.pdf'], PERIOD_OPEN) + + expect(plan.rows[0].status).toBe('ambiguous') + expect(plan.rows[0].journal_entry_id).toBeNull() + expect(plan.rows[0].candidates.map((c) => c.journal_entry_id)).toEqual(['je-a', 'je-b']) + expect(plan.summary.ambiguous).toBe(1) + }) + + it('reports which year the plan was resolved against', async () => { + const supabase = makeSupabase({ vouchers: [makeVoucher({ id: 'je-1' })] }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['A31.pdf'], PERIOD_OPEN) + + expect(plan.fiscal_period_id).toBe(PERIOD_OPEN) + }) + + it('never auto-selects a collision-list ref, even on a clean single hit', async () => { + // "A4.pdf" is a scanner's paper size far more often than verifikat A4, + // and verifikat A4 exists in every migrated ledger, so the collision is + // guaranteed. The parse survives, the pre-tick does not. + const supabase = makeSupabase({ + vouchers: [makeVoucher({ id: 'je-a4', source_voucher_number: 4, voucher_number: 4 })], + }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['A4 scan.pdf'], PERIOD_OPEN) + + expect(plan.rows[0]).toMatchObject({ + status: 'needs_confirmation', + parsed_ref: { series: 'A', number: 4 }, + journal_entry_id: 'je-a4', + }) + }) + + it('never auto-selects a series-less filename, even on a single hit', async () => { + const supabase = makeSupabase({ vouchers: [makeVoucher({ id: 'je-1' })] }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['31.pdf'], PERIOD_OPEN) + + expect(plan.rows[0]).toMatchObject({ + status: 'needs_confirmation', + parsed_ref: { series: null, number: 31 }, + journal_entry_id: 'je-1', + }) + expect(plan.summary.needs_confirmation).toBe(1) + }) + + it('reports an unreadable filename as unparsed without touching the ledger', async () => { + const supabase = makeSupabase({ vouchers: [makeVoucher({ id: 'je-1' })] }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['kvitto ica.pdf'], PERIOD_OPEN) + + expect(plan.rows[0]).toMatchObject({ + status: 'unparsed', + parsed_ref: null, + journal_entry_id: null, + candidates: [], + }) + expect(plan.summary.unparsed).toBe(1) + }) + + it('flags a ledger with no source refs at all, so the miss is explained', async () => { + const supabase = makeSupabase({ vouchers: [], sourceRefCount: 0 }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['A31.pdf'], PERIOD_OPEN) + + expect(plan.rows[0].status).toBe('no_match') + expect(plan.no_source_refs).toBe(true) + }) + + it('does not blame missing source refs when the ledger has them', async () => { + const supabase = makeSupabase({ vouchers: [], sourceRefCount: 120 }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['A31.pdf'], PERIOD_OPEN) + + expect(plan.rows[0].status).toBe('no_match') + expect(plan.no_source_refs).toBe(false) + }) + + it('skips the diagnostic round-trip once anything matched', async () => { + const supabase = makeSupabase({ vouchers: [makeVoucher({ id: 'je-1' })], sourceRefCount: 0 }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', ['A31.pdf', 'kvitto.pdf'], PERIOD_OPEN) + + expect(plan.no_source_refs).toBe(false) + }) + + it('counts a mixed batch correctly', async () => { + const supabase = makeSupabase({ vouchers: [makeVoucher({ id: 'je-1' })] }) + + const plan = await buildUnderlagPlan(supabase, 'company-1', [ + 'A31_a.pdf', + 'A31_b.pdf', + 'A99.pdf', + 'kvitto.pdf', + ], PERIOD_OPEN) + + expect(plan.summary).toEqual({ + total: 4, + matched: 2, + needs_confirmation: 0, + ambiguous: 0, + period_locked: 0, + no_match: 1, + unparsed: 1, + }) + }) +}) + +describe('planPermitsAttach', () => { + it('override permits an unresolvable filename onto any same-year target', async () => { + const supabase = makeSupabase({ vouchers: [] }) + + await expect( + planPermitsAttach(supabase, 'company-1', 'kvitto ica.pdf', 'je-1', PERIOD_OPEN, true), + ).resolves.toBe(true) + // A parsed ref with no candidate in the year is unresolvable too. + await expect( + planPermitsAttach(supabase, 'company-1', 'A99.pdf', 'je-1', PERIOD_OPEN, true), + ).resolves.toBe(true) + }) + + it('override does NOT permit a resolvable filename onto a different target', async () => { + // The hole this closes: a lying client could set override=true and scatter + // cleanly-named underlag across arbitrary same-year verifikat. + const supabase = makeSupabase({ vouchers: [makeVoucher({ id: 'je-1' })] }) + + await expect( + planPermitsAttach(supabase, 'company-1', 'A31.pdf', 'je-other', PERIOD_OPEN, true), + ).resolves.toBe(false) + // The target the filename actually points at stays permitted, of course. + await expect( + planPermitsAttach(supabase, 'company-1', 'A31.pdf', 'je-1', PERIOD_OPEN, true), + ).resolves.toBe(true) + }) + + it('accepts the entry the filename resolves to', async () => { + const supabase = makeSupabase({ vouchers: [makeVoucher({ id: 'je-1' })] }) + + await expect(planPermitsAttach(supabase, 'company-1', 'A31.pdf', 'je-1', PERIOD_OPEN, false)).resolves.toBe(true) + }) + + it('accepts any candidate of an ambiguous filename: the user picked one', async () => { + const supabase = makeSupabase({ + vouchers: [makeVoucher({ id: 'je-a' }), makeVoucher({ id: 'je-b' })], + }) + + await expect( + planPermitsAttach(supabase, 'company-1', 'A31.pdf', 'je-b', PERIOD_OPEN, false), + ).resolves.toBe(true) + }) + + it('refuses a target in another fiscal year even when the ref matches', async () => { + const supabase = makeSupabase({ + vouchers: [makeVoucher({ id: 'je-other-year', fiscal_period_id: PERIOD_LOCKED })], + }) + + await expect( + planPermitsAttach(supabase, 'company-1', 'A31.pdf', 'je-other-year', PERIOD_OPEN, false), + ).resolves.toBe(false) + }) + + it('refuses an entry the filename does not point at', async () => { + const supabase = makeSupabase({ vouchers: [makeVoucher({ id: 'je-1' })] }) + + await expect(planPermitsAttach(supabase, 'company-1', 'A31.pdf', 'je-other', PERIOD_OPEN, false)).resolves.toBe( + false, + ) + }) + + it('refuses an unreadable filename outright', async () => { + const supabase = makeSupabase({ vouchers: [makeVoucher({ id: 'je-1' })] }) + + await expect(planPermitsAttach(supabase, 'company-1', 'kvitto.pdf', 'je-1', PERIOD_OPEN, false)).resolves.toBe( + false, + ) + }) +}) diff --git a/lib/documents/__tests__/voucher-ref-resolver.test.ts b/lib/documents/__tests__/voucher-ref-resolver.test.ts new file mode 100644 index 00000000..e4564183 --- /dev/null +++ b/lib/documents/__tests__/voucher-ref-resolver.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from 'vitest' +import { + buildVoucherIndex, + candidatesForNumber, + candidatesForRef, + periodIdForDate, + resolveDatedRef, + sourceVoucherLabel, + voucherLabel, + type FiscalPeriodRow, + type VoucherRow, +} from '@/lib/documents/voucher-ref-resolver' + +const PERIOD_2024 = 'period-2024' +const PERIOD_2025 = 'period-2025' + +const periods: FiscalPeriodRow[] = [ + { + id: PERIOD_2024, + period_start: '2024-01-01', + period_end: '2024-12-31', + is_closed: false, + locked_at: null, + }, + { + id: PERIOD_2025, + period_start: '2025-01-01', + period_end: '2025-12-31', + is_closed: false, + locked_at: null, + }, +] + +function makeVoucher(overrides: Partial & Pick): VoucherRow { + return { + fiscal_period_id: PERIOD_2024, + entry_date: '2024-03-14', + description: 'Import: A31', + voucher_series: 'A', + voucher_number: 47, + source_voucher_series: 'A', + source_voucher_number: 31, + ...overrides, + } +} + +describe('buildVoucherIndex', () => { + it('indexes an entry by both its period key and its source ref', () => { + const index = buildVoucherIndex([makeVoucher({ id: 'je-1' })]) + + expect(index.byPeriodKey.get(`${PERIOD_2024}|A|31`)).toBe('je-1') + expect(candidatesForRef(index, { series: 'A', number: 31 })).toHaveLength(1) + expect(index.ambiguousPeriodKeys.size).toBe(0) + }) + + it('skips entries with no source ref (non-SIE and pre-2026-04 imports)', () => { + const index = buildVoucherIndex([ + makeVoucher({ id: 'je-1', source_voucher_series: null, source_voucher_number: null }), + ]) + + expect(index.byPeriodKey.size).toBe(0) + expect(index.bySourceRef.size).toBe(0) + expect(index.byNumber.size).toBe(0) + }) + + it('drops BOTH entries when one source ref repeats inside a fiscal year', () => { + const index = buildVoucherIndex([ + makeVoucher({ id: 'je-1' }), + makeVoucher({ id: 'je-2' }), + ]) + + expect(index.byPeriodKey.has(`${PERIOD_2024}|A|31`)).toBe(false) + expect(index.ambiguousPeriodKeys.has(`${PERIOD_2024}|A|31`)).toBe(true) + // Both stay discoverable so a caller can present the choice. + expect(candidatesForRef(index, { series: 'A', number: 31 })).toHaveLength(2) + }) + + it('keeps a third repeat out of byPeriodKey once the key is ambiguous', () => { + const index = buildVoucherIndex([ + makeVoucher({ id: 'je-1' }), + makeVoucher({ id: 'je-2' }), + makeVoucher({ id: 'je-3' }), + ]) + + expect(index.byPeriodKey.has(`${PERIOD_2024}|A|31`)).toBe(false) + expect(candidatesForRef(index, { series: 'A', number: 31 })).toHaveLength(3) + }) + + it('keeps the same source ref in different fiscal years apart', () => { + const index = buildVoucherIndex([ + makeVoucher({ id: 'je-2024' }), + makeVoucher({ id: 'je-2025', fiscal_period_id: PERIOD_2025, entry_date: '2025-03-14' }), + ]) + + expect(index.byPeriodKey.get(`${PERIOD_2024}|A|31`)).toBe('je-2024') + expect(index.byPeriodKey.get(`${PERIOD_2025}|A|31`)).toBe('je-2025') + expect(candidatesForRef(index, { series: 'A', number: 31 })).toHaveLength(2) + }) + + it('matches series case-insensitively on both sides', () => { + const index = buildVoucherIndex([makeVoucher({ id: 'je-1', source_voucher_series: 'a' })]) + + expect(candidatesForRef(index, { series: 'A', number: 31 })).toHaveLength(1) + expect(index.byPeriodKey.get(`${PERIOD_2024}|A|31`)).toBe('je-1') + }) +}) + +describe('candidatesForNumber', () => { + it('finds a number across every series', () => { + const index = buildVoucherIndex([ + makeVoucher({ id: 'je-a', source_voucher_series: 'A' }), + makeVoucher({ id: 'je-b', source_voucher_series: 'B' }), + ]) + + expect(candidatesForNumber(index, 31).map((v) => v.id)).toEqual(['je-a', 'je-b']) + expect(candidatesForNumber(index, 999)).toEqual([]) + }) +}) + +describe('periodIdForDate', () => { + it('finds the period containing the date, inclusive of both bounds', () => { + expect(periodIdForDate(periods, '2024-01-01')).toBe(PERIOD_2024) + expect(periodIdForDate(periods, '2024-12-31')).toBe(PERIOD_2024) + expect(periodIdForDate(periods, '2025-06-01')).toBe(PERIOD_2025) + expect(periodIdForDate(periods, '2023-06-01')).toBeNull() + }) +}) + +describe('resolveDatedRef', () => { + const index = buildVoucherIndex([ + makeVoucher({ id: 'je-2024' }), + makeVoucher({ id: 'je-2025', fiscal_period_id: PERIOD_2025, entry_date: '2025-03-14' }), + ]) + + it('resolves via the fiscal period the attachment date falls in', () => { + expect(resolveDatedRef(index, periods, { series: 'A', number: 31, date: '2024-05-02' })).toBe( + 'je-2024', + ) + expect(resolveDatedRef(index, periods, { series: 'A', number: 31, date: '2025-05-02' })).toBe( + 'je-2025', + ) + }) + + it('returns undefined when the date falls outside every known period', () => { + expect( + resolveDatedRef(index, periods, { series: 'A', number: 31, date: '2023-05-02' }), + ).toBeUndefined() + }) + + it('resolves a financial-year window when exactly one entry falls inside it', () => { + expect( + resolveDatedRef(index, periods, { + series: 'A', + number: 31, + date: '2024-01-01', + dateTo: '2024-12-31', + }), + ).toBe('je-2024') + }) + + it('refuses a financial-year window that spans two candidates', () => { + expect( + resolveDatedRef(index, periods, { + series: 'A', + number: 31, + date: '2024-01-01', + dateTo: '2025-12-31', + }), + ).toBeUndefined() + }) + + it('returns undefined for an ambiguous key rather than picking one', () => { + const ambiguous = buildVoucherIndex([makeVoucher({ id: 'je-1' }), makeVoucher({ id: 'je-2' })]) + + expect( + resolveDatedRef(ambiguous, periods, { series: 'A', number: 31, date: '2024-05-02' }), + ).toBeUndefined() + }) +}) + +describe('labels', () => { + it('separates our voucher label from the source label', () => { + const entry = makeVoucher({ id: 'je-1' }) + + expect(voucherLabel(entry)).toBe('A47') + expect(sourceVoucherLabel(entry)).toBe('A31') + }) + + it('returns null when a label is not fully populated', () => { + expect(voucherLabel({ voucher_series: 'A', voucher_number: null })).toBeNull() + expect(sourceVoucherLabel({ source_voucher_series: null, source_voucher_number: 31 })).toBeNull() + }) +}) diff --git a/lib/documents/filename-voucher-ref.ts b/lib/documents/filename-voucher-ref.ts new file mode 100644 index 00000000..70462e9c --- /dev/null +++ b/lib/documents/filename-voucher-ref.ts @@ -0,0 +1,174 @@ +/** + * Read a source-system voucher reference out of an underlag filename. + * + * Systems that export receipts alongside a SIE file name each file after the + * verifikat it belongs to: SpeedLedger writes `A31_.pdf`, Fortnox + * `V123.pdf`, others `2024-A-31 kvitto.pdf`. That prefix is a deterministic + * pointer into the ledger, which is why underlag import does not need to read + * the document at all: no AI, no amount matching, no date windows. + * + * Design rule: an unrecognised name returns null. A wrong parse attaches + * räkenskapsinformation to the wrong verifikat, and that cannot be undone + * (BFL 7 kap), so the cost of guessing is far higher than the cost of asking. + */ + +export type VoucherRefPattern = + /** `A31`, `A31_uuid`, `A-31 kvitto`, `2024_A31`, `ver A31` */ + | 'series_number' + /** `31`, `31_kvitto`: a number with no series at all. */ + | 'number_only' + +export interface ParsedFileNameRef { + /** Null when the filename carried a number but no series. */ + series: string | null + number: number + pattern: VoucherRefPattern + /** + * Whether this parse may be pre-selected in a bulk plan. Three classes are + * never auto-selectable, even on a single-candidate hit: + * - series-less parses (`31.pdf` can point at any series); + * - refs on the collision list (`A4.pdf` is far more often a scanner's + * paper size than verifikat A4, `K10.pdf` a blankett); + * - three-letter series (`IMG_0031.jpg`: real SIE series are 1-2 chars, + * three letters is a camera or scanner prefix). + * They all still parse and resolve; a human confirms with one click. + */ + autoSelectable: boolean +} + +/** + * Optional noise ahead of the reference: a year folder prefix and the words + * some exporters prepend. Kept tight on purpose, `(?:19|20)\d{2}` rather than + * any 4 digits, so a voucher number is never eaten as a year. + * + * `ifikation` must precede `ifikat` in the alternation: regex alternation is + * first-match, so the short branch would otherwise consume `Verifikat` out of + * `Verifikation 31` and leave `ion` for the series group to swallow. + */ +const YEAR_NOISE = '(?:(?:19|20)\\d{2}[-_. ]+)?' +/** + * The lookahead is load-bearing, not decoration. Without it the engine + * backtracks into the shorter alternatives and `Verifikation 31` matches `ver` + * + `ifikat`, leaving `ion` for the series group to swallow as series `ION`. + * Requiring the word to end here means the prefix is either the whole word or + * not consumed at all. + */ +const VER_NOISE = '(?:ver(?:ifikation|ifikat)?(?![A-Za-zÅÄÖåäö])[-_. ]*)?' + +/** `A31`, `A-31`, `A_31`, `A 31`, optionally followed by `_`/`-`/space + anything. */ +const SERIES_NUMBER_RE = new RegExp( + `^${YEAR_NOISE}${VER_NOISE}([A-Za-zÅÄÖåäö]{1,3})[-_. ]?(\\d{1,7})(?:[-_. ].*)?$`, + 'i', +) + +/** + * `31`, `31_kvitto`, `Verifikat 31`. The `ver` prefix is allowed here but the + * year prefix is NOT: `2024 31` is far more likely a date fragment than + * voucher 31 of 2024, and this branch has no series to corroborate it with. + */ +const NUMBER_ONLY_RE = new RegExp(`^${VER_NOISE}(\\d{1,6})(?:[-_. ].*)?$`, 'i') + +/** + * A date-named file, never a voucher number. Deliberately loose where the + * parser is strict: unpadded components (`2024-1-31`), two-digit years + * (`24-01-31`), any of `-_. /` as separator, and the compact `20240131`. + * A false positive here costs one manual assignment; a false negative attaches + * a receipt to a verifikat whose number happens to equal a year fragment. + */ +const DATE_PREFIX_RE = new RegExp( + '^(?:' + + // 20240131 + '(?:19|20)\\d{6}' + + // 2024-01-31, 2024-1-31, 24-01-31, 2024 01 31, 2024/01/31 + '|(?:19|20)?\\d{2}[-_. /]\\d{1,2}[-_. /]\\d{1,2}' + + // 31.01.2024, 31/1/2024, 12-24-2024: day-first and US order. Without this + // the day becomes a voucher number that always exists in the year. + '|\\d{1,2}[-_. /]\\d{1,2}[-_. /](?:19|20)\\d{2}' + + ')(?!\\d)', +) + +/** + * `ver` is a prefix word, never a series. Without this `ver 31.pdf` parses as + * series VER and comes back auto-selectable, while the spelled-out + * `Verifikat 31.pdf` correctly yields a series-less reference that requires + * confirmation. Same filename, two trust levels, decided by an abbreviation. + */ +const NOT_A_SERIES = new Set(['VER']) + +/** + * Refs that are, in the wild, far more often document names than voucher + * references: A0-A6 are paper sizes (every scanner emits an `A4.pdf`), + * K2-K13 / N1-N9 / T1-T2 are Skatteverket blanketter, Q1-Q4 are quarters. + * Verifikat A4 genuinely exists in every migrated ledger, which is exactly + * why these must not be pre-selected: the review table cannot tell a scanned + * "A4.pdf" from the real receipt for voucher A4, and a wrong link is + * permanent. Demoted, not refused: a genuine A4 costs one click. + * + * The inconsistency this fixes: `31.pdf` already required confirmation while + * `A4 scan.pdf`, which carries LESS voucher evidence in a single-series + * company, was pre-ticked. + */ +const COLLISION_REFS = new Set([ + 'A0', 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', + 'K2', 'K3', 'K4', 'K5', 'K6', 'K7', 'K8', 'K9', 'K10', 'K11', 'K12', 'K13', + 'N1', 'N2', 'N3', 'N4', 'N5', 'N6', 'N7', 'N8', 'N9', + 'T1', 'T2', + 'Q1', 'Q2', 'Q3', 'Q4', +]) + +/** Real SIE series are 1-2 characters; three letters is IMG/DSC/DOC/SCN. */ +const MAX_AUTO_SERIES_LENGTH = 2 + +/** + * Any four-digit run that reads as a calendar year. Used to refuse a + * SERIES-LESS parse: `2024` alone is overwhelmingly a year, not verifikat 2024. + */ +const YEAR_LIKE_RE = /^(?:19|20)\d{2}$/ + +/** + * Trim only. Directory components are NOT stripped: `file.name` from an + * `` never carries a path, while the manual-reference box + * feeds arbitrary user text through this same parser, where splitting on `/` + * would quietly turn the typed date `2024/01/31` into voucher 31. + */ +function baseName(fileName: string): string { + return fileName.trim() +} + +/** Drop the extension, but only a real-looking one (`.pdf`, `.jpeg`). */ +function stripExtension(name: string): string { + return name.replace(/\.[A-Za-z0-9]{1,5}$/, '') +} + +export function parseVoucherRefFromFileName(fileName: string): ParsedFileNameRef | null { + const stem = stripExtension(baseName(fileName)) + if (!stem) return null + + // A file named after its date is the single most common false positive: the + // digits parse cleanly and point at a verifikat number that has nothing to do + // with the receipt. Refuse the whole name rather than try to be clever. + if (DATE_PREFIX_RE.test(stem)) return null + + const seriesMatch = SERIES_NUMBER_RE.exec(stem) + if (seriesMatch) { + const series = seriesMatch[1].toUpperCase() + const number = Number(seriesMatch[2]) + if (!NOT_A_SERIES.has(series) && Number.isInteger(number) && number > 0) { + // The check runs on the NORMALIZED ref: a scanner's `A0004.pdf` parses + // to number 4 and must be caught by the same A4 entry. + const autoSelectable = + series.length <= MAX_AUTO_SERIES_LENGTH && !COLLISION_REFS.has(`${series}${number}`) + return { series, number, pattern: 'series_number', autoSelectable } + } + } + + const numberMatch = NUMBER_ONLY_RE.exec(stem) + if (numberMatch && !YEAR_LIKE_RE.test(numberMatch[1])) { + const number = Number(numberMatch[1]) + if (Number.isInteger(number) && number > 0) { + return { series: null, number, pattern: 'number_only', autoSelectable: false } + } + } + + return null +} diff --git a/lib/documents/underlag-import.ts b/lib/documents/underlag-import.ts new file mode 100644 index 00000000..5ef46c71 --- /dev/null +++ b/lib/documents/underlag-import.ts @@ -0,0 +1,286 @@ +/** + * Underlag import: attach a folder of receipt files to already-migrated + * verifikat, using nothing but the voucher reference in each filename. + * + * A SIE file carries the ledger but not the underlag, so a migrating customer + * has to bring the receipts over separately. Systems that export both name each + * receipt after its verifikat (`A31_.pdf`), and the SIE import preserved + * that same identity on every entry, so the pairing is a lookup rather than an + * interpretation. No AI, no amount matching, no date windows. + * + * This module only PLANS. Nothing here writes: the plan goes back to the user, + * who approves it, and each approved row is then attached one file at a time. + * That split is deliberate: linking a document to a posted verifikat makes it + * räkenskapsinformation, which can never be re-pointed (BFL 7 kap), so a bulk + * write with no preview would be an unrecoverable mistake by design. + * + * EVERY plan is scoped to one fiscal year, which the user declares. That is not + * ceremony. Source systems restart voucher numbering every year and a filename + * carries no year, so `A31` alone does not identify a verifikat. An earlier + * version resolved company-wide and treated "only one candidate exists" as + * proof of identity: with a partial migration, or with the year's A31 among the + * vouchers the importer skipped (empty, single-line, unbalanced), that silently + * attached a 2023 receipt to a 2025 verifikat, permanently. Cardinality is not + * identity. Scoping cannot make the year inferable, so it makes it asserted: + * a file can only ever land in the year the user named. + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import { parseVoucherRefFromFileName } from '@/lib/documents/filename-voucher-ref' +import { + buildVoucherIndex, + candidatesForNumber, + candidatesForRef, + fetchFiscalPeriods, + fetchVouchersForNumbers, + hasSourceRefVouchers, + sourceVoucherLabel, + voucherLabel, + type FiscalPeriodRow, + type VoucherRow, +} from '@/lib/documents/voucher-ref-resolver' + +export type UnderlagPlanStatus = + /** Exactly one open verifikat: safe to pre-select. */ + | 'matched' + /** Resolved, but the filename gave no series: a human confirms the pick. */ + | 'needs_confirmation' + /** The ref exists in several fiscal years: the user picks which one. */ + | 'ambiguous' + /** The only candidate sits in a closed or locked period: the DB will refuse. */ + | 'period_locked' + /** Parsed a ref, but no migrated verifikat carries it. */ + | 'no_match' + /** The filename carries no readable voucher reference. */ + | 'unparsed' + +export interface UnderlagPlanCandidate { + journal_entry_id: string + /** Our own label after renumbering, e.g. `A47`. */ + voucher_label: string | null + /** The label the source system used, i.e. what the filename says, e.g. `A31`. */ + source_voucher_label: string | null + entry_date: string + description: string | null + period_locked: boolean +} + +export interface UnderlagPlanRow { + file_name: string + status: UnderlagPlanStatus + parsed_ref: { series: string | null; number: number } | null + /** The single resolved target, when there is exactly one. */ + journal_entry_id: string | null + /** Every candidate, so an ambiguous row can be resolved by hand. */ + candidates: UnderlagPlanCandidate[] +} + +export interface UnderlagPlanSummary { + total: number + matched: number + needs_confirmation: number + ambiguous: number + period_locked: number + no_match: number + unparsed: number +} + +export interface UnderlagPlan { + rows: UnderlagPlanRow[] + summary: UnderlagPlanSummary + /** The fiscal year every row in this plan was resolved against. */ + fiscal_period_id: string + /** + * True when the SELECTED fiscal year holds no migrated entry carrying a + * source voucher ref. Either that year was never imported from SIE, or the + * import predates the columns (added 2026-04-21 and never backfilled), in + * which case filename matching cannot work for it at all and the UI must say + * so instead of showing 400 misses. Often it just means the wrong year is + * selected, which is the first thing worth telling the user. + */ + no_source_refs: boolean +} + +function isPeriodLocked(period: FiscalPeriodRow | undefined): boolean { + return period ? period.is_closed || period.locked_at !== null : false +} + +function toCandidate(entry: VoucherRow, periods: Map): UnderlagPlanCandidate { + return { + journal_entry_id: entry.id, + voucher_label: voucherLabel(entry), + source_voucher_label: sourceVoucherLabel(entry), + entry_date: entry.entry_date, + description: entry.description ?? null, + period_locked: isPeriodLocked(periods.get(entry.fiscal_period_id)), + } +} + +function emptySummary(): UnderlagPlanSummary { + return { + total: 0, + matched: 0, + needs_confirmation: 0, + ambiguous: 0, + period_locked: 0, + no_match: 0, + unparsed: 0, + } +} + +/** + * Build the match plan for a set of filenames, inside one declared fiscal year. + * Reads only: no upload, no link. + * + * The same function backs the preview and the per-file attach check, so the + * server can never link a file to a verifikat the preview would not have + * proposed. + */ +export async function buildUnderlagPlan( + supabase: SupabaseClient, + companyId: string, + fileNames: string[], + fiscalPeriodId: string, +): Promise { + const parsed = fileNames.map((fileName) => ({ + fileName, + ref: parseVoucherRefFromFileName(fileName), + })) + + const numbers = parsed.map((p) => p.ref?.number).filter((n): n is number => n != null) + + const [vouchers, periodRows] = await Promise.all([ + fetchVouchersForNumbers(supabase, companyId, numbers, fiscalPeriodId), + fetchFiscalPeriods(supabase, companyId), + ]) + + // The single most important line in this module: candidates outside the + // declared year are dropped BEFORE the index is built, so no downstream + // branch can ever see, count or propose one. + const index = buildVoucherIndex( + vouchers.filter((entry) => entry.fiscal_period_id === fiscalPeriodId), + ) + const periods = new Map(periodRows.map((p) => [p.id, p])) + + const summary = emptySummary() + summary.total = parsed.length + + const rows: UnderlagPlanRow[] = parsed.map(({ fileName, ref }) => { + if (!ref) { + summary.unparsed++ + return { + file_name: fileName, + status: 'unparsed', + parsed_ref: null, + journal_entry_id: null, + candidates: [], + } + } + + const parsedRef = { series: ref.series, number: ref.number } + const entries = ref.series + ? candidatesForRef(index, { series: ref.series, number: ref.number }) + : candidatesForNumber(index, ref.number) + const candidates = entries.map((entry) => toCandidate(entry, periods)) + + if (candidates.length === 0) { + summary.no_match++ + return { + file_name: fileName, + status: 'no_match', + parsed_ref: parsedRef, + journal_entry_id: null, + candidates: [], + } + } + + if (candidates.length > 1) { + // Inside one fiscal year a source ref should be unique, so this is either + // a re-imported year or a series-less filename hitting several series. + // Hand the choice back rather than pick. + summary.ambiguous++ + return { + file_name: fileName, + status: 'ambiguous', + parsed_ref: parsedRef, + journal_entry_id: null, + candidates, + } + } + + const only = candidates[0] + if (only.period_locked) { + summary.period_locked++ + return { + file_name: fileName, + status: 'period_locked', + parsed_ref: parsedRef, + journal_entry_id: only.journal_entry_id, + candidates, + } + } + + if (!ref.autoSelectable) { + summary.needs_confirmation++ + return { + file_name: fileName, + status: 'needs_confirmation', + parsed_ref: parsedRef, + journal_entry_id: only.journal_entry_id, + candidates, + } + } + + summary.matched++ + return { + file_name: fileName, + status: 'matched', + parsed_ref: parsedRef, + journal_entry_id: only.journal_entry_id, + candidates, + } + }) + + // Only worth a round-trip when nothing landed: the answer distinguishes + // "wrong filenames" from "this year holds no source refs to match against", + // which most often means the wrong year is selected. + const no_source_refs = + summary.matched + summary.needs_confirmation + summary.ambiguous + summary.period_locked === 0 + ? !(await hasSourceRefVouchers(supabase, companyId, fiscalPeriodId)) + : false + + return { rows, summary, fiscal_period_id: fiscalPeriodId, no_source_refs } +} + +/** + * Whether attaching `fileName` to `journalEntryId` is permitted by the plan. + * + * Guards the attach route against a stale or wrong client: the file the browser + * uploads must land on a verifikat the preview would propose for that name. + * `fiscalPeriodId` is the caller-supplied declared year, which the route has + * ALREADY asserted equal to the target entry's own period before calling this; + * this function only decides the filename-to-target question inside that year. + * + * `override` marks a deliberate manual assignment. It is honored ONLY when the + * filename is unresolvable in the declared year (no parse, or no candidate): + * a filename the resolver CAN place must land where it points, override or + * not, otherwise a lying client could scatter cleanly-named underlag across + * arbitrary same-year verifikat. The shipped UI only ever overrides rows whose + * filenames resolved to nothing, so this costs it no capability. + */ +export async function planPermitsAttach( + supabase: SupabaseClient, + companyId: string, + fileName: string, + journalEntryId: string, + fiscalPeriodId: string, + override: boolean, +): Promise { + const plan = await buildUnderlagPlan(supabase, companyId, [fileName], fiscalPeriodId) + const row = plan.rows[0] + if (!row) return false + if (row.candidates.some((candidate) => candidate.journal_entry_id === journalEntryId)) { + return true + } + return override && row.candidates.length === 0 +} diff --git a/lib/documents/voucher-ref-resolver.ts b/lib/documents/voucher-ref-resolver.ts new file mode 100644 index 00000000..9b7a7d18 --- /dev/null +++ b/lib/documents/voucher-ref-resolver.ts @@ -0,0 +1,314 @@ +/** + * Resolve a SOURCE-system voucher reference to the gnubok verifikat it became. + * + * The SIE importer renumbers vouchers per target series (so source `A31` may + * land as `A47` here), but it preserves the source identity on every entry: + * `journal_entries.source_voucher_series` + `source_voucher_number`, written by + * the `import_sie_journal_entries` RPC straight from #VER. That pair is the only + * safe join key back to the old system. Matching on our own `voucher_number` + * instead silently attaches underlag to the wrong verifikat as soon as the + * import skipped an empty or unbalanced voucher, which it routinely does. + * + * Two consumers, one resolution truth: + * - the provider migration sweep (Bokio/Fortnox), which knows each attachment's + * voucher ref AND its date/financial year, and + * - the underlag file import, which only knows what the filename says. + * + * Hence two entry points: `resolveDatedRef` when a date narrows the candidates, + * and `candidatesForRef` when it does not and the caller must handle ambiguity + * itself (surface the choice rather than guess: an underlag on the wrong + * verifikat is räkenskapsinformation and cannot be re-pointed afterwards). + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import { fetchAllRows } from '@/lib/supabase/fetch-all' + +/** A voucher as written in the source system. */ +export interface SourceVoucherRef { + series: string + number: number +} + +/** A source ref plus the date window that narrows it to one fiscal year. */ +export interface DatedSourceVoucherRef extends SourceVoucherRef { + /** Attachment date, or the financial year start when only that is known. */ + date: string + /** Financial year end, when the source only pins the attachment to a year. */ + dateTo?: string +} + +export interface VoucherRow { + id: string + fiscal_period_id: string + entry_date: string + source_voucher_series: string | null + source_voucher_number: number | null + // Display-only, and fetched only by the reads that need them: the provider + // sweep resolves thousands of entries and never renders any of this. + description?: string | null + voucher_series?: string | null + voucher_number?: number | null +} + +export interface FiscalPeriodRow { + id: string + period_start: string + period_end: string + is_closed: boolean + locked_at: string | null +} + +export interface VoucherIndex { + /** + * (period, series, number) → entry id, for keys that resolve to exactly one + * verifikat. Keys seen more than once are removed and recorded as ambiguous. + */ + byPeriodKey: Map + /** "series|number" → every entry carrying it, across all fiscal years. */ + bySourceRef: Map + /** number → every entry carrying it in ANY series, for series-less filenames. */ + byNumber: Map + ambiguousPeriodKeys: Set +} + +/** Find the fiscal period whose date range contains a given date. */ +export function periodIdForDate(periods: FiscalPeriodRow[], date: string): string | null { + const period = periods.find((p) => p.period_start <= date && date <= p.period_end) + return period?.id ?? null +} + +/** + * Series comparison is case-insensitive on both sides of the join. SIE writes + * series uppercase in practice but the spec does not require it, and a filename + * is whatever the user's export tool produced: `a31.pdf` must still find `A31`. + */ +function normalizeSeries(series: string): string { + return series.trim().toUpperCase() +} + +/** + * In-memory key for a verifikat: fiscal period + series + number. Scoping by + * period is essential: source systems restart voucher numbering every year, so + * `A31` alone is not unique once several years are migrated. + */ +export function voucherKey(periodId: string, series: string, number: number): string { + return `${periodId}|${normalizeSeries(series)}|${number}` +} + +/** "series|number", the period-agnostic key. */ +export function sourceRefKey(series: string, number: number): string { + return `${normalizeSeries(series)}|${number}` +} + +export function buildVoucherIndex(vouchers: VoucherRow[]): VoucherIndex { + const byPeriodKey = new Map() + const bySourceRef = new Map() + const byNumber = new Map() + const ambiguousPeriodKeys = new Set() + + // Get-or-create + push, never copy: the provider sweep indexes every + // migrated entry in the company, and per-row array copies turn that O(n²). + const appendTo = (map: Map, key: K, row: VoucherRow) => { + const list = map.get(key) + if (list) list.push(row) + else map.set(key, [row]) + } + + for (const v of vouchers) { + if (v.source_voucher_series == null || v.source_voucher_number == null) continue + + appendTo(bySourceRef, sourceRefKey(v.source_voucher_series, v.source_voucher_number), v) + appendTo(byNumber, v.source_voucher_number, v) + + const key = voucherKey(v.fiscal_period_id, v.source_voucher_series, v.source_voucher_number) + if (byPeriodKey.has(key)) { + // Two entries share one source ref inside one fiscal year: neither can be + // chosen without guessing, so drop both rather than attach blind. + byPeriodKey.delete(key) + ambiguousPeriodKeys.add(key) + } else if (!ambiguousPeriodKeys.has(key)) { + byPeriodKey.set(key, v.id) + } + } + + return { byPeriodKey, bySourceRef, byNumber, ambiguousPeriodKeys } +} + +/** + * Resolve a ref that carries date information. Returns undefined when the ref + * matches nothing or is ambiguous: callers count it as unmatched, never guess. + */ +export function resolveDatedRef( + index: VoucherIndex, + periods: FiscalPeriodRow[], + ref: DatedSourceVoucherRef, +): string | undefined { + if (ref.dateTo) { + // The source pinned the attachment to a financial year, not a day: accept + // it only when exactly one migrated verifikat in that window carries the ref. + const candidates = (index.bySourceRef.get(sourceRefKey(ref.series, ref.number)) ?? []).filter( + (voucher) => ref.date <= voucher.entry_date && voucher.entry_date <= ref.dateTo!, + ) + return candidates.length === 1 ? candidates[0].id : undefined + } + + const periodId = periodIdForDate(periods, ref.date) + return periodId ? index.byPeriodKey.get(voucherKey(periodId, ref.series, ref.number)) : undefined +} + +/** + * Every migrated verifikat carrying a source ref, across all fiscal years. + * A filename gives no date, so a ref that hits several years is genuinely + * ambiguous and the caller must ask instead of picking one. + */ +export function candidatesForRef(index: VoucherIndex, ref: SourceVoucherRef): VoucherRow[] { + return index.bySourceRef.get(sourceRefKey(ref.series, ref.number)) ?? [] +} + +/** + * Candidates for a filename that carried a number but no series (`31.pdf`). + * Searches every series, so this is only usable when it yields exactly one hit, + * and the caller must still make a human confirm it. + */ +export function candidatesForNumber(index: VoucherIndex, number: number): VoucherRow[] { + return index.byNumber.get(number) ?? [] +} + +// Both selects are written out inline at their call site rather than hoisted +// into a shared constant. tests/schema/no-phantom-columns.test.ts resolves +// column lists by scanning the AST for string literals passed to .select(); +// a constant is opaque to it, and hiding this query surface would drop all +// eight journal_entries columns out of the phantom-column net on the one code +// path that writes irreversible räkenskapsinformation links. + +/** + * Statuses a resolved verifikat may have to receive underlag. Posted is the + * normal case; reversed stays in, because a storno'd original remains + * räkenskapsinformation and its underlag belongs on it. Draft and cancelled + * are excluded: the SIE import RPC posts every entry inside its own + * transaction, so a draft with a source ref should be unobservable, but the + * link is irreversible, and an invariant that lives in another file is not an + * invariant this module may lean on. + */ +const ATTACHABLE_STATUSES = ['posted', 'reversed'] + +/** + * All entries that carry a source voucher ref, resolution columns only. + * + * A stable `.order('id')` is required: fetchAllRows pages with `.range()`, and + * PostgREST paging without a deterministic order can skip or repeat rows once + * the table exceeds one page (journal_entries crosses 1000 after a couple of + * migrated years), which would defeat both resolution and any dedup built on it. + */ +export async function fetchSourceRefVouchers( + supabase: SupabaseClient, + companyId: string, +): Promise { + return fetchAllRows(({ from, to }) => + supabase + .from('journal_entries') + .select('id, fiscal_period_id, entry_date, source_voucher_series, source_voucher_number') + .eq('company_id', companyId) + .not('source_voucher_number', 'is', null) + .in('status', ATTACHABLE_STATUSES) + .order('id', { ascending: true }) + .range(from, to), + ) +} + +/** PostgREST puts `.in()` lists in the URL, so the filter is chunked. */ +const REF_QUERY_CHUNK = 200 + +/** + * Only the entries that could match one of `numbers`, rather than every + * migrated entry in the company. The filename flow resolves a handful of refs + * per request and would otherwise pull thousands of rows into memory each time. + * Series is filtered in memory afterwards: it is case-insensitive here and a + * series-less filename has to search across all of them anyway. + * + * Carries the display columns too, because this is the read behind a plan the + * user has to be able to read before approving it. + * + * `fiscalPeriodId` narrows the read at the DB. It is an OPTIMIZATION, not the + * enforcement: buildUnderlagPlan re-filters the rows in memory before indexing, + * and that in-memory filter is the line the year guarantee rests on. + */ +export async function fetchVouchersForNumbers( + supabase: SupabaseClient, + companyId: string, + numbers: number[], + fiscalPeriodId?: string, +): Promise { + const unique = [...new Set(numbers)] + if (unique.length === 0) return [] + + const rows: VoucherRow[] = [] + for (let i = 0; i < unique.length; i += REF_QUERY_CHUNK) { + const chunk = unique.slice(i, i + REF_QUERY_CHUNK) + const chunkRows = await fetchAllRows(({ from, to }) => { + let query = supabase + .from('journal_entries') + .select( + 'id, fiscal_period_id, entry_date, description, voucher_series, voucher_number, source_voucher_series, source_voucher_number', + ) + .eq('company_id', companyId) + .in('source_voucher_number', chunk) + .in('status', ATTACHABLE_STATUSES) + if (fiscalPeriodId) query = query.eq('fiscal_period_id', fiscalPeriodId) + return query.order('id', { ascending: true }).range(from, to) + }) + rows.push(...chunkRows) + } + return rows +} + +/** + * Whether a fiscal year holds any SIE-imported entry carrying a source ref. + * Distinguishes "the filenames are wrong" from "this year was never imported + * from SIE", which are the same empty plan on screen but different problems. + */ +export async function hasSourceRefVouchers( + supabase: SupabaseClient, + companyId: string, + fiscalPeriodId: string, +): Promise { + const { count, error } = await supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('fiscal_period_id', fiscalPeriodId) + .not('source_voucher_number', 'is', null) + + if (error) throw new Error(`Failed to count migrated vouchers: ${error.message}`) + return (count ?? 0) > 0 +} + +export async function fetchFiscalPeriods( + supabase: SupabaseClient, + companyId: string, +): Promise { + return fetchAllRows(({ from, to }) => + supabase + .from('fiscal_periods') + .select('id, period_start, period_end, is_closed, locked_at') + .eq('company_id', companyId) + .order('id', { ascending: true }) + .range(from, to), + ) +} + +/** Our own label for a verifikat (`A47`), as opposed to the source label. */ +export function voucherLabel(entry: Pick): string | null { + return entry.voucher_series && entry.voucher_number != null + ? `${entry.voucher_series}${entry.voucher_number}` + : null +} + +/** The label the source system used (`A31`), which is what filenames carry. */ +export function sourceVoucherLabel( + entry: Pick, +): string | null { + return entry.source_voucher_series && entry.source_voucher_number != null + ? `${entry.source_voucher_series}${entry.source_voucher_number}` + : null +} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 667aee8f..a6ebbcd7 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -2015,6 +2015,30 @@ const DOCUMENT: Record = { message_sv: 'Kopplingen misslyckades.', message_en: 'Failed to link document to journal entry.', }, + UNDERLAG_REF_MISMATCH: { + httpStatus: 409, + message_sv: + 'Filnamnet pekar inte på den verifikation som valdes. Ladda om förhandsgranskningen och försök igen.', + message_en: + 'The filename does not point at the selected verifikat. Reload the preview and try again.', + }, + UNDERLAG_PERIOD_MISMATCH: { + httpStatus: 409, + message_sv: + 'Verifikationen tillhör ett annat räkenskapsår än det du valde för underlagen. Ladda om förhandsgranskningen och försök igen.', + message_en: + 'The verifikat belongs to a different fiscal year than the one selected for these files. Reload the preview and try again.', + }, + UNDERLAG_ENTRY_NOT_POSTED: { + httpStatus: 409, + message_sv: 'Verifikationen är inte bokförd, så underlag kan inte kopplas till den ännu.', + message_en: 'The journal entry is not posted, so documents cannot be attached to it yet.', + }, + UNDERLAG_ENTRY_NOT_MIGRATED: { + httpStatus: 400, + message_sv: 'Verifikationen kommer inte från en SIE-import och kan inte matchas mot filnamn.', + message_en: 'The journal entry did not come from a SIE import and cannot be matched by filename.', + }, } // Invoice-inbox manual upload and attach-document (extension REST routes). diff --git a/messages/en.json b/messages/en.json index e0f3c2c9..f43fe463 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6987,6 +6987,48 @@ "csv_chip_articles": "Articles", "sie_title": "SIE file", "sie_description": "Bookkeeping from another system or from your accountant", + "underlag_title": "Receipts for imported vouchers", + "underlag_description": "Attach receipts and invoices to verifikat from a SIE import, using the voucher number in the filename", + "underlag_step_select": "Pick files", + "underlag_step_review": "Review", + "underlag_step_result": "Result", + "underlag_step_counter": "Step {current}/{total}: {label}", + "underlag_intro": "A SIE file carries the bookkeeping but not the receipts. If your old system named each receipt after its verifikat, they can be attached automatically, with no AI reading required.", + "underlag_intro_formats": "Filenames that work: A31_abc123.pdf, A31.pdf, A-31 receipt.pdf, 2024-A-31.pdf. Matching uses the voucher number from your old system, not the number here.", + "underlag_pick_files": "Pick files", + "underlag_year_help": "The old system restarts voucher numbering every year, so A31 only identifies a verifikat within one year. Pick the year the export came from. Files are never attached to any other year.", + "underlag_year_required": "Pick a fiscal year above to continue. If the year you want is missing, its bookkeeping was not imported via SIE.", + "underlag_year_label": "Which fiscal year are these receipts from?", + "underlag_summary": "{matched} of {total} files matched a verifikat in {year}.", + "underlag_no_source_refs": "The selected fiscal year holds no verifikat from a SIE import, so there is nothing to match the filenames against. Check that you picked the right year.", + "underlag_locked_warning": "{count, plural, one {1 file points at a closed or locked period. Unlock the period first, otherwise it cannot be attached.} other {# files point at a closed or locked period. Unlock the period first, otherwise they cannot be attached.}}", + "underlag_col_include": "Include", + "underlag_col_file": "File", + "underlag_col_ref": "Parsed", + "underlag_col_target": "Verifikat", + "underlag_col_error": "Error", + "underlag_ref_none": "No voucher number", + "underlag_status_matched": "Matched", + "underlag_status_needs_confirmation": "Confirm", + "underlag_status_ambiguous": "Several matches", + "underlag_status_period_locked": "Locked period", + "underlag_status_no_match": "No match", + "underlag_status_unparsed": "Cannot be parsed", + "underlag_pick_candidate": "Pick a verifikat", + "underlag_manual_placeholder": "e.g. A31", + "underlag_manual_not_found_title": "No verifikat found", + "underlag_manual_not_found_body": "No imported verifikat carries the number {ref} from the old system.", + "underlag_confirm_title": "Attach the receipts?", + "underlag_confirm_body": "{count, plural, one {1 file will be archived and attached to its verifikat in {year}.} other {# files will be archived and attached to verifikat in {year}.}} A link to a posted verifikat is accounting information and cannot be moved afterwards.", + "underlag_confirm_action": "Attach", + "underlag_run": "{count, plural, one {Attach 1 receipt} other {Attach # receipts}}", + "underlag_running": "Attaching {done} of {total}...", + "underlag_back": "Start over", + "underlag_done_title": "Receipts attached", + "underlag_done_body": "{linked} attached, {failed} failed.", + "underlag_all_ok_title": "All receipts attached", + "underlag_all_ok_body": "Every selected file is now attached to its verifikat.", + "underlag_new_import": "Import more receipts", "loading_migration": "Loading migration tool...", "export_heading": "Export", "export_sie_title": "SIE 4", diff --git a/messages/sv.json b/messages/sv.json index cfc635a5..6bd74a94 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -6987,6 +6987,48 @@ "csv_chip_articles": "Artiklar", "sie_title": "SIE-fil", "sie_description": "Bokföring från ett annat system eller från revisorn", + "underlag_title": "Underlag till importerade verifikat", + "underlag_description": "Koppla kvitton och fakturor till verifikat från en SIE-import, via verifikatnumret i filnamnet", + "underlag_step_select": "Välj filer", + "underlag_step_review": "Granska", + "underlag_step_result": "Resultat", + "underlag_step_counter": "Steg {current}/{total}: {label}", + "underlag_intro": "En SIE-fil innehåller bokföringen men inte underlagen. Om ditt gamla system namngav varje underlag efter sitt verifikat kan de kopplas automatiskt, utan AI-tolkning.", + "underlag_intro_formats": "Filnamn som fungerar: A31_abc123.pdf, A31.pdf, A-31 kvitto.pdf, 2024-A-31.pdf. Matchningen sker mot verifikatnumret i ditt gamla system, inte mot numret här.", + "underlag_pick_files": "Välj filer", + "underlag_year_help": "Verifikatnumret i filnamnet räknas om varje år i det gamla systemet, så A31 pekar bara ut ett verifikat inom ett år. Välj det år exporten kommer från. Filerna kopplas aldrig till något annat år.", + "underlag_year_required": "Välj ett räkenskapsår ovan för att fortsätta. Saknas det år du söker har bokföringen för det året inte importerats via SIE.", + "underlag_year_label": "Vilket räkenskapsår gäller underlagen?", + "underlag_summary": "{matched} av {total} filer matchade ett verifikat i {year}.", + "underlag_no_source_refs": "Det valda räkenskapsåret innehåller inga verifikat från en SIE-import, så det finns inget att matcha filnamnen mot. Kontrollera att du valt rätt år.", + "underlag_locked_warning": "{count, plural, one {1 fil pekar på en stängd eller låst period. Lås upp perioden först, annars går den inte att koppla.} other {# filer pekar på en stängd eller låst period. Lås upp perioden först, annars går de inte att koppla.}}", + "underlag_col_include": "Ta med", + "underlag_col_file": "Fil", + "underlag_col_ref": "Tolkat", + "underlag_col_target": "Verifikat", + "underlag_col_error": "Fel", + "underlag_ref_none": "Inget verifikatnummer", + "underlag_status_matched": "Matchad", + "underlag_status_needs_confirmation": "Bekräfta", + "underlag_status_ambiguous": "Flera träffar", + "underlag_status_period_locked": "Låst period", + "underlag_status_no_match": "Ingen träff", + "underlag_status_unparsed": "Kan inte tolkas", + "underlag_pick_candidate": "Välj verifikat", + "underlag_manual_placeholder": "T.ex. A31", + "underlag_manual_not_found_title": "Hittade inget verifikat", + "underlag_manual_not_found_body": "Ingen importerad verifikation har numret {ref} från det gamla systemet.", + "underlag_confirm_title": "Koppla underlagen?", + "underlag_confirm_body": "{count, plural, one {1 fil arkiveras och kopplas till sitt verifikat i {year}.} other {# filer arkiveras och kopplas till verifikat i {year}.}} En koppling till ett bokfört verifikat är räkenskapsinformation och går inte att flytta i efterhand.", + "underlag_confirm_action": "Koppla", + "underlag_run": "{count, plural, one {Koppla 1 underlag} other {Koppla # underlag}}", + "underlag_running": "Kopplar {done} av {total}...", + "underlag_back": "Börja om", + "underlag_done_title": "Underlagen är kopplade", + "underlag_done_body": "{linked} kopplade, {failed} misslyckades.", + "underlag_all_ok_title": "Alla underlag kopplade", + "underlag_all_ok_body": "Varje vald fil ligger nu som underlag på sitt verifikat.", + "underlag_new_import": "Importera fler underlag", "loading_migration": "Laddar migreringsverktyg...", "export_heading": "Exportera", "export_sie_title": "SIE 4", diff --git a/tests/pg/underlag-attach-period-lock.pg.test.ts b/tests/pg/underlag-attach-period-lock.pg.test.ts new file mode 100644 index 00000000..6e5a42d3 --- /dev/null +++ b/tests/pg/underlag-attach-period-lock.pg.test.ts @@ -0,0 +1,132 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool } from '@/tests/pg/setup' +import { insertBalancedLines, insertDraftJournalEntry, seedCompany } from '@/tests/pg/fixtures' + +// The underlag import (lib/documents/underlag-import.ts) refuses to propose a +// file whose target verifikat sits in a closed or locked period, and the attach +// route maps the failure to DOC_UPLOAD_PERIOD_LOCKED. Both rest on +// enforce_period_lock_documents (migration 20240101000017): a BEFORE +// INSERT/UPDATE trigger on document_attachments that blocks the LINK, not the +// archive. These tests pin that contract, because the plan surface would +// otherwise be free to drift into promising links the database refuses. +const PERIOD_LOCK_ERROR = /locked\/closed fiscal period/i + +async function insertPostedEntry(params: { + userId: string + companyId: string + fiscalPeriodId: string + voucherNumber: number +}): Promise { + const entryId = await insertDraftJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + voucherNumber: params.voucherNumber, + }) + await insertBalancedLines(entryId) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [ + entryId, + ]) + return entryId +} + +async function insertDocument(params: { + userId: string + companyId: string + journalEntryId: string | null +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.document_attachments + (id, user_id, company_id, storage_path, file_name, file_size_bytes, + mime_type, sha256_hash, journal_entry_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + id, + params.userId, + params.companyId, + `documents/${params.userId}/${id}.pdf`, + 'A31_underlag.pdf', + 1024, + 'application/pdf', + randomUUID().replace(/-/g, '').padEnd(64, '0'), + params.journalEntryId, + ], + ) + return id +} + +describe('underlag-attach-period-lock.pg: attaching underlag across a period lock', () => { + it('allows attaching to a verifikat in an open period', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 1, + }) + + await expect( + insertDocument({ userId, companyId, journalEntryId: entryId }), + ).resolves.toBeDefined() + }) + + it('rejects attaching to a verifikat in a CLOSED period', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 1, + }) + await getPool().query( + `UPDATE public.fiscal_periods SET is_closed = true, closed_at = now() WHERE id = $1`, + [fiscalPeriodId], + ) + + await expect( + insertDocument({ userId, companyId, journalEntryId: entryId }), + ).rejects.toThrow(PERIOD_LOCK_ERROR) + }) + + it('rejects attaching to a verifikat in a LOCKED period', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 1, + }) + await getPool().query(`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, [ + fiscalPeriodId, + ]) + + await expect( + insertDocument({ userId, companyId, journalEntryId: entryId }), + ).rejects.toThrow(PERIOD_LOCK_ERROR) + }) + + it('still archives an UNLINKED document in a closed period: the lock guards the link', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + await getPool().query( + `UPDATE public.fiscal_periods SET is_closed = true, closed_at = now() WHERE id = $1`, + [fiscalPeriodId], + ) + + await expect( + insertDocument({ userId, companyId, journalEntryId: null }), + ).resolves.toBeDefined() + }) + + it('rejects linking an already-archived document into a locked period', async () => { + // The UPDATE path matters as much as the INSERT: /api/documents/[id]/link + // moves an existing inbox document onto a verifikat. + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedEntry({ + userId, companyId, fiscalPeriodId, voucherNumber: 1, + }) + const docId = await insertDocument({ userId, companyId, journalEntryId: null }) + await getPool().query(`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, [ + fiscalPeriodId, + ]) + + await expect( + getPool().query( + `UPDATE public.document_attachments SET journal_entry_id = $1 WHERE id = $2`, + [entryId, docId], + ), + ).rejects.toThrow(PERIOD_LOCK_ERROR) + }) +})