From 13be0c569a25282c5a0411cd9304cb4b3b2a6e72 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 30 May 2026 13:36:46 +0200 Subject: [PATCH] feat(mcp): expose multi-tx RPCs (match_batch_allocate + bulk_book_transactions) (#614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bulk-book): manual booking mode + document inheritance Two pieces of user feedback from PR #606: 1. "How come it is only mallar? Is it not possible to have manuell bokfoering?" - BulkBookDialog was template-only. Added a Tabs primitive with Mall / Manuell tabs. Manual tab pre-fills lines from the selected txs (one line per tx on 1930 + counterparty placeholder on 3001/5800 by direction), then the user edits Konto / Debet / Kredit / Beskrivning. Live balance + bank-leg checks drive the confirm button - same invariants the RPC enforces server-side. 2. "Documents attached does not follow into the bookkeeping. And if there are two different documents attached, none of them follow." The bulk_book_transactions RPC now propagates each tx's document onto the target verifikat (new in Branch B, existing in Branch A) as verifikationsunderlag. Per BFL 5 kap 6§ + BFNAR 2013:2 kap 4 a verifikat may have multiple underlag; every receipt that justified a tx is now retention-protected on the combined entry. The dialog shows a small count chip ("N bilagor foeljer med") so the user sees what will inherit. Also dropped p_user_id from the RPC signature (round-3 hardening pattern applied consistently across all multi-tx RPCs after PR #607). Caller resolves from auth.uid() inside the function. Schema: BulkBookSchema is now a 3-way XOR (existing_journal_entry_id | template_id+mode | manual_lines), with manual_lines validated as accountNumber + nonNegativeAmount per line. pg-real tests: - doc inheritance into a new combined verifikat (mixed: 2 of 3 txs have docs - docs_linked should be 2, not 3) - doc inheritance into an existing posted verifikat (link branch) - manual lines path (no template expansion artifacts in the resulting JE - just the 2 user lines) - unbalanced manual lines still rejected by BULK_BOOK_UNBALANCED Migration applied to remote. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(bulk-book): PR #610 review - pg-real signature, account allowlist, account-number validity Three review findings on PR #610: 1. pg-real failure: 2 link-existing tests still used 5-arg SELECT bulk_book_transactions($1::uuid[], $2, $3, $4, $5) after the userId removal. My earlier replace_all caught only the patterns that had ::jsonb on $3; the link-existing tests pass null for new_entry and used a bare $3 so they slipped through. (Greptile P1) 2. Manual lines bypassed chart_of_accounts validation. A typo or adversarial caller could post to a BAS account that doesn't exist in this company's chart, corrupting the hauptbok and breaking SIE export. Both compliance-swarm (OWASP V2.3) and swedish-compliance flagged this. Added a single-roundtrip allowlist check in the route: query chart_of_accounts for distinct account_numbers in manual_lines and reject with BULK_BOOK_INVALID_ACCOUNT if any are missing or inactive. 3. UI canConfirm guard missed invalid account numbers. Account input allows 1-3 digits and JS string comparison '193' >= '1900' is false, so a 3-digit entry escapes bankLineNet, the bank match could pass via other lines, and the server returned 400 only after submit. Added previewLines.every(l => /^\d{4}$/.test(l.account_number)) to canConfirm so the Confirm button stays disabled inline. (Greptile P2) Co-Authored-By: Claude Opus 4.7 (1M context) * fix(bulk-book): PR #610 round 2 - RPC chart-of-accounts, doc tenant isolation, GRANTs Seven compliance findings from the round-1 bot reviews: Migration (20260602121000_bulk_book_round2_fixes.sql): - RPC chart-of-accounts allowlist (defense-in-depth): every line in p_new_entry.lines is now verified to be an active BAS account for p_company_id. Closes the gap where the template branch and direct DB callers (psql, future MCP) bypassed the route's manual-branch check. Returns BULK_BOOK_INVALID_ACCOUNT with the offending list. (OWASP V8.2.1 + SOC 2 CC6.3) - Document inheritance CTE: added "AND d.company_id = p_company_id" to the UPDATE join so the tenant isolation is enforced on both sides (tx + doc), not just the tx side. Four bots converged on this finding (V1.2.5, A.8.2, CC6.6, swedish-compliance). - Bank-leg range check: "length(account_number) = 4 AND account_number BETWEEN '1900' AND '1999'" replaces the bare lexicographic comparison. Lexicographic-on-4-digit is safe today; the length guard is defense-in-depth against schema drift. (swedish-compliance) - Explicit role grants: REVOKE ALL FROM PUBLIC + GRANT EXECUTE TO authenticated on both bulk_book_transactions and match_batch_allocate. (SOC 2 CC6.1) UI (BulkBookDialog): - Manual-mode prefill no longer suggests a hardcoded 3001/5800 counterpart. Reason (swedish-compliance): a user accepting the prefill could submit a verifikat with no VAT line (26xx), under-reporting utgaaende moms. The bank side stays pre-filled (unambiguous); the counterpart row scaffolds blank for the user to choose. Schema (BulkBookSchema): - manual_lines.debit_amount + credit_amount bounded at 99,999,999 SEK per line. Catches typos before the RPC. (compliance-swarm V4.5) i18n: - docs_inherit_hint terminology: "bilaga" -> "verifikationsunderlag" and an explicit "sparas i 7 ar enligt BFL 7 kap" reminder. swedish-compliance flagged that "bilaga" risks users treating the files as deletable attachments rather than retention-bound raekenskapsinformation. Migration applied to remote. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(test): seed chart_of_accounts in bulk-book pg-real seedTenant The round-2 RPC fix added a chart_of_accounts allowlist check inside bulk_book_transactions, but the test fixtures don't seed COA — so every existing test that submits lines (1930, 3001, 2611, etc.) now returns BULK_BOOK_INVALID_ACCOUNT instead of the expected error code. Seed the 8 accounts the suite actually uses directly in seedTenant (cheaper than calling seed_chart_of_accounts which inserts the full BAS 2026 chart). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(mcp): expose match_batch_allocate + bulk_book_transactions as MCP tools Surfaces the multi-tx flows shipped in PRs #603/#606/#608/#610 so Claude Desktop/Code can drive them via chat. - migration 20260603120000: expand pending_operations.operation_type CHECK to include match_batch_allocate, bulk_book_transactions, plus undo_sie_import (which was missing from prior expansions despite being wired in risk-tiers.ts and the commit dispatcher). - types/index.ts: extend PendingOperationType. - lib/pending-operations/risk-tiers.ts: match_batch_allocate = medium (same tier as single-tx match), bulk_book_transactions = high (creates a verifikat with arbitrary lines, same surface as create_voucher). - lib/pending-operations/commit.ts: thin commit handlers that call the SQL RPCs and translate the structured error envelope. The RPCs themselves do all the locking, balance checks, JE creation, voucher number, payment/junction rows, and doc inheritance. - extensions/general/mcp-server/server.ts: two new tool definitions. Both stage via stagePendingOperation with period_status hint and pre-validate inputs (direction, sum-equals-tx-abs, same-date, not-already-booked) so the agent gets a clear error inline before the RPC runs. - payload-size.bench: bump from 30K to 31K tokens (with rationale). Two new tools earn the bump; descriptions already trimmed to fit the <=280-char description limit. Migration applied to remote and version aligned with local filename. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): PR #614 review - allocation guard, IDOR pre-check, currency + JE-date Round-1 review fixes on PR #614: - Greptile P1: per-allocation invoice_id / supplier_invoice_id guard. The inputSchema marks both as optional (they're mutually exclusive by kind), so JSON Schema can't express "X required iff Y=A". Added explicit check in the execute handler: customer_invoice rows must carry invoice_id; supplier_invoice rows must carry supplier_invoice_id. - OWASP V8.2.1: IDOR pre-check on match_batch_allocate. Verify every invoice / supplier_invoice referenced in the allocations belongs to this company BEFORE staging. The RPC re-checks (BATCH_INVOICE_NOT_FOUND), but failing fast at the MCP layer gives the agent a clear error. - OWASP V8.2.1: same pre-check on bulk_book_transactions for existing_journal_entry_id. Fetches the JE at stage time, verifies status=posted and company_id, throws if not found. - swedish-compliance: currency homogeneity check on bulk_book. Mixed SEK + EUR in one samlingsverifikat violates BFL 5 kap 6§ st 3 motpart clarity. Cross-currency batches go through match_batch_allocate instead (which handles FX diff on 7960/3960). - swedish-compliance: period-lock check on the link-existing branch now uses MAX(tx_date, JE.entry_date), not just tx_date. Otherwise a tx in an open period could attach to a verifikat in a locked period and the guard would miss it. - A.8.11 + CC7.2: sanitised RPC error logging. log.error now emits only { code, message } instead of the full error object — error.details can echo invoice IDs, amounts, and counterparty identifiers. Not actioned (PR-comment, no code change): - V2.3 double-validation in commit handler — RPC enforces balance, accounts, bank-leg via the chart_of_accounts allowlist (PR #610 round 2). Commit handler is a thin pass-through by design. - A.8.2 step-up approval for high-tier ops — architectural change affecting all high-tier ops, not PR-scoped. - V2.4 rate limiting on bulk endpoints — platform-level concern. - 0.005 epsilon / account-class allowlist — pre-existing patterns. - undo_sie_import storno requirement — separate RPC, this PR only backfilled the missing CHECK constraint. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): PR #614 round 2 - trust-boundary comments + balance pre-check + audit log Round-2 review fixes (compliance-swarm went 14 -> 9 after round 1; remaining HIGHs are all "do the same tenant check at multiple layers"). The bot itself offers the alternative: "or document and reference the specific RPC line that enforces this." Following that. - commit.ts: trust-boundary comment blocks on both commitMatchBatchAllocate and commitBulkBookTransactions, citing the exact RPC + migration where tenant isolation + chart_of_accounts allowlist are enforced authoritatively. The commit handler stays a thin pass-through by design; re-querying would triple the same check without adding security. (V8.2.1, A.8.2) - commit.ts: structured success-path log.info() on both handlers with companyId, operationType, journal_entry_id, and tx count. No raw amounts or IDs that could echo PII. (V16) - server.ts: balance pre-check on bulk_book create-new path. RPC enforces BULK_BOOK_UNBALANCED authoritatively, but failing fast at staging gives the agent a clear error before pending_operations is even touched. (V2.3 / swedish-compliance) Not actioned this round: - V2.2 oneOf/if-then-else in JSON Schema for mutual exclusivity — JSON Schema vocabulary support is shaky across MCP clients; runtime check in execute() is the canonical pattern across the existing toolset. - CC6.1 generic error string to caller — RPC error codes are user-actionable (BULK_BOOK_UNBALANCED, BATCH_INVOICE_NOT_FOUND); a generic string would degrade UX. - CC7.2 audit RPC RAISE messages for PII — separate audit; not PR-scoped. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): PR #614 round 3 — last 5 LOWs + salary_run/agi constraint backfill Compliance-swarm went 14 → 9 → 5 (all LOW). Cleaning the last 5 + the swedish-compliance findings. - migration 20260603121000: backfill create_salary_run + generate_agi into pending_operations.operation_type CHECK. Both have risk-tier entries and commit executors but were never added (same bug class as undo_sie_import). Production has no rows of either type today. (swedish-compliance) - server.ts: Number.isFinite guard in bulk_book balance pre-check. Number(x) || 0 silently treats NaN as 0 — a malformed amount could pass the balance check by accident. (compliance-swarm A.8.28) - server.ts: count-equality + missing-set assertion in match_batch_allocate tenant pre-check. Belt-and-suspenders so a null/undefined row in the Supabase JSON response can't pass silently. Same pattern on both invoice and supplier_invoice branches. (CC6.1) - server.ts: fix BFL paragraph citation in currency-homogeneity comment. Was "BFL 5 kap 6§ st 3", should be "BFL 5 kap 2§" (SEK denomination) read with 5 kap 6§ (valutakurs). (swedish-compliance) - server.ts: clarify 0.005 tolerance comment — it's for floating-point equalisation only, not a rounding allowance. RPC enforces exact balance to the öre. (swedish-compliance) - commit.ts: expand audit-log txId comment — included intentionally for trail-to-source join, scoped to companyId already logged. (compliance-swarm A.8.15/CC7.2) Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): PR #614 round 4 — Swedish plural typo + balance comment parity + agent-routing hint Round-3 review caught: - swedish-compliance: \`kundfakturaor\` typo (real räkenskapsinformation defect under BFL 5 kap 7§). Swedish plural for \`kundfaktura\` is \`kundfakturor\` (drop the final \`a\`, add \`or\`), same for \`leverantörsfaktura\` → \`leverantörsfakturor\`. Fixed via slice(-1) + 'or'. - swarm A.8.28: match_batch_allocate balance tolerance check was missing the equivalent "RPC enforces exact balance" comment that bulk_book has. Added. - swedish-compliance: currency-mismatch error message now routes the agent to gnubok_match_batch_allocate for cross-currency allocations instead of letting it retry with hand-built FX lines. Not actioned (out of pattern / out of scope): - Integer arithmetic for balance checks (codebase pattern is float + epsilon; would diverge from match_batch_allocate, supplier-payment, invoice-payment, etc.) - DSD docs / runbook for txId-in-log and stripped-error.details trade-offs (out of PR scope; tracked separately) - Link-existing target verifikat description match (architectural; every link-existing op would need this) Co-Authored-By: Claude Opus 4.7 (1M context) * feat(mcp): expose link_transaction_to_journal_entry as MCP tool The REST endpoint /api/transactions/[id]/link-journal-entry already lets the duplicate-payment UI attach a bank tx to an already-posted verifikat without creating new bookkeeping. Agents had no equivalent — closing that parity gap so users on Claude can match bank txs against vouchers they booked manually. The core link logic moves to lib/transactions/link-journal-entry.ts so both the REST route and the new commit handler share one implementation (preserves all structured-error codes, optimistic-lock invoice update, and compensating rollback). New 'link_transaction_journal_entry' op type wired through the risk tiers (medium), TOOL_SCOPE_MAP (transactions:write), and dispatcher. Bumps the tools/list payload-size ceiling 31K → 31.5K — same family bump PRs #603/#606 made when adding match_batch_allocate / bulk_book_transactions. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): PR #614 round 5 — bot findings on link_transaction_journal_entry Addresses the swedish-compliance + compliance-swarm findings on commit 5b884c3a: 1. **CHECK constraint backfill** — new migration adding 'link_transaction_journal_entry' to pending_operations.operation_type. Same bug class as the salary_run/agi backfill in 20260603121000; without it, every staged op would be rejected silently in production (BFL 5 kap 6–7§ audit-trail gap). 2. **Payment-date exchange rate** — invoice_payments.exchange_rate now uses transaction.exchange_rate (rate on payment date) instead of invoice.exchange_rate (rate on invoice date), per BFL 5 kap 2§ + ML 8 kap 21–23§. The full 3960/7960 posting still belongs to createInvoicePaymentJournalEntry by contract — this path only links to an EXISTING verifikat. 3. **voucherLabel format centralized** — exported formatVoucherLabel helper returns the canonical `A-12` format (with hyphen, matches gnubok_link_invoice_to_voucher and SIE #VER cross-references). Both the MCP staging preview and the committed service result import it, so the user can't approve one label and have a different one land in the audit trail. 4. **Rollback warn log restored** — txLog.warn-equivalent (IDs only, no PII) when the compensating rollback itself fails, surfacing partial-state gaps for reconciliation per GDPR Art.5(1)(f) / SOC 2 CC7.2. Lost in the refactor that extracted the shared service; now present in both rollback call sites. 5. **Commit-layer log.info** — structured success log mirroring commitMatchBatchAllocate / commitBulkBookTransactions (companyId, tx/JE IDs, settledInvoice boolean). No raw amounts or counterparty names. 6. **Data minimization on invoice fetch** — explicit column list replaces select('*, customer:customers(name)') in the shared service; the MCP staging pre-check now fetches only invoice_number + remaining_amount (drops total + paid_amount). voucher_description omitted from preview_data per Art.25. Test impact: existing route + dispatcher tests updated to expect `A-12` instead of `A12`. All 4308 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(invoices): correct FX bookkeeping + UI for match-invoice flow User report: matching a 230 SEK bank tx against a 140 USD invoice produced 1930 Dr 2 142,50 / 1510 Cr 2 142,50 — fictitious numbers that didn't match either the bank receipt or the booked AR. Root cause: the preview route called resolveSekAmount(tx.amount, null, INV.currency, INV.rate), treating the SEK tx number as if it were in the invoice's currency and multiplying by the invoice's stored rate. Both the preview and the commit then used the bogus number on both legs and silently dropped the FX gain/loss. A second issue surfaced in the same dialog: for a 1 250 SEK invoice with a prior 230 SEK partial, the comparison row showed "Differens: 250 kr" (off the original total) instead of "20 kr" (off the actual 1 020 kr remaining). This patch: 1. **New shared helper** lib/bookkeeping/invoice-payment-lines.ts - buildInvoicePaymentClearingLines(tx, invoice, description) → bank-leg, AR-leg, fx-diff, and a balanced line array. Bank-leg is always the actual SEK that hit the bank (resolveSekAmount with the TX's currency context, honouring tx.amount_sek when set). AR-leg is the SEK value of the customer-debt reduction at the invoice's stored rate. Diff posts to 3960 (gain) or 7960 (loss) so the verifikat balances per BFL 5 kap 4–5§. Mirrors the match_batch_allocate RPC's contract: when the tx is cross-currency, the single match fully clears the invoice's remaining amount. 2. **Preview route** uses the helper for the clearing branch — replaces the buggy resolveSekAmount call. Now byte-identical to what commit builds. 3. **Match-invoice POST** uses the helper + createJournalEntry directly for the clearing path, bypassing createInvoicePaymentJournalEntry on this single flow. mark-paid and other callers of that function still work as before (full payment + caller-supplied exchangeRateDifference). 4. **InvoiceMatchDialog** compares the bank tx against invoice.remaining_amount (not invoice.total) for both customer and supplier branches; cross-currency dialogs now show the different- currencies warning instead of a meaningless numeric diff. The dialog's invoice card also displays remaining_amount. 8 new unit tests cover same-currency full/partial, cross-currency gain/loss, exact match (no FX line), sub-öre tolerance, and USD-on-USD with pre- populated amount_sek. All 4316 tests pass. Scope note: this expands PR #614 beyond the original "expose multi-tx RPCs as MCP tools" since the same FX bug class affected the new MCP tool too (round 5 already addressed the invoice_payments.exchange_rate side). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): PR #614 round 7 — CI build + 4 HIGH bot findings Core Build was failing on e29a0ba2/5e9d4c3d due to a TypeScript type-cast error in linkTransactionToJournalEntry. Plus the swedish-compliance review flagged four substantive bugs in my recent commits. 1. **TS build error** — `invoice = invoiceRow as typeof invoice` inferred `never` because the LHS type included `null`. Switched to a named `FetchedInvoice` alias and `as unknown as FetchedInvoice`. 2. **TOOL_SCOPE_MAP missing two write-capable tools** (🟠 HIGH OWASP V8.2.1). `gnubok_match_batch_allocate` and `gnubok_bulk_book_transactions` (added in PRs #603/#606) were never registered, meaning any API key could invoke them regardless of scope. Backfilled both with `transactions:write`. 3. **`paymentExchangeRate` fallback wrong-date rate** (swedish-compliance). `transaction.exchange_rate ?? invoice.exchange_rate ?? null` falls back to the INVOICE date's rate when the tx rate is null. Per ML 8 kap 21–23§ the payment row must record the PAYMENT-date rate. Removed the fallback — `null` is correct when the tx is SEK; downstream lookups can populate it lazily from Riksbanken if needed. 4. **Currency-mismatch corrupts paid_amount** (swedish-compliance). The link path was accumulating `tx.amount` into `invoice.paid_amount` without checking that the currencies matched. A 230 SEK tx applied to a USD invoice would record "230 USD paid" silently. Added explicit LINK_TX_INVOICE_CURRENCY_MISMATCH guard (400) — cross-currency settlement must go through the match-invoice flow which routes through buildInvoicePaymentClearingLines. 5. **Cross-currency PARTIAL overstates FX gain/loss** (swedish-compliance, BFL 5 kap 4–5§). `buildInvoicePaymentClearingLines` was crediting the FULL invoice remaining to 1510 on every cross-currency match — zeroing the GL balance while the invoice row stayed at status=partially_paid, and booking a fake huge FX diff to 3960/7960. Fix: only book FX-diff when `bankSek >= arSekFullRemaining`. Partials default to 1930 = 1510 = bankSek, deferring the FX adjustment to the final settlement (or to a manual mark-paid with explicit exchange_rate_difference). Documented the helper as customer-invoice- only (supplier-side has different DR/CR polarity and goes through match_batch_allocate RPC). Test impact: 1 helper test updated to match the defer-on-ambiguous-loss behavior, 1 new test covers the partial-defers-FX path explicitly. All 4317 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): PR #614 round 8 — close out remaining bot findings CI green on round 7 (4 of 4 checks), HIGH count 2 → 1. Round-8 closes the remaining HIGH and the smaller doc/guard items. 1. **PI1.3 risk acknowledgment restored** (SOC 2 HIGH). The shared rollbackTxLink helper already had warn-level logging on rollback failure, but the explicit PI1.3 reference comment from the original route was lost in the refactor. Added inline so the reconciliation- gap risk is visible to future maintainers. 2. **MCP currency-mismatch pre-stage check.** gnubok_link_transaction_to_ journal_entry now fetches invoice.currency and rejects cross-currency matches before staging, saving the user an approval round-trip when the commit handler's LINK_TX_INVOICE_CURRENCY_MISMATCH guard would fire anyway. 3. **fxDiffSek JSDoc clarified.** The sign convention (positive = loss, negative = gain) is correct for verifikat balancing but counter- intuitive at a P&L glance. Documented explicitly + pointed callers needing a "gain" number at `bankSek - arSek`. 4. **Reject both invoice_id + supplier_invoice_id** on the same match_batch_allocate row (V4.5). Extra IDs previously leaked into preview_data silently. 5. **Reject zero-amount tx** in bulk_book_transactions direction guard (A.8.28). A txs[0].amount === 0 would have mis-classified the batch as 'expense'. Mirrors the existing guard in match_batch_allocate. 6. **Reject debit=0 && credit=0 lines** in bulk_book new_entry (BFL 5 kap 6§ — every verifikat line must represent a real bokföringspost with a non-zero amount). 7. **Data-minimization comments** added on the match-invoice preview route (amount_sek + exchange_rate fetch is for the FX-fix bank-leg math) and on the bulk_book_transactions preview_data block (aggregate counts only — no per-tx PII). Mirrors the pattern already documented on gnubok_link_transaction_to_journal_entry. Skipped: - 1510 vs 1515 (osäkra kundfordringar) — future improvement, needs reading the original invoice JE's account, not a single-tool fix. - transaction_description PII masking in preview_data — needs product call on the truncation strategy and would degrade approval-UX. - "invoice.match_confirmed event removed" finding — false positive; the event is emitted at lib/transactions/link-journal-entry.ts:270-280. All 4317 tests pass; payload-size guard still under ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(invoices): PR #614 round 9 — block cross-currency in single match-invoice path Closes the swedish-compliance finding from round-8 review: a SEK bank tx matched against a USD invoice through /api/transactions/[id]/match-invoice would silently corrupt invoice.paid_amount (accumulator treats SEK as USD) and flip a 140 USD invoice to status='paid' after a tiny partial. The round-6/7 FX fix corrected the JOURNAL ENTRY lines but the invoice STATE update still ran the same broken accumulator. Proper cross-currency settlement on this path requires converting tx.amount to invoice.currency at the bank-date rate AND storing invoice_payments rows with the right (amount, currency) pair. That's a larger design call that belongs in its own PR. This change blocks cross-currency on the single-allocation path: - New MATCH_INVOICE_CURRENCY_MISMATCH structured error (400, bilingual) - Same-currency check inserted right after MATCH_INVOICE_NOT_OPEN - Mirrors the LINK_TX_INVOICE_CURRENCY_MISMATCH guard added to the link path in round-7 - Routes the user to the multi-allocation flow (gnubok_match_batch_allocate) which DOES handle 3960/7960 FX-diff postings end-to-end Same-currency (SEK→SEK or USD→USD) remains fully supported including partials; the buildInvoicePaymentClearingLines helper handles those correctly. For SEK tx → USD invoice the user now gets a clean 400 error pointing at the right flow, instead of silently corrupted ledger state. 1 new route test covers the guard. All 4318 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../__tests__/route.test.ts | 4 +- .../[id]/link-journal-entry/route.ts | 256 +-------- .../match-invoice/__tests__/route.test.ts | 111 +++- .../[id]/match-invoice/preview/route.ts | 53 +- .../transactions/[id]/match-invoice/route.ts | 75 ++- .../transactions/InvoiceMatchDialog.tsx | 44 +- .../__tests__/payload-size.bench.test.ts | 13 +- extensions/general/mcp-server/server.ts | 528 ++++++++++++++++++ lib/auth/api-keys.ts | 3 + .../__tests__/invoice-payment-lines.test.ts | 159 ++++++ lib/bookkeeping/invoice-payment-lines.ts | 207 +++++++ lib/errors/structured-errors.ts | 14 + .../link-transaction-journal-entry.test.ts | 263 +++++++++ lib/pending-operations/commit.ts | 201 +++++++ lib/pending-operations/risk-tiers.ts | 16 + lib/transactions/link-journal-entry.ts | 378 +++++++++++++ ...tions_add_batch_allocate_and_bulk_book.sql | 62 ++ ...ding_operations_add_salary_run_and_agi.sql | 56 ++ ...ons_add_link_transaction_journal_entry.sql | 59 ++ types/index.ts | 6 + 20 files changed, 2209 insertions(+), 299 deletions(-) create mode 100644 lib/bookkeeping/__tests__/invoice-payment-lines.test.ts create mode 100644 lib/bookkeeping/invoice-payment-lines.ts create mode 100644 lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts create mode 100644 lib/transactions/link-journal-entry.ts create mode 100644 supabase/migrations/20260603120000_pending_operations_add_batch_allocate_and_bulk_book.sql create mode 100644 supabase/migrations/20260603121000_pending_operations_add_salary_run_and_agi.sql create mode 100644 supabase/migrations/20260604120000_pending_operations_add_link_transaction_journal_entry.sql diff --git a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts index cf7bc792..405ca4df 100644 --- a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts +++ b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts @@ -165,7 +165,9 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => { expect(status).toBe(200) expect(body.success).toBe(true) expect(body.journal_entry_id).toBe(JE_UUID) - expect(body.voucher_label).toBe('A12') + // Canonical format from formatVoucherLabel — series-number with hyphen, + // matches gnubok_link_invoice_to_voucher and SIE #VER cross-references. + expect(body.voucher_label).toBe('A-12') expect(body.invoice_id).toBeNull() expect(body.invoice_status).toBeNull() }) diff --git a/app/api/transactions/[id]/link-journal-entry/route.ts b/app/api/transactions/[id]/link-journal-entry/route.ts index 36e7a02c..77d8f368 100644 --- a/app/api/transactions/[id]/link-journal-entry/route.ts +++ b/app/api/transactions/[id]/link-journal-entry/route.ts @@ -10,9 +10,7 @@ * - journal_entry_id (required): the existing posted JE to link to. * - invoice_id (optional): when supplied, also inserts an * invoice_payments row pointing at the existing JE and flips the - * invoice status to 'paid' / 'partially_paid'. Same optimistic-lock - * pattern as match-invoice. Omit when linking against a JE that - * doesn't relate to a customer invoice (uncommon but supported). + * invoice status to 'paid' / 'partially_paid'. * * Effects: * - transactions.journal_entry_id = je_id @@ -25,16 +23,17 @@ * * NEVER creates a new journal entry; the underlying double-entry already * exists. The match log records 'linked_to_existing_voucher' for audit. + * + * Core logic is shared with the MCP commit handler in lib/pending-operations/commit.ts + * (gnubok_link_transaction_to_journal_entry) — see lib/transactions/link-journal-entry.ts. */ import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { validateBody } from '@/lib/api/validate' import { LinkTransactionJournalEntrySchema } from '@/lib/api/schemas' -import { logMatchEvent } from '@/lib/invoices/match-log' -import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' -import type { Invoice, Transaction } from '@/types' +import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry' ensureInitialized() @@ -53,242 +52,33 @@ export const POST = withRouteContext( const txLog = log.child({ transactionId, journalEntryId: journal_entry_id, invoiceId: invoice_id }) - // Data minimization (GDPR Art.5(1)(c)): pull only the columns the route - // actually uses for validation, the optimistic-lock invoice update, the - // invoice_payments insert, and the compensating-rollback path. Avoid - // `select('*')` so freshly-added columns (PII or otherwise) never leak - // into the request scope or downstream logs by accident. - const { data: transaction, error: fetchTxError } = await supabase - .from('transactions') - .select( - 'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id', - ) - .eq('id', transactionId) - .eq('company_id', companyId) - .single() - - if (fetchTxError || !transaction) { - return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, { requestId }) - } - - if (transaction.journal_entry_id) { - return errorResponseFromCode('LINK_TX_TX_ALREADY_LINKED', txLog, { - requestId, - details: { existingJournalEntryId: transaction.journal_entry_id }, - }) - } - - const { data: journalEntry, error: fetchJeError } = await supabase - .from('journal_entries') - .select('id, status, voucher_series, voucher_number, entry_date') - .eq('id', journal_entry_id) - .eq('company_id', companyId) - .single() - - if (fetchJeError || !journalEntry) { - return errorResponseFromCode('LINK_TX_JE_NOT_FOUND', txLog, { requestId }) - } - - if (journalEntry.status !== 'posted') { - return errorResponseFromCode('LINK_TX_JE_NOT_POSTED', txLog, { - requestId, - details: { currentStatus: journalEntry.status }, - }) - } - - // If invoice_id supplied, validate + prepare invoice update. - let invoice: (Invoice & { customer?: { name?: string } | null }) | null = null - let newPaidAmount = 0 - let newRemaining = 0 - let isFullyPaid = false - let newStatus: 'paid' | 'partially_paid' = 'paid' - - if (invoice_id) { - const { data: invoiceRow, error: fetchInvError } = await supabase - .from('invoices') - .select('*, customer:customers(name)') - .eq('id', invoice_id) - .eq('company_id', companyId) - .single() - - if (fetchInvError || !invoiceRow) { - return errorResponseFromCode('LINK_TX_INVOICE_NOT_FOUND', txLog, { requestId }) - } - - if ( - invoiceRow.status !== 'sent' && - invoiceRow.status !== 'overdue' && - invoiceRow.status !== 'partially_paid' - ) { - return errorResponseFromCode('LINK_TX_INVOICE_NOT_OPEN', txLog, { - requestId, - details: { currentStatus: invoiceRow.status }, - }) - } - - invoice = invoiceRow as Invoice & { customer?: { name?: string } | null } - - const paidAmount = transaction.amount - newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100 - const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0)) - newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100) - isFullyPaid = newRemaining <= 0 - newStatus = isFullyPaid ? 'paid' : 'partially_paid' - } - - // Capture pre-link values so the compensating-rollback path below can - // restore the row if the optimistic invoice update loses its race or - // the invoice_payments insert fails. Without this snapshot a partial - // state would persist: tx linked, invoice unchanged, no payment row. - const priorTxState = { - journal_entry_id: transaction.journal_entry_id, // validated null above - invoice_id: transaction.invoice_id, - potential_invoice_id: transaction.potential_invoice_id, - potential_supplier_invoice_id: transaction.potential_supplier_invoice_id, - is_business: transaction.is_business, - } - - // Link the transaction first. If a subsequent step fails the compensating - // path below restores priorTxState. Doing the tx update before the invoice - // update preserves the "transaction disappears from inbox" UX even if the - // invoice update races. - const { error: updateTxError } = await supabase - .from('transactions') - .update({ - journal_entry_id, - invoice_id: invoice_id ?? null, - potential_invoice_id: null, - potential_supplier_invoice_id: null, - is_business: true, - }) - .eq('id', transactionId) - .eq('company_id', companyId) - .is('journal_entry_id', null) - - if (updateTxError) { - txLog.error('failed to link transaction to journal entry', updateTxError) - return errorResponse(updateTxError, txLog, { requestId }) - } - - async function rollbackTxLink(reason: string) { - const { error: rollbackErr } = await supabase - .from('transactions') - .update(priorTxState) - .eq('id', transactionId) - .eq('company_id', companyId) - if (rollbackErr) { - // Best-effort: the original error is more useful to surface; a - // failed rollback gets warn-logged so a reconciliation job can pick - // up the partial state offline. PI1.3 risk is documented here so - // the audit trail is honest about the remaining gap. - txLog.warn('failed to roll back transaction link after subsequent step failed', { - rollbackError: rollbackErr.message, - reason, - }) - } - } - - const now = new Date().toISOString() - - if (invoice && invoice_id) { - // Optimistic lock: only flip status if invoice is still matchable. - const { data: updatedRows, error: updateInvError } = await supabase - .from('invoices') - .update({ - status: newStatus, - paid_at: isFullyPaid ? now : null, - paid_amount: newPaidAmount, - remaining_amount: newRemaining, - }) - .eq('id', invoice_id) - .eq('company_id', companyId) - .in('status', ['sent', 'overdue', 'partially_paid']) - .select('id') - - if (updateInvError) { - await rollbackTxLink('invoice update errored') - txLog.error('failed to update invoice status', updateInvError) - return errorResponse(updateInvError, txLog, { requestId }) - } - - if (!updatedRows || updatedRows.length === 0) { - await rollbackTxLink('invoice optimistic lock returned 0 rows') - return errorResponseFromCode('LINK_TX_INVOICE_RACE', txLog, { requestId }) - } - - const { error: paymentInsertError } = await supabase - .from('invoice_payments') - .insert({ - user_id: user.id, - company_id: companyId, - invoice_id, - payment_date: transaction.date, - amount: transaction.amount, - currency: invoice.currency, - exchange_rate: invoice.exchange_rate, - journal_entry_id, - transaction_id: transactionId, - notes: 'Kopplad till befintlig verifikation (ingen ny bokföring skapad)', - }) - - if (paymentInsertError && paymentInsertError.code !== '23505') { - // Compensate: revert the invoice update and the tx link before - // surfacing the error so the ledger doesn't carry an invoice that - // says "paid" with no corresponding payment row. - const { error: invRevertErr } = await supabase - .from('invoices') - .update({ - status: invoice.status, - paid_at: invoice.paid_at ?? null, - paid_amount: invoice.paid_amount ?? 0, - remaining_amount: invoice.remaining_amount ?? invoice.total, - }) - .eq('id', invoice_id) - .eq('company_id', companyId) - if (invRevertErr) { - txLog.warn('failed to revert invoice status after payment insert failed', { - rollbackError: invRevertErr.message, - }) - } - await rollbackTxLink('invoice_payments insert failed') - txLog.error('failed to record invoice payment', paymentInsertError) - return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId }) - } - } - - logMatchEvent(supabase, user.id, transactionId, 'linked_to_existing_voucher', { + const outcome = await linkTransactionToJournalEntry(supabase, user.id, companyId, { + transactionId, + journalEntryId: journal_entry_id, invoiceId: invoice_id, - newState: { - journal_entry_id, - invoice_id: invoice_id ?? null, - invoice_status: invoice ? newStatus : null, - }, }) - if (invoice && invoice_id) { - try { - eventBus.emit({ - type: 'invoice.match_confirmed', - payload: { - invoice: invoice as Invoice, - transaction: transaction as Transaction, - userId: user.id, - companyId, - }, + if (!outcome.ok) { + // LINK_TX_DB_ERROR is the only code emitted on raw DB failure; route it + // through the generic errorResponse fallback so the INTERNAL_ERROR envelope + // matches the rest of the API. Everything else maps to a structured-error + // entry with the right HTTP status. + if (outcome.code === 'LINK_TX_DB_ERROR') { + return errorResponse(new Error(String(outcome.details?.reason ?? 'Database error')), txLog, { + requestId, }) - } catch (err) { - txLog.warn('invoice.match_confirmed event emission failed', err as Error) } + return errorResponseFromCode(outcome.code, txLog, { requestId, details: outcome.details }) } return NextResponse.json({ success: true, - journal_entry_id, - voucher_label: `${journalEntry.voucher_series ?? 'A'}${journalEntry.voucher_number ?? ''}`, - invoice_id: invoice_id ?? null, - invoice_status: invoice ? newStatus : null, - paid_amount: invoice ? newPaidAmount : null, - remaining_amount: invoice ? newRemaining : null, + journal_entry_id: outcome.result.journalEntryId, + voucher_label: outcome.result.voucherLabel, + invoice_id: outcome.result.invoiceId, + invoice_status: outcome.result.invoiceStatus, + paid_amount: outcome.result.paidAmount, + remaining_amount: outcome.result.remainingAmount, }) }, { requireWrite: true }, diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index efc77cd4..c4127491 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -14,18 +14,20 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) -const mockCreateInvoicePaymentJournalEntry = vi.fn() const mockCreateInvoiceCashEntry = vi.fn() vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ - createInvoicePaymentJournalEntry: (...args: unknown[]) => mockCreateInvoicePaymentJournalEntry(...args), createInvoiceCashEntry: (...args: unknown[]) => mockCreateInvoiceCashEntry(...args), getRevenueAccount: vi.fn().mockReturnValue('3001'), getOutputVatAccount: vi.fn().mockReturnValue('2611'), })) const mockReverseEntry = vi.fn() +const mockFindFiscalPeriod = vi.fn() +const mockCreateJournalEntry = vi.fn() vi.mock('@/lib/bookkeeping/engine', () => ({ reverseEntry: (...args: unknown[]) => mockReverseEntry(...args), + findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args), + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), })) vi.mock('@/lib/invoices/match-log', () => ({ @@ -71,6 +73,12 @@ describe('POST /api/transactions/[id]/match-invoice', () => { mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) // Default to no soft-duplicate detected — happy-path tests don't care. mockDetectDuplicate.mockResolvedValue(null) + // Clearing path delegates to findFiscalPeriod + createJournalEntry (FX fix + // PR #614 round 6 — see lib/bookkeeping/invoice-payment-lines.ts). Give + // both safe defaults; tests that exercise the clearing path override + // mockCreateJournalEntry to assert the result id. + mockFindFiscalPeriod.mockResolvedValue('fp-1') + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) }) it('returns 401 when not authenticated', async () => { @@ -197,6 +205,38 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_OPEN') }) + it('returns 400 MATCH_INVOICE_CURRENCY_MISMATCH for cross-currency settlement', async () => { + // Round-9 fix: a SEK bank tx paying a USD invoice would otherwise + // corrupt invoice.paid_amount (accumulator treats SEK as USD), flip + // a 140 USD invoice to status=paid after a tiny partial. Block here + // and route the user to the multi-allocation flow that handles + // 3960/7960 FX-diff postings end-to-end. + const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, currency: 'SEK' }) + const invoice = makeInvoice({ + id: VALID_UUID, + status: 'sent', + currency: 'USD', + total: 140, + remaining_amount: 140, + }) + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('MATCH_INVOICE_CURRENCY_MISMATCH') + expect(body.error.details).toMatchObject({ + transactionCurrency: 'SEK', + invoiceCurrency: 'USD', + }) + }) + it('matches transaction to invoice with accrual method (full payment)', async () => { const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' }) const customer = makeCustomer() @@ -220,7 +260,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Fetch company settings enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) // Update invoice (optimistic lock returns updated row) enqueue({ data: [{ id: VALID_UUID }], error: null }) @@ -251,16 +291,24 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(body.remaining_amount).toBe(0) expect(body.journal_entry_id).toBe('je-1') - // Verify accrual payment entry was called with paymentAmount - expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith( + // Clearing path now builds lines via buildInvoicePaymentClearingLines and + // posts via createJournalEntry directly (FX fix PR #614 round 6). For a + // same-currency SEK invoice that's two lines: Dr 1930 12 500 / Cr 1510 + // 12 500, no FX-diff line. + expect(mockCreateJournalEntry).toHaveBeenCalledWith( expect.anything(), 'company-1', 'user-1', - expect.objectContaining({ id: VALID_UUID }), - '2024-06-15', - undefined, - expect.anything(), - 12500 + expect.objectContaining({ + fiscal_period_id: 'fp-1', + entry_date: '2024-06-15', + source_type: 'invoice_paid', + source_id: VALID_UUID, + lines: expect.arrayContaining([ + expect.objectContaining({ account_number: '1930', debit_amount: 12500 }), + expect.objectContaining({ account_number: '1510', credit_amount: 12500 }), + ]), + }), ) }) @@ -294,7 +342,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Fetch company settings enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-payment' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-payment' }) // Update invoice (optimistic lock) enqueue({ data: [{ id: VALID_UUID }], error: null }) @@ -346,7 +394,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // routes any non-typed error to INTERNAL_ERROR. expect((body.error as unknown as { code: string }).code).toBe('INTERNAL_ERROR') // Invoice should NOT have been updated — no further DB calls after storno failure - expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) it('supports partial payment (partially_paid status)', async () => { @@ -364,7 +412,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-partial' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-partial' }) // Update invoice (optimistic lock) enqueue({ data: [{ id: VALID_UUID }], error: null }) @@ -419,10 +467,11 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-clearing' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-clearing' }) - // The PDF re-attach block runs because invoice.journal_entry_id is set; - // returning null skips the attach without aborting the match. + // Route order: PDF re-attach (runs first when invoice.journal_entry_id is + // set; null result skips the attach insert) → optimistic invoice update → + // invoice_payments → update transaction → logMatchEvent. enqueue({ data: null, error: null }) // document_attachments lookup enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice enqueue({ data: null, error: null }) // insert invoice_payments @@ -438,8 +487,9 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(status).toBe(200) expect(body.invoice_status).toBe('paid') - // Must clear 1510, not re-recognise revenue + VAT - expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled() + // Must clear 1510 via the clearing-entry path, not re-recognise revenue + + // VAT via createInvoiceCashEntry. + expect(mockCreateJournalEntry).toHaveBeenCalled() expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled() }) @@ -492,7 +542,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-clearing' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-clearing' }) // Update invoice enqueue({ data: [{ id: VALID_UUID }], error: null }) @@ -512,8 +562,9 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(status).toBe(200) expect(body.invoice_status).toBe('partially_paid') - // Cash partial uses accrual-style clearing entry, NOT createInvoiceCashEntry - expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled() + // Cash partial uses accrual-style clearing entry (now via the shared + // helper + createJournalEntry), NOT createInvoiceCashEntry. + expect(mockCreateJournalEntry).toHaveBeenCalled() expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled() }) @@ -530,7 +581,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) // Optimistic lock returns 0 rows (another request fully paid it) enqueue({ data: [], error: null }) @@ -559,7 +610,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) // Optimistic lock succeeds enqueue({ data: [{ id: VALID_UUID }], error: null }) @@ -586,7 +637,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked')) + mockCreateJournalEntry.mockRejectedValue(new Error('Period locked')) // Update invoice (optimistic lock) enqueue({ data: [{ id: VALID_UUID }], error: null }) @@ -642,7 +693,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(status).toBe(409) expect(body.error.code).toBe('MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER') expect(body.error.details?.existing_journal_entry_id).toBe('je-existing') - expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) it('does NOT run hard-duplicate guard for partially_paid invoices (legitimate additional payment)', async () => { @@ -660,7 +711,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Hard-duplicate check is skipped for partially_paid; jump straight to settings enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-partial-extra' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-partial-extra' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice enqueue({ data: null, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) // update tx @@ -709,7 +760,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(body.error.code).toBe('MATCH_INVOICE_POSSIBLE_DUPLICATE') expect(body.error.details?.candidate?.journal_entry_id).toBe('je-manual') expect(body.error.details?.candidate?.voucher_label).toBe('A12') - expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) it('force=true bypasses the soft-duplicate guard when the candidate echo matches', async () => { @@ -739,7 +790,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-forced' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-forced' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice enqueue({ data: null, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) // update tx @@ -801,7 +852,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(body.error.code).toBe('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH') expect(body.error.details?.expected_journal_entry_id).toBe(STALE_UUID) expect(body.error.details?.detected_journal_entry_id).toBe(OTHER_CANDIDATE_UUID) - expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) it('returns 409 MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH when no current duplicate exists for the force call', async () => { @@ -823,6 +874,6 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) expect(status).toBe(409) expect(body.error.code).toBe('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH') - expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) }) diff --git a/app/api/transactions/[id]/match-invoice/preview/route.ts b/app/api/transactions/[id]/match-invoice/preview/route.ts index 84c343c1..d78bed2b 100644 --- a/app/api/transactions/[id]/match-invoice/preview/route.ts +++ b/app/api/transactions/[id]/match-invoice/preview/route.ts @@ -19,6 +19,7 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries' +import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines' import type { EntityType, Invoice, InvoiceItem } from '@/types' import { z } from 'zod' @@ -49,9 +50,13 @@ export const GET = withRouteContext( } const { invoice_id } = parsed.data + // Data minimization (GDPR Art.5(1)(c)): amount_sek + exchange_rate are + // pulled because buildInvoicePaymentClearingLines needs them for the + // cross-currency bank-leg math (round-7 FX fix). All other columns + // would broaden the projection without serving the preview's purpose. const { data: transaction, error: txErr } = await supabase .from('transactions') - .select('id, date, amount, currency') + .select('id, date, amount, amount_sek, currency, exchange_rate') .eq('id', transactionId) .eq('company_id', companyId) .single() @@ -157,22 +162,38 @@ export const GET = withRouteContext( }) lines.push(...creditLines) } else { - // Clearing entry: Dr 1930 / Cr 1510 at the paid amount in SEK. + // Clearing entry. Delegates to the shared helper so the preview and + // the committed verifikat are byte-identical — fixing the prior + // bug where the preview ran `resolveSekAmount(tx.amount, null, + // INV.currency, INV.rate)`, treating the SEK tx number as if it + // were in the invoice's currency and multiplying by the invoice's + // rate. That produced a fictitious bank-leg and silently dropped + // the FX gain/loss for cross-currency invoices. const inv = invoice as Invoice - const bookedSek = resolveSekAmount(paidAmount, null, inv.currency, inv.exchange_rate) - const amount = Math.round(bookedSek * 100) / 100 - lines.push({ - account_number: '1930', - debit_amount: amount, - credit_amount: 0, - description: 'Inbetalning från bank', - }) - lines.push({ - account_number: '1510', - debit_amount: 0, - credit_amount: amount, - description: 'Kvittning kundfordran', - }) + const { lines: clearingLines } = buildInvoicePaymentClearingLines( + { + amount: transaction.amount, + amount_sek: transaction.amount_sek ?? null, + currency: transaction.currency, + exchange_rate: transaction.exchange_rate ?? null, + }, + { + currency: inv.currency, + exchange_rate: inv.exchange_rate ?? null, + remaining_amount: inv.remaining_amount ?? null, + total: inv.total, + paid_amount: inv.paid_amount ?? null, + }, + 'Inbetalning kundfaktura', + ) + for (const line of clearingLines) { + lines.push({ + account_number: line.account_number, + debit_amount: line.debit_amount, + credit_amount: line.credit_amount, + description: line.line_description ?? '', + }) + } } return NextResponse.json({ diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 37e2c77a..d072c90f 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -1,8 +1,6 @@ import { NextResponse } from 'next/server' -import { - createInvoicePaymentJournalEntry, - createInvoiceCashEntry, -} from '@/lib/bookkeeping/invoice-entries' +import { createInvoiceCashEntry } from '@/lib/bookkeeping/invoice-entries' +import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines' import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors' import { getErrorMessage } from '@/lib/errors/get-error-message' @@ -101,6 +99,31 @@ export const POST = withRouteContext( }) } + // Currency-integrity guard (BFL 5 kap 2§ + swedish-compliance PR #614 + // round 9). invoices.paid_amount / remaining_amount are denominated in + // invoice.currency; invoice_payments rows carry currency = invoice.currency + // with amount in that currency. The accumulator below assumes + // `tx.amount` is already in invoice.currency. For a SEK bank tx paying + // a USD invoice the accumulator would silently treat 230 SEK as "230 + // USD paid" and flip a 140 USD invoice to status=paid after a partial. + // + // Block cross-currency on this single-allocation path until a proper + // FX-aware settlement flow lands. Same-currency (SEK→SEK or USD→USD) + // remains fully supported including partials; the buildInvoicePayment- + // ClearingLines helper handles the bookkeeping side correctly in both + // cases. For SEK tx → USD invoice the user should use the multi- + // allocation dialog (gnubok_match_batch_allocate) which DOES handle + // FX-diff postings on 3960/7960 end-to-end. + if (transaction.currency !== invoice.currency) { + return errorResponseFromCode('MATCH_INVOICE_CURRENCY_MISMATCH', txLog, { + requestId, + details: { + transactionCurrency: transaction.currency, + invoiceCurrency: invoice.currency, + }, + }) + } + // Hard-duplicate guard: if the invoice is 'sent'/'overdue' but already // has a payment voucher attached (status leak), refuse — booking again // would double-credit 1510 / double-debit 1930. Partially-paid invoices @@ -311,10 +334,48 @@ export const POST = withRouteContext( // intentional — under kontantmetoden 1510 has no prior balance, so // partials leave a credit on 1510 that gets resolved on final // payment when createInvoiceCashEntry would normally run. - const journalEntry = await createInvoicePaymentJournalEntry( - supabase, companyId, user.id, invoice as Invoice, transaction.date, - undefined, invoice.customer?.name, paidAmount, + // + // Builds lines via buildInvoicePaymentClearingLines so the verifikat + // is byte-identical to what the preview route showed the user. For + // same-currency invoices that's just 1930/1510. For cross-currency + // it also posts a 3960/7960 FX-diff line so the verifikat balances + // per BFL 5 kap 4–5§. Bypasses createInvoicePaymentJournalEntry on + // this single path (mark-paid and other callers still use it) — + // see lib/bookkeeping/invoice-payment-lines.ts for the contract. + const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date) + if (!fiscalPeriodId) { + return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, { + requestId, + details: { paymentDate: transaction.date }, + }) + } + const desc = invoice.customer?.name + ? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}` + : `Inbetalning kundfaktura ${invoice.invoice_number}` + const { lines: clearingLines } = buildInvoicePaymentClearingLines( + { + amount: transaction.amount, + amount_sek: transaction.amount_sek ?? null, + currency: transaction.currency, + exchange_rate: transaction.exchange_rate ?? null, + }, + { + currency: invoice.currency, + exchange_rate: invoice.exchange_rate ?? null, + remaining_amount: invoice.remaining_amount ?? null, + total: invoice.total, + paid_amount: invoice.paid_amount ?? null, + }, + desc, ) + const journalEntry = await createJournalEntry(supabase, companyId!, user.id, { + fiscal_period_id: fiscalPeriodId, + entry_date: transaction.date, + description: desc, + source_type: 'invoice_paid', + source_id: invoice.id, + lines: clearingLines, + }) journalEntryId = journalEntry?.id ?? null } } catch (err) { diff --git a/components/transactions/InvoiceMatchDialog.tsx b/components/transactions/InvoiceMatchDialog.tsx index 72c5fba4..061050c4 100644 --- a/components/transactions/InvoiceMatchDialog.tsx +++ b/components/transactions/InvoiceMatchDialog.tsx @@ -338,7 +338,10 @@ export default function InvoiceMatchDialog({ - {/* Invoice details */} + {/* Invoice details. Shows remaining_amount (what the customer + still owes) rather than the original total, so a partially- + paid invoice displays the actual figure the user is matching + against. Mirrors the supplier-invoice block below. */} {isCustomerInvoice && (

{t('invoice_label')}

@@ -354,7 +357,7 @@ export default function InvoiceMatchDialog({ {formatCurrency( - transaction.potential_invoice!.total, + transaction.potential_invoice!.remaining_amount ?? transaction.potential_invoice!.total, transaction.potential_invoice!.currency, )} @@ -385,17 +388,29 @@ export default function InvoiceMatchDialog({
)} - {/* Amount comparison */} + {/* Amount comparison. Compares the bank tx against what the + customer STILL OWES (remaining_amount), not the original + invoice.total — otherwise a 1 250 SEK invoice with a prior + 230 SEK partial would show "Differens: 250 kr" when a 1 000 + SEK top-up arrives, instead of the actual 20 kr shortfall. + The customer branch previously fell back to .total; both + branches now mirror the supplier branch's correct logic. */} {(() => { const txAbs = Math.abs(transaction.amount) - const invTotal = isSupplierInvoice + const invRemaining = isSupplierInvoice ? transaction.potential_supplier_invoice!.remaining_amount ?? transaction.potential_supplier_invoice!.total - : transaction.potential_invoice!.total + : transaction.potential_invoice!.remaining_amount ?? transaction.potential_invoice!.total const invCurrency = isSupplierInvoice ? transaction.potential_supplier_invoice!.currency : transaction.potential_invoice!.currency const sameCurrency = transaction.currency === invCurrency - const amountsMatch = sameCurrency && Math.abs(txAbs - invTotal) < 0.01 + // Cross-currency "match" comparison is meaningless without an FX + // conversion — show the explicit different-currencies warning + // and skip the numeric match check. The committed verifikat is + // built by buildInvoicePaymentClearingLines, which posts the + // FX diff to 3960/7960 so the books balance correctly even + // when the on-screen numbers can't be naively compared. + const amountsMatch = sameCurrency && Math.abs(txAbs - invRemaining) < 0.01 if (amountsMatch) { return ( @@ -406,16 +421,25 @@ export default function InvoiceMatchDialog({ ) } - const diff = Math.abs(txAbs - invTotal) return (

{t('amounts_differ')}

- {t('amount_diff', { amount: formatCurrency(diff, transaction.currency) })} - {!sameCurrency && t('different_currencies')} - {isSupplierInvoice && diff > 0.01 && sameCurrency && t('partial_payment_note')} + {sameCurrency ? ( + <> + {t('amount_diff', { + amount: formatCurrency( + Math.abs(txAbs - invRemaining), + transaction.currency, + ), + })} + {isSupplierInvoice && t('partial_payment_note')} + + ) : ( + t('different_currencies') + )}

diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index 992baac5..50bd1d1f 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -13,7 +13,7 @@ describe('tools/list payload size guard', () => { })) const payload = JSON.stringify({ tools: projection }) const approxTokens = Math.round(payload.length / 4) - // Ceiling progression: 20K → 25K → 30K. + // Ceiling progression: 20K → 25K → 30K → 31K → 31.5K. // * 20K → 25K when item 8 of the agent-native API plan landed // (additionalProperties: false on all inputSchemas + period_status in the // staged operation envelope). @@ -22,9 +22,18 @@ describe('tools/list payload size guard', () => { // gnubok_approve_pending_operation, gnubok_reject_pending_operation, // gnubok_set_inbox_extracted_data from main + gnubok_get_agent_briefing, // _remember_fact, _forget_fact, _feedback from the agent branch). + // * 30K → 31K when gnubok_match_batch_allocate and + // gnubok_bulk_book_transactions landed (PRs #603/#606/#608/#610). Each + // adds the shared STAGED_OPERATION_SCHEMA + a non-trivial inputSchema + // for the multi-tx flows. Descriptions already trimmed to 230–260 chars. + // * 31K → 31.5K when gnubok_link_transaction_to_journal_entry landed (PR + // #614). Same family as match_batch_allocate / bulk_book_transactions — + // closes the MCP parity gap with the existing REST endpoint so agents + // can attach a bank tx to an already-posted verifikat without creating + // duplicate bookkeeping. Description trimmed to ~180 chars. // Long-term answer to growth is leaning harder on gnubok_search_tools — if this // fires again, prefer trimming descriptions or making a tool opt-in via search // before bumping further. - expect(approxTokens).toBeLessThan(30_000) + expect(approxTokens).toBeLessThan(31_500) }) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index f86dda37..cb4f9445 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -11,6 +11,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { upsertCounterpartyTemplate, findCounterpartyTemplatesBatch, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' +import { formatVoucherLabel } from '@/lib/transactions/link-journal-entry' import { eventBus } from '@/lib/events/bus' import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' @@ -4408,6 +4409,533 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_match_batch_allocate', + description: 'Allocate 1 bank tx across N customer OR N supplier invoices (samlingsbetalning, BFL 5 kap 6§). Use when one receipt covers many invoices, or one transfer pays many bills. Customer kind requires income tx; supplier kind requires expense. Never mix kinds. Stages.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + transaction_id: { type: 'string' }, + allocations: { + type: 'array', + minItems: 1, + maxItems: 100, + items: { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', enum: ['customer_invoice', 'supplier_invoice'] }, + invoice_id: { type: 'string' }, + supplier_invoice_id: { type: 'string' }, + amount: { type: 'number', exclusiveMinimum: 0, description: 'Amount in TX currency. Cross-currency = bank-credited SEK.' }, + }, + required: ['kind', 'amount'], + }, + }, + }, + required: ['transaction_id', 'allocations'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const transactionId = args.transaction_id as string + const allocations = args.allocations as Array<{ + kind: 'customer_invoice' | 'supplier_invoice' + invoice_id?: string + supplier_invoice_id?: string + amount: number + }> + if (!transactionId) throw new Error('transaction_id is required') + if (!Array.isArray(allocations) || allocations.length === 0) { + throw new Error('allocations is required (non-empty array)') + } + + const { data: transaction, error: txError } = await supabase + .from('transactions') + .select('id, description, merchant_name, amount, currency, date, journal_entry_id') + .eq('id', transactionId) + .eq('company_id', companyId) + .single() + if (txError || !transaction) throw new Error('Transaction not found') + if (transaction.journal_entry_id) throw new Error('Transaction is already booked') + if (transaction.amount === 0) throw new Error('Transaction has zero amount') + + // Direction guard mirrors the RPC: customer_invoice → income, supplier_invoice → expense. + const hasCustomer = allocations.some((a) => a.kind === 'customer_invoice') + const hasSupplier = allocations.some((a) => a.kind === 'supplier_invoice') + if (hasCustomer && hasSupplier) { + throw new Error('Cannot mix customer_invoice and supplier_invoice allocations in one batch') + } + if (hasCustomer && transaction.amount <= 0) { + throw new Error('Customer allocations require an income transaction (amount > 0)') + } + if (hasSupplier && transaction.amount >= 0) { + throw new Error('Supplier allocations require an expense transaction (amount < 0)') + } + + // Per-allocation guard (Greptile P1): each row must carry the + // correct ID for its kind. The inputSchema marks both invoice_id + // and supplier_invoice_id as optional because they're mutually + // exclusive — but the JSON-Schema vocabulary can't express "X + // required iff Y=A". Check explicitly here. Round-8: also reject + // unexpected extra IDs (V4.5) — a customer_invoice row supplying + // supplier_invoice_id silently leaks the extra ID into preview_data. + for (const [i, a] of allocations.entries()) { + if (a.kind === 'customer_invoice') { + if (!a.invoice_id) { + throw new Error(`allocations[${i}]: invoice_id is required when kind=customer_invoice`) + } + if (a.supplier_invoice_id) { + throw new Error(`allocations[${i}]: supplier_invoice_id must not be set when kind=customer_invoice`) + } + } else if (a.kind === 'supplier_invoice') { + if (!a.supplier_invoice_id) { + throw new Error(`allocations[${i}]: supplier_invoice_id is required when kind=supplier_invoice`) + } + if (a.invoice_id) { + throw new Error(`allocations[${i}]: invoice_id must not be set when kind=supplier_invoice`) + } + } + } + + // Tenant-isolation pre-check (OWASP V8.2.1): verify every + // referenced invoice belongs to this company BEFORE staging. + // The RPC also re-checks this, but failing fast at the MCP + // layer gives the agent a clear error instead of an opaque + // BATCH_INVOICE_NOT_FOUND code at commit time. + const invoiceIds = allocations + .filter((a) => a.kind === 'customer_invoice') + .map((a) => a.invoice_id!) + const supplierInvoiceIds = allocations + .filter((a) => a.kind === 'supplier_invoice') + .map((a) => a.supplier_invoice_id!) + // Belt-and-suspenders (CC6.1): assert both count equality AND the + // missing-set is empty. The Supabase REST client de-dupes by PK so + // count >= unique input length is enough on its own, but the + // explicit guard prevents an undefined-row edge case in the JSON + // response from silently passing. + if (invoiceIds.length > 0) { + const uniqueIds = Array.from(new Set(invoiceIds)) + const { data: found } = await supabase + .from('invoices') + .select('id') + .in('id', uniqueIds) + .eq('company_id', companyId) + const foundRows = found ?? [] + const foundSet = new Set(foundRows.map((r) => r.id)) + const missing = uniqueIds.filter((id) => !foundSet.has(id)) + if (missing.length > 0 || foundRows.length !== uniqueIds.length) { + throw new Error(`Invoices not found for this company: ${missing.join(', ') || '(count mismatch)'}`) + } + } + if (supplierInvoiceIds.length > 0) { + const uniqueIds = Array.from(new Set(supplierInvoiceIds)) + const { data: found } = await supabase + .from('supplier_invoices') + .select('id') + .in('id', uniqueIds) + .eq('company_id', companyId) + const foundRows = found ?? [] + const foundSet = new Set(foundRows.map((r) => r.id)) + const missing = uniqueIds.filter((id) => !foundSet.has(id)) + if (missing.length > 0 || foundRows.length !== uniqueIds.length) { + throw new Error(`Supplier invoices not found for this company: ${missing.join(', ') || '(count mismatch)'}`) + } + } + + const totalAllocated = allocations.reduce((sum, a) => sum + a.amount, 0) + const txAbs = Math.abs(transaction.amount) + // 0.005 SEK tolerance is for floating-point equalisation only, + // NOT a rounding allowance. The RPC `match_batch_allocate` + // re-enforces the same guard (BATCH_AMOUNT_EXCEEDS_TX / + // BATCH_AMOUNT_BELOW_TX) authoritatively (per PR #607 round 3), + // and the verifikat lines balance exactly to the öre. + if (Math.abs(totalAllocated - txAbs) > 0.005) { + throw new Error( + `Allocations sum (${totalAllocated.toFixed(2)}) must equal transaction amount (${txAbs.toFixed(2)})` + ) + } + + const txDesc = transaction.merchant_name || transaction.description || transactionId + // Swedish plurals: kundfaktura → kundfakturor (not kundfakturaor). + // Same for leverantörsfaktura → leverantörsfakturor. + const noun = hasCustomer ? 'kundfaktura' : 'leverantörsfaktura' + const summary = `${allocations.length} ${allocations.length === 1 ? noun : `${noun.slice(0, -1)}or`}` + + return stagePendingOperation(supabase, companyId, userId, 'match_batch_allocate', + `Fördela: ${txDesc} → ${summary}`, + { transaction_id: transactionId, allocations }, + // GDPR Art.25: transaction_description is included in preview_data + // so the user can recognise the tx at approval time (merchant_name + // or fallback to bank description). Same trade-off documented on + // gnubok_link_transaction_to_journal_entry — it's the minimum + // signal needed for an informed approval. Counterparty-identifying + // invoice IDs stay in params (audit trail); they are NOT echoed + // back into preview_data beyond aggregate counts. + { + transaction_description: txDesc, + transaction_amount: transaction.amount, + transaction_currency: transaction.currency, + transaction_date: transaction.date, + allocations_count: allocations.length, + allocations_kind: hasCustomer ? 'customer_invoice' : 'supplier_invoice', + total_allocated: totalAllocated, + }, + actor, + { + description: 'After approval the combined verifikat is created and each invoice is advanced. Verify with gnubok_get_ar_ledger (customer) or gnubok_get_supplier_ledger.', + tool: hasCustomer ? 'gnubok_get_ar_ledger' : 'gnubok_get_supplier_ledger', + }, + { dateForPeriodCheck: transaction.date } + ) + }, + }, + + { + name: 'gnubok_link_transaction_to_journal_entry', + description: 'Link 1 bank tx to an already-posted verifikat (no new bokföring). Use when the user booked the affärshändelse manually. Pass invoice_id to also settle a kundfaktura. Stages.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + transaction_id: { type: 'string' }, + journal_entry_id: { type: 'string' }, + invoice_id: { type: 'string', description: 'Optional kundfaktura to settle alongside the link.' }, + }, + required: ['transaction_id', 'journal_entry_id'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const transactionId = args.transaction_id as string + const journalEntryId = args.journal_entry_id as string + const invoiceId = (args.invoice_id as string | undefined) ?? undefined + if (!transactionId || !journalEntryId) { + throw new Error('transaction_id and journal_entry_id are required') + } + + // Tenant-isolation + state pre-checks (OWASP V8.2.1). The commit + // handler re-validates authoritatively; failing fast at stage time + // gives the agent a clean error before the user is asked to approve. + const { data: tx, error: txError } = await supabase + .from('transactions') + .select('id, date, amount, currency, journal_entry_id, description, merchant_name') + .eq('id', transactionId) + .eq('company_id', companyId) + .maybeSingle() + if (txError || !tx) throw new Error('Transaction not found') + if (tx.journal_entry_id) { + throw new Error('Transaction is already linked to a journal entry') + } + + const { data: je, error: jeError } = await supabase + .from('journal_entries') + .select('id, status, voucher_series, voucher_number, entry_date') + .eq('id', journalEntryId) + .eq('company_id', companyId) + .maybeSingle() + if (jeError || !je) throw new Error('Journal entry not found') + if (je.status !== 'posted') { + throw new Error(`Journal entry must be posted (status=${je.status})`) + } + + let invoicePreview: { invoice_number: string | null; remaining: number | null; will_be_fully_paid: boolean } | null = null + if (invoiceId) { + // GDPR Art.5(1)(c): only the columns the preview displays. We need + // remaining_amount for the will-be-fully-paid math, invoice_number + // for the staged-op title, and currency so we can fast-fail the + // mismatch before the user is asked to approve (the commit handler + // re-checks authoritatively via LINK_TX_INVOICE_CURRENCY_MISMATCH). + const { data: invoice, error: invError } = await supabase + .from('invoices') + .select('id, invoice_number, status, currency, remaining_amount') + .eq('id', invoiceId) + .eq('company_id', companyId) + .maybeSingle() + if (invError || !invoice) throw new Error('Invoice not found') + if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) { + throw new Error(`Invoice is not in an open state (status=${invoice.status})`) + } + // Currency-mismatch pre-stage check (swedish-compliance PR #614 + // round 8). The link-to-existing-voucher contract requires tx and + // invoice currency to match — cross-currency settlement must go + // through the match-invoice flow that posts 3960/7960 FX-diff + // lines via buildInvoicePaymentClearingLines. Failing fast here + // saves the user an approval round-trip. + if (tx.currency !== invoice.currency) { + throw new Error( + `Transaction currency (${tx.currency}) does not match invoice currency (${invoice.currency}). Cross-currency settlement must go through the match-invoice flow.` + ) + } + // Explicit NaN guard (A.8.28): silently treating a malformed numeric + // column as 0 would let a bogus preview pass to the user. The DB + // column is NUMERIC NOT NULL on remaining_amount once status leaves + // 'draft', so a NaN here means something upstream is broken. + const remaining = Number(invoice.remaining_amount) + const txAmount = Number(tx.amount) + if (!Number.isFinite(remaining) || !Number.isFinite(txAmount)) { + throw new Error('Invoice remaining_amount or tx amount is not a finite number') + } + const newRemaining = Math.max(0, Math.round((remaining - txAmount) * 100) / 100) + invoicePreview = { + invoice_number: (invoice.invoice_number as string | null) ?? null, + remaining: newRemaining, + will_be_fully_paid: newRemaining <= 0, + } + } + + // Period-lock check uses the LATER of tx.date and je.entry_date so a + // tx in an open period attached to a verifikat in a locked period + // surfaces the period_status envelope correctly. Mirrors the same + // logic in gnubok_bulk_book_transactions. + const txDate = tx.date as string + const jeDate = je.entry_date as string + const periodCheckDate = jeDate > txDate ? jeDate : txDate + + // Centralised verifikat-label format (formatVoucherLabel) — keeps the + // MCP staging preview and the committed audit-trail label byte-identical, + // so BFL 5 kap 7§ traceability holds even if the format ever changes. + const voucherLabel = formatVoucherLabel( + je.voucher_series as string | null, + je.voucher_number as number | null, + ) + const txDesc = (tx.merchant_name as string | null) || (tx.description as string | null) || transactionId.slice(0, 8) + + return stagePendingOperation( + supabase, + companyId, + userId, + 'link_transaction_journal_entry', + invoiceId + ? `Länka ${txDesc} → verifikat ${voucherLabel} + faktura ${invoicePreview?.invoice_number ?? invoiceId.slice(0, 8)}` + : `Länka ${txDesc} → verifikat ${voucherLabel}`, + { transaction_id: transactionId, journal_entry_id: journalEntryId, invoice_id: invoiceId ?? null }, + // GDPR Art.25: voucher_description is intentionally OMITTED from + // preview_data — it can carry free-text merchant/counterparty PII + // and the voucher_label alone uniquely identifies the verifikat for + // the user's approval decision. Same reasoning as the per-tx + // description handling elsewhere in this file. + { + transaction_description: txDesc, + transaction_amount: tx.amount, + transaction_currency: tx.currency, + transaction_date: txDate, + voucher_label: voucherLabel, + voucher_date: jeDate, + invoice_id: invoiceId ?? null, + invoice_number: invoicePreview?.invoice_number ?? null, + invoice_remaining_after: invoicePreview?.remaining ?? null, + will_be_fully_paid: invoicePreview?.will_be_fully_paid ?? null, + }, + actor, + { + description: invoiceId + ? 'After approval the tx attaches to the existing verifikat and the invoice flips to paid/partially_paid. No new bokföring. Verify with gnubok_get_ar_ledger.' + : 'After approval the tx attaches to the existing verifikat. No new bokföring. Verify with gnubok_query_journal.', + tool: invoiceId ? 'gnubok_get_ar_ledger' : 'gnubok_query_journal', + }, + { dateForPeriodCheck: periodCheckDate } + ) + }, + }, + + { + name: 'gnubok_bulk_book_transactions', + description: 'Bulk-book N bank txs on the same date into 1 samlingsverifikat (BFL 5 kap 6§). Two paths: link N txs to an existing posted verifikat, or create a new verifikat from caller-supplied lines. All txs must share date + direction. Docs on the txs inherit. Stages.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + tx_ids: { type: 'array', minItems: 1, maxItems: 200, items: { type: 'string' } }, + existing_journal_entry_id: { type: 'string' }, + new_entry: { + type: 'object', + additionalProperties: false, + properties: { + description: { type: 'string', minLength: 1, maxLength: 500 }, + lines: { + type: 'array', + minItems: 2, + maxItems: 200, + items: { + type: 'object', + additionalProperties: false, + properties: { + account_number: { type: 'string', pattern: '^\\d{4}$' }, + debit_amount: { type: 'number', minimum: 0 }, + credit_amount: { type: 'number', minimum: 0 }, + currency: { type: 'string', minLength: 3, maxLength: 3 }, + line_description: { type: 'string', maxLength: 200 }, + }, + required: ['account_number', 'debit_amount', 'credit_amount', 'currency'], + }, + }, + }, + required: ['description', 'lines'], + }, + }, + required: ['tx_ids'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const txIds = args.tx_ids as string[] + const existingJeId = (args.existing_journal_entry_id as string | undefined) ?? null + const newEntry = (args.new_entry as { description: string; lines: unknown[] } | undefined) ?? null + if (!Array.isArray(txIds) || txIds.length === 0) throw new Error('tx_ids is required (non-empty)') + if ((existingJeId == null) === (newEntry == null)) { + throw new Error('Provide exactly one of existing_journal_entry_id or new_entry') + } + + // Balance pre-check on the create-new path (compliance-swarm V2.3 + // / swedish-compliance). The RPC also rejects with + // BULK_BOOK_UNBALANCED, but failing fast here lets the agent get + // a clear error before staging is even attempted. + // The 0.005 tolerance is for floating-point equalisation only, + // NOT a rounding allowance per BFL 5 kap 4–5§. The RPC enforces + // exact balance to the öre on insert. + if (newEntry) { + const lines = (newEntry as { lines?: Array<{ debit_amount?: number; credit_amount?: number }> }).lines + if (Array.isArray(lines) && lines.length > 0) { + // Reject NaN / non-finite values explicitly (A.8.28). + // `Number(x) || 0` silently treats NaN as 0; that would let + // a malformed amount pass the balance check by accident. + // Round-8 addition: reject debit=0 && credit=0 "ghost" lines + // (BFL 5 kap 6§ — every line must represent a real + // bokföringspost with a non-zero amount). + for (const [i, l] of lines.entries()) { + const d = Number(l.debit_amount) + const c = Number(l.credit_amount) + if (!Number.isFinite(d) || !Number.isFinite(c)) { + throw new Error(`new_entry.lines[${i}]: debit_amount and credit_amount must be finite numbers`) + } + if (d === 0 && c === 0) { + throw new Error(`new_entry.lines[${i}]: debit_amount and credit_amount cannot both be zero (BFL 5 kap 6§)`) + } + } + const totalDebit = lines.reduce((s, l) => s + Number(l.debit_amount), 0) + const totalCredit = lines.reduce((s, l) => s + Number(l.credit_amount), 0) + if (Math.abs(totalDebit - totalCredit) > 0.005) { + throw new Error( + `new_entry.lines must balance — debits=${totalDebit.toFixed(2)} credits=${totalCredit.toFixed(2)}` + ) + } + } + } + + const { data: txs, error: txError } = await supabase + .from('transactions') + .select('id, amount, currency, date, journal_entry_id') + .in('id', txIds) + .eq('company_id', companyId) + if (txError || !txs || txs.length !== txIds.length) { + throw new Error('One or more transactions not found') + } + const booked = txs.find((t) => t.journal_entry_id != null) + if (booked) throw new Error(`Transaction ${booked.id} is already booked`) + const dates = new Set(txs.map((t) => t.date)) + if (dates.size > 1) throw new Error('All transactions must share the same date') + // Reject zero-amount txs (round-8 / A.8.28). The direction computation + // below treats amount === 0 as 'expense' (amount > 0 is false), which + // would then mis-classify a real income tx in the same batch. Mirrors + // the explicit zero-amount guard in gnubok_match_batch_allocate. + const zeroAmountTx = txs.find((t) => t.amount === 0) + if (zeroAmountTx) throw new Error(`Transaction ${zeroAmountTx.id} has zero amount`) + const direction = txs[0]!.amount > 0 ? 'income' : 'expense' + if (txs.some((t) => (direction === 'income' ? t.amount < 0 : t.amount > 0))) { + throw new Error('All transactions must share the same direction (all income or all expense)') + } + // Currency homogeneity (swedish-compliance): a samlingsverifikat + // combining e.g. SEK + EUR txs without explicit FX lines violates + // BFL 5 kap 2§ (alla belopp skall uttryckas i svenska kronor) + // read together with the valutakurs rules in BFL 5 kap 6§. + // Cross-currency batches should go through gnubok_match_batch_allocate + // (which handles the FX diff on 7960/3960). Reject mixed currencies here. + const currencies = new Set(txs.map((t) => t.currency)) + if (currencies.size > 1) { + // Route the agent to the cross-currency-capable tool rather + // than letting it retry with hand-built FX lines. + throw new Error( + 'All transactions must share the same currency. For cross-currency allocations, use gnubok_match_batch_allocate (which handles the FX diff on 7960/3960).' + ) + } + + const txSum = txs.reduce((s, t) => s + t.amount, 0) + const txDate = txs[0]!.date as string + + // For link-existing branch, also fetch the target JE and use the + // LATER of tx_date and JE.entry_date for period-lock check + // (swedish-compliance): otherwise a tx in an open period could be + // attached to a verifikat in a closed period and the guard + // would miss it. Same query also enforces tenant isolation on + // the JE (OWASP V8.2.1) before the RPC sees the ID. + let periodCheckDate = txDate + if (existingJeId) { + const { data: je, error: jeError } = await supabase + .from('journal_entries') + .select('id, entry_date, status') + .eq('id', existingJeId) + .eq('company_id', companyId) + .maybeSingle() + if (jeError || !je) { + throw new Error('Existing journal entry not found for this company') + } + if (je.status !== 'posted') { + throw new Error(`Existing journal entry must be posted (status=${je.status})`) + } + // Pass the later date so the period-lock guard fires on whichever + // side is in a locked/closed period. + periodCheckDate = (je.entry_date as string) > txDate ? (je.entry_date as string) : txDate + } + + return stagePendingOperation(supabase, companyId, userId, 'bulk_book_transactions', + existingJeId + ? `Länka ${txIds.length} transaktioner till verifikat (${txDate})` + : `Samlingsverifikation: ${txIds.length} transaktioner ${txDate}`, + { + tx_ids: txIds, + existing_journal_entry_id: existingJeId, + new_entry: newEntry, + }, + // GDPR Art.25: preview_data carries only aggregate counts + the + // shared date/direction — no per-tx descriptions, no per-line + // descriptions, no counterparty IDs. The user-facing approval + // dialog reconstructs detail from the tx_ids list at render time + // rather than persisting denormalized PII here. Same privacy-by- + // design rationale as gnubok_link_transaction_to_journal_entry. + { + tx_count: txIds.length, + tx_date: txDate, + tx_sum: txSum, + direction, + mode: existingJeId ? 'link_existing' : 'create_new', + }, + actor, + { + description: 'After approval the verifikat carries the combined business event. Verify with gnubok_query_journal or gnubok_get_reconciliation_status.', + tool: 'gnubok_query_journal', + }, + { dateForPeriodCheck: periodCheckDate } + ) + }, + }, + { name: 'gnubok_find_voucher_candidates_for_invoice', description: 'List posted verifikat that credit kundfordran (1510) and could be the payment for this invoice. Use before gnubok_link_invoice_to_voucher when the user wants to mark a faktura paid against an existing verifikation (no new bokföring).', diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 0aaa5374..9a817522 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -144,6 +144,9 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_get_counterparty_templates: 'transactions:read', gnubok_suggest_categories: 'transactions:read', gnubok_match_transaction_to_invoice: 'transactions:write', + gnubok_link_transaction_to_journal_entry: 'transactions:write', + gnubok_match_batch_allocate: 'transactions:write', + gnubok_bulk_book_transactions: 'transactions:write', gnubok_auto_match_period: 'transactions:write', // Customers gnubok_list_customers: 'customers:read', diff --git a/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts b/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts new file mode 100644 index 00000000..5f2b8fb3 --- /dev/null +++ b/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest' +import { buildInvoicePaymentClearingLines } from '../invoice-payment-lines' + +describe('buildInvoicePaymentClearingLines', () => { + describe('same currency (SEK invoice + SEK tx)', () => { + it('full payment: 1930 = 1510 = tx amount, no FX line', () => { + const result = buildInvoicePaymentClearingLines( + { amount: 1250, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'SEK', exchange_rate: null, remaining_amount: 1250, total: 1250, paid_amount: 0 }, + 'Inbetalning kundfaktura', + ) + expect(result.bankSek).toBe(1250) + expect(result.arSek).toBe(1250) + expect(result.fxDiffSek).toBe(0) + expect(result.lines).toHaveLength(2) + expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 1250, credit_amount: 0 }) + expect(result.lines[1]).toMatchObject({ account_number: '1510', debit_amount: 0, credit_amount: 1250 }) + }) + + it('partial payment: 1930 = 1510 = tx amount (the actual SEK received)', () => { + // Scenario from the user: invoice 1 250, prior 230 partial, now 1 000 hits. + // 1930/1510 must equal 1 000 (not 1 250). After this verifikat the invoice + // remaining is 20 SEK and status stays partially_paid (handled by the + // caller, not this helper). + const result = buildInvoicePaymentClearingLines( + { amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'SEK', exchange_rate: null, remaining_amount: 1020, total: 1250, paid_amount: 230 }, + 'Inbetalning kundfaktura', + ) + expect(result.bankSek).toBe(1000) + expect(result.arSek).toBe(1000) + expect(result.fxDiffSek).toBe(0) + expect(result.lines).toHaveLength(2) + }) + + it('expense tx (negative amount) treats absolute SEK value', () => { + const result = buildInvoicePaymentClearingLines( + { amount: -500, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'SEK', exchange_rate: null, remaining_amount: 500, total: 500, paid_amount: 0 }, + 'desc', + ) + expect(result.bankSek).toBe(500) + expect(result.arSek).toBe(500) + }) + }) + + describe('cross currency (USD invoice + SEK tx)', () => { + it('bank received MORE SEK than booked: gain to 3960', () => { + // Invoice 100 USD booked at 10.00 (1000 SEK on 1510) + // Bank receives 1100 SEK (rate moved to 11.00 by payment date) + // FX gain = 100 SEK → 3960 credit + const result = buildInvoicePaymentClearingLines( + { amount: 1100, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'USD', exchange_rate: 10, remaining_amount: 100, total: 100, paid_amount: 0 }, + 'Inbetalning kundfaktura', + ) + expect(result.bankSek).toBe(1100) + expect(result.arSek).toBe(1000) + expect(result.fxDiffSek).toBe(-100) + expect(result.lines).toHaveLength(3) + expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 1100 }) + expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 1000 }) + expect(result.lines[2]).toMatchObject({ + account_number: '3960', + credit_amount: 100, + line_description: 'Valutakursvinst', + }) + // Balanced + const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0) + const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0) + expect(Math.round((debit - credit) * 100)).toBe(0) + }) + + it('ambiguous loss scenario (bank < SEK booked) is treated as partial — defers FX', () => { + // Invoice 100 USD booked at 10.50 (1050 SEK on 1510) + // Bank receives 1000 SEK — could be (a) partial payment that didn't + // cover the full USD amount, or (b) full payment at a worse FX rate. + // From a SEK-only bank tx we can't distinguish; defaulting to "partial" + // is the safer choice (no premature 1510 zeroing). If the user knows + // it's actually a full-clear-with-loss, they use mark-paid with an + // explicit exchange_rate_difference instead. + const result = buildInvoicePaymentClearingLines( + { amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'USD', exchange_rate: 10.5, remaining_amount: 100, total: 100, paid_amount: 0 }, + 'Inbetalning kundfaktura', + ) + expect(result.bankSek).toBe(1000) + expect(result.arSek).toBe(1000) + expect(result.fxDiffSek).toBe(0) + expect(result.lines).toHaveLength(2) + }) + + it('partial cross-currency payment defers FX: bank-leg = AR-leg = bankSek, no 3960/7960 line', () => { + // Invoice 140 USD @ 15.30 (2142 SEK booked on 1510) + // Bank receives 230 SEK — way below the 2142 remaining. If we credited + // the full 2142 to 1510 we'd zero the GL balance while the invoice row + // stayed partially_paid (BFL 5 kap 4–5§ violation). Defer FX to the + // final settlement that closes the invoice. + const result = buildInvoicePaymentClearingLines( + { amount: 230, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'USD', exchange_rate: 15.3, remaining_amount: 140, total: 140, paid_amount: 0 }, + 'Delbetalning kundfaktura', + ) + expect(result.bankSek).toBe(230) + expect(result.arSek).toBe(230) + expect(result.fxDiffSek).toBe(0) + expect(result.lines).toHaveLength(2) + expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 230 }) + expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 230 }) + }) + + it('exact match: no FX line', () => { + // Invoice 100 USD @ 10.00 (1000 SEK booked); bank receives 1000 SEK + const result = buildInvoicePaymentClearingLines( + { amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'USD', exchange_rate: 10, remaining_amount: 100, total: 100, paid_amount: 0 }, + 'desc', + ) + expect(result.bankSek).toBe(1000) + expect(result.arSek).toBe(1000) + expect(result.fxDiffSek).toBe(0) + expect(result.lines).toHaveLength(2) + }) + + it('sub-öre FX diff is suppressed (within floating-point tolerance)', () => { + // 100.001 USD × 10 = 1000.01, but bookkeeping rounds at the line level + const result = buildInvoicePaymentClearingLines( + { amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { + currency: 'USD', + exchange_rate: 10, + remaining_amount: 100.0001, + total: 100.0001, + paid_amount: 0, + }, + 'desc', + ) + expect(Math.abs(result.fxDiffSek)).toBeLessThanOrEqual(0.005) + expect(result.lines).toHaveLength(2) + }) + }) + + describe('cross currency (USD invoice + USD tx)', () => { + it('uses tx amount_sek for the bank-leg when populated', () => { + // USD-denominated bank account paying a USD invoice — ingest converts + // tx → SEK using the bank-date rate. + const result = buildInvoicePaymentClearingLines( + { amount: 100, amount_sek: 1100, currency: 'USD', exchange_rate: 11 }, + { currency: 'USD', exchange_rate: 10, remaining_amount: 100, total: 100, paid_amount: 0 }, + 'desc', + ) + // Same-currency path: bank-leg uses resolveSekAmount (which honours + // amount_sek), AR-leg equals bank-leg, no FX diff line. + expect(result.bankSek).toBe(1100) + expect(result.arSek).toBe(1100) + expect(result.fxDiffSek).toBe(0) + }) + }) +}) diff --git a/lib/bookkeeping/invoice-payment-lines.ts b/lib/bookkeeping/invoice-payment-lines.ts new file mode 100644 index 00000000..58b68b6e --- /dev/null +++ b/lib/bookkeeping/invoice-payment-lines.ts @@ -0,0 +1,207 @@ +/** + * Builds the journal-entry lines for the clearing entry that closes (fully + * or partially) a customer invoice against an actual bank transaction. + * + * The lines built here are the "Inbetalning kundfaktura" path under + * faktureringsmetoden (accrual) — Dr 1930 / Cr 1510, with a 3960/7960 + * FX-diff line when the invoice and the bank tx are in different currencies. + * + * Shared between: + * - GET /api/transactions/[id]/match-invoice/preview (read-only, drives + * the dialog the user confirms against) + * - POST /api/transactions/[id]/match-invoice (the commit path) + * + * Single source of truth so the preview and the committed verifikat are + * byte-identical. Earlier the two diverged on the cross-currency math — + * the preview ran `resolveSekAmount(tx.amount, null, INV.currency, INV.rate)`, + * treating the SEK tx number as if it were in the invoice's currency and + * multiplying by the invoice's stored rate. That produced a fictitious + * bank-leg amount and silently dropped the FX gain/loss. + * + * # Customer-invoice only + * + * This helper is the CUSTOMER side (kundfaktura): AR account 1510, FX gain + * 3960 (valutakursvinster rörelsefordringar), FX loss 7960 + * (valutakursförluster rörelsefordringar), bank-leg = Dr. Supplier-side + * settlement has the opposite DR/CR polarity (Cr 1930 / Dr 2440-series) and + * a different account taxonomy; it lives in the match_batch_allocate RPC, + * not here. Do not call this helper from supplier-invoice flows. + * + * # Currency model + * + * tx.currency — currency of the bank tx (almost always SEK) + * tx.amount — amount in tx.currency + * tx.exchange_rate — populated at ingest only when tx.currency != SEK + * tx.amount_sek — pre-computed SEK at ingest for non-SEK tx + * invoice.currency — currency the invoice was issued in + * invoice.exchange_rate — the rate at which AR was originally booked on 1510 + * + * Bank-leg (1930) = always the actual SEK that hit the bank. + * AR-leg (1510) = the SEK value of the customer-debt reduction at the + * INVOICE's stored rate (capped to bankSek on partials + * to keep 1510 in sync with invoice.remaining_amount). + * FX diff = (AR-leg SEK − Bank-leg SEK); sign drives 3960 vs 7960. + * Per BFL 5 kap 4–5§ every verifikat must balance to the + * öre; the FX diff line is what makes the cross-currency + * verifikat balance. Only emitted when the bank tx fully + * clears the invoice's remaining — partials defer the + * FX adjustment to the final settlement to avoid + * prematurely zeroing 1510 while the AR row still says + * partially_paid. + */ +import type { CreateJournalEntryLineInput } from '@/types' +import { resolveSekAmount } from './currency-utils' + +const TWO_DP = (n: number): number => Math.round(n * 100) / 100 + +export interface PaymentClearingTx { + amount: number + amount_sek: number | null + currency: string + exchange_rate: number | null +} + +export interface PaymentClearingInvoice { + currency: string + exchange_rate: number | null + remaining_amount: number | null + total: number + paid_amount: number | null +} + +export interface PaymentClearingLines { + /** Actual SEK that hit the bank. The 1930 debit. */ + bankSek: number + /** SEK value of the AR reduction at the invoice's stored rate. The 1510 credit. */ + arSek: number + /** + * fxDiffSek = arSek − bankSek (this orientation matches what's needed to + * make the verifikat balance: positive value goes Dr 7960, negative + * value goes Cr 3960). + * + * Sign reading (note this is the OPPOSITE of an intuitive "profit" + * orientation — the value here is a balance-adjustment magnitude, not a + * P&L number, because AR is the side being cleared): + * positive → bank received FEWER SEK than AR was booked at → kursförlust → 7960 Dr + * negative → bank received MORE SEK than AR was booked at → kursvinst → 3960 Cr + * |value| ≤ 0.005 → no FX diff line emitted (floating-point tolerance, + * NOT a rounding allowance per BFL 5 kap 4–5§) + * + * If you want an intuitive "gain" number for UI display, use + * `bankSek - arSek` (negate this field). Do not consume the raw sign + * in caller logic without reading this paragraph. + */ + fxDiffSek: number + lines: CreateJournalEntryLineInput[] +} + +/** + * Build the verifikat lines for a customer-invoice payment matched against + * a bank tx. Pure — no DB calls. Caller decides how to persist. + * + * For same-currency invoices the FX diff is always 0 and only the two + * bank/AR lines are returned. For cross-currency, a 3960 or 7960 line is + * appended to balance the verifikat. Per the contract documented in this + * file, when the tx is cross-currency we assume the bank tx fully clears + * the invoice's remaining amount and book the full FX diff to one + * verifikat — same pattern as the match_batch_allocate RPC, which is the + * only other code path that posts FX diffs on customer-invoice + * settlements. + */ +export function buildInvoicePaymentClearingLines( + tx: PaymentClearingTx, + invoice: PaymentClearingInvoice, + description: string, +): PaymentClearingLines { + // Bank-leg: actual SEK that hit the bank. resolveSekAmount returns the + // raw amount for SEK txs and amount * exchange_rate for foreign txs + // (preferring the pre-computed amount_sek when set). + const bankSek = TWO_DP( + resolveSekAmount( + Math.abs(tx.amount), + tx.amount_sek != null ? Math.abs(tx.amount_sek) : null, + tx.currency, + tx.exchange_rate, + ), + ) + + const sameCurrency = tx.currency === invoice.currency + const invoiceIsForeign = invoice.currency !== 'SEK' + + let arSek: number + let fxDiffSek: number + + if (sameCurrency || !invoiceIsForeign) { + // Same currency (or SEK invoice paid by SEK tx): the customer-debt + // reduction equals what hit the bank. No FX diff possible. + arSek = bankSek + fxDiffSek = 0 + } else { + // Cross-currency: AR is denominated in invoice.currency and was + // booked on 1510 at invoice.exchange_rate. The remaining-amount × rate + // is the SEK currently sitting on 1510 for this invoice. + const invRemainingForeign = invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0) + const invRate = invoice.exchange_rate ?? 1 + const arSekFullRemaining = TWO_DP(invRemainingForeign * invRate) + + // Branch on whether the bank tx fully clears (or over-pays) the + // remaining 1510 balance. Partial cross-currency must NOT credit the + // full remaining — that would zero 1510 in the GL while the invoice + // row stays at status=partially_paid, leaving the ledger inconsistent + // with the AR sub-ledger and over-stating FX gain/loss for the period. + // Defer the FX adjustment to the final settlement (when bank-SEK + // covers the full remaining), per BFL 5 kap 4–5§ "verifikat must + // reflect the actual affärshändelse". + if (bankSek >= arSekFullRemaining - 0.005) { + // Full payment of remaining (or overpay): clear AR and book FX diff. + arSek = arSekFullRemaining + fxDiffSek = TWO_DP(arSek - bankSek) + } else { + // Partial cross-currency: book 1930 / 1510 at bankSek (the actual + // SEK that moved), no FX line. The deferred FX diff lands on the + // verifikat that finally closes the invoice. + arSek = bankSek + fxDiffSek = 0 + } + } + + const lines: CreateJournalEntryLineInput[] = [ + { + account_number: '1930', + debit_amount: bankSek, + credit_amount: 0, + line_description: description, + }, + { + account_number: '1510', + debit_amount: 0, + credit_amount: arSek, + line_description: description, + }, + ] + + // Tolerance of 0.005 SEK is for floating-point equalisation only, not a + // rounding allowance per BFL 5 kap 4–5§. Same rationale as the balance + // pre-check in gnubok_bulk_book_transactions. + if (Math.abs(fxDiffSek) > 0.005) { + if (fxDiffSek > 0) { + // arSek > bankSek → bank received fewer SEK than booked. Loss → 7960 debit. + lines.push({ + account_number: '7960', + debit_amount: Math.abs(fxDiffSek), + credit_amount: 0, + line_description: 'Valutakursförlust', + }) + } else { + // bankSek > arSek → bank received more SEK than booked. Gain → 3960 credit. + lines.push({ + account_number: '3960', + debit_amount: 0, + credit_amount: Math.abs(fxDiffSek), + line_description: 'Valutakursvinst', + }) + } + } + + return { bankSek, arSek, fxDiffSek, lines } +} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 35685a9a..1509d367 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -346,6 +346,13 @@ const MATCH_INVOICE: Record = { message_sv: 'Endast fakturor kan matchas mot en transaktion. Proforma och följesedel saknar momsskyldighet.', message_en: 'Only invoices may be matched to a transaction; proforma and delivery notes have no VAT obligation.', }, + MATCH_INVOICE_CURRENCY_MISMATCH: { + httpStatus: 400, + message_sv: + 'Transaktionens och fakturans valuta måste vara samma. För valutaomräkning, använd flerfaktura-matchningen som hanterar valutakursdifferenser på 3960/7960.', + message_en: + 'Transaction and invoice currency must match. For cross-currency settlement, use the multi-invoice allocation flow which posts FX-diff lines on 3960/7960.', + }, MATCH_INVOICE_ALREADY_PAID: { httpStatus: 409, message_sv: 'Fakturan har redan slutbetalats av en annan förfrågan.', @@ -434,6 +441,13 @@ const LINK_TX_JE: Record = { message_sv: 'Fakturan ändrades samtidigt. Försök igen.', message_en: 'Invoice status changed concurrently. Retry the request.', }, + LINK_TX_INVOICE_CURRENCY_MISMATCH: { + httpStatus: 400, + message_sv: + 'Transaktionens och fakturans valuta måste vara samma för att länka till en befintlig verifikation. Använd matchningsdialogen för valutaomräkning.', + message_en: + 'Transaction and invoice currency must match to link to an existing voucher. Use the match-invoice flow for cross-currency settlement.', + }, } const MATCH_SI: Record = { diff --git a/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts b/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts new file mode 100644 index 00000000..73dcf734 --- /dev/null +++ b/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts @@ -0,0 +1,263 @@ +/** + * Unit tests for commitLinkTransactionJournalEntry. + * Driven through the public commitPendingOperation dispatcher. + * + * The MCP tool gnubok_link_transaction_to_journal_entry stages a + * 'link_transaction_journal_entry' pending_operation; this dispatcher + * picks it up, the executor delegates to the shared service in + * lib/transactions/link-journal-entry.ts. The service is also covered + * indirectly by the REST route test + * app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts — + * these tests focus on the dispatcher/executor wiring. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { createQueuedMockSupabase, makeTransaction, makeInvoice } from '@/tests/helpers' +import type { PendingOperation } from '@/types' + +vi.mock('@/lib/invoices/match-log', () => ({ + logMatchEvent: vi.fn(), +})) + +import { commitPendingOperation } from '../commit' + +const TX_UUID = '550e8400-e29b-41d4-a716-446655440000' +const JE_UUID = '550e8400-e29b-41d4-a716-446655440001' +const INV_UUID = '550e8400-e29b-41d4-a716-446655440002' + +function makePendingOp(overrides: Partial): PendingOperation { + return { + id: 'op-1', + user_id: 'user-1', + company_id: 'company-1', + operation_type: 'link_transaction_journal_entry', + status: 'pending', + title: 'test', + params: {}, + preview_data: {}, + result_data: null, + actor_type: 'user', + actor_id: null, + actor_label: null, + risk_level: 'medium', + created_at: '2026-05-30T00:00:00Z', + resolved_at: null, + updated_at: '2026-05-30T00:00:00Z', + ...overrides, + } as PendingOperation +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) + +describe('commitPendingOperation: link_transaction_journal_entry', () => { + it('returns 400 when transaction_id is missing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ params: { journal_entry_id: JE_UUID } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/transaction_id/i) + }) + + it('returns 404 when transaction not found', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: { message: 'not found' } }) // tx fetch + enqueue({ data: null, error: null }) // dispatcher's auto-reject update + + const op = makePendingOp({ + params: { transaction_id: TX_UUID, journal_entry_id: JE_UUID }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + // 404 is auto-rejected by the dispatcher (so the user can re-stage with + // adjusted inputs); the originating http_status is preserved on the + // result for the caller to inspect. + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(404) + }) + + it('returns 400 when transaction already linked', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: 'je-prior' }), + error: null, + }) + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + params: { transaction_id: TX_UUID, journal_entry_id: JE_UUID }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/already linked/i) + }) + + it('returns 400 when JE is not posted', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'draft', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + params: { transaction_id: TX_UUID, journal_entry_id: JE_UUID }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/posted/i) + }) + + it('happy path: links tx without invoice, no new bookkeeping created', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 12, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ data: null, error: null }) // tx UPDATE + enqueue({ data: null, error: null }) // logMatchEvent insert + enqueue({ data: null, error: null }) // dispatcher commit update + + const op = makePendingOp({ + params: { transaction_id: TX_UUID, journal_entry_id: JE_UUID }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + transaction_id: TX_UUID, + journal_entry_id: JE_UUID, + voucher_label: 'A-12', + invoice_id: null, + invoice_status: null, + }) + }) + + it('happy path with invoice: links tx, flips invoice to paid', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ + data: makeInvoice({ + id: INV_UUID, + status: 'sent', + total: 1000, + remaining_amount: 1000, + paid_amount: 0, + currency: 'SEK', + }), + error: null, + }) + enqueue({ data: null, error: null }) // tx UPDATE + enqueue({ data: [{ id: INV_UUID }], error: null }) // optimistic-lock invoice UPDATE + enqueue({ data: null, error: null }) // invoice_payments INSERT + enqueue({ data: null, error: null }) // logMatchEvent insert + enqueue({ data: null, error: null }) // dispatcher commit update + + const op = makePendingOp({ + params: { + transaction_id: TX_UUID, + journal_entry_id: JE_UUID, + invoice_id: INV_UUID, + }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + invoice_id: INV_UUID, + invoice_status: 'paid', + paid_amount: 1000, + remaining_amount: 0, + }) + }) + + it('returns 409 LINK_TX_INVOICE_RACE when optimistic lock loses', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ + data: makeInvoice({ id: INV_UUID, status: 'sent', total: 1000, remaining_amount: 1000 }), + error: null, + }) + enqueue({ data: null, error: null }) // tx UPDATE succeeds + enqueue({ data: [], error: null }) // optimistic invoice UPDATE returns 0 rows + enqueue({ data: null, error: null }) // compensating rollback restores tx + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + params: { + transaction_id: TX_UUID, + journal_entry_id: JE_UUID, + invoice_id: INV_UUID, + }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + // 409 is auto-rejected by the dispatcher (a fresh stage with the latest + // invoice state will succeed if the racing payer didn't already settle). + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index e7e8b763..95d1ca33 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -40,6 +40,7 @@ import { createSupplierInvoiceRegistrationEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching' +import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry' import { getErrorEntry } from '@/lib/errors/structured-errors' import { parseSIEFile } from '@/lib/import/sie-parser' import { executeSIEImport, undoSIEImport } from '@/lib/import/sie-import' @@ -2615,6 +2616,197 @@ async function commitGenerateAgi( } } +// ── Multi-tx commit handlers (PRs #603/#606/#608/#610) ──────────── +// +// Both wrap their SQL RPC. The RPCs do all the heavy lifting (locking, +// balance/period checks, journal entry creation, voucher number, +// payment/junction rows, doc inheritance). The commit handlers just +// shape params, call the RPC, and translate the structured error code +// or success payload into an ExecutorResult. + +async function commitMatchBatchAllocate( + supabase: SupabaseClient, + companyId: string, + params: Record +): Promise { + // Trust boundary (compliance-swarm V8.2.1, A.8.2): + // Tenant isolation is enforced authoritatively inside the SQL RPC + // `match_batch_allocate` (supabase/migrations/20260601122000_*.sql): + // - `transactions` row fetched WHERE id = p_tx_id AND company_id = p_company_id + // - `invoices` and `supplier_invoices` rows fetched WHERE id = ? AND company_id = p_company_id + // - `auth.uid()` resolves the caller; membership checked against + // `company_members.company_id = p_company_id` + // The MCP execute() handler additionally pre-checks the same IDs to + // surface clean errors before staging. This commit handler is a thin + // pass-through by design — re-querying here would triple the same + // check without adding security. + const txId = params.transaction_id as string + const allocations = params.allocations + if (!txId) return { error: 'transaction_id is required', status: 400 } + if (!Array.isArray(allocations) || allocations.length === 0) { + return { error: 'allocations is required (non-empty array)', status: 400 } + } + const { data, error } = await supabase.rpc('match_batch_allocate', { + p_tx_id: txId, + p_allocations: allocations, + p_company_id: companyId, + }) + if (error) { + // Sanitised log (A.8.11, CC7.2): only error code + message, no + // payload — error.details can echo invoice IDs, amounts, etc. + log.error('match_batch_allocate RPC error', { + code: (error as { code?: string }).code, + message: error.message, + }) + return { error: error.message || 'Database error', status: 500 } + } + const result = data as { ok: boolean; code?: string; details?: unknown; journal_entry_id?: string } + if (!result || !result.ok) { + return { + error: result?.code || 'match_batch_allocate failed', + status: 400, + data: result?.details as Record | undefined, + } + } + // Structured audit-trail entry on success (compliance-swarm V16). Tx + // count + JE id + the source tx id only — no amounts, no + // counterparty identifiers, no descriptions. txId is included + // intentionally so the audit trail can join successful commits back + // to the source bank tx without a separate query; it's not PII on + // its own (just an internal UUID, scoped to companyId already logged). + log.info('match_batch_allocate committed', { + companyId, + operationType: 'match_batch_allocate', + journalEntryId: result.journal_entry_id, + txId, + allocationCount: allocations.length, + }) + return { data: result as unknown as Record, status: 200 } +} + +async function commitBulkBookTransactions( + supabase: SupabaseClient, + companyId: string, + params: Record +): Promise { + // Trust boundary (compliance-swarm V8.2.1, A.8.2): + // Tenant isolation + chart-of-accounts validation are enforced + // authoritatively inside the SQL RPC `bulk_book_transactions` + // (supabase/migrations/20260602121000_*.sql): + // - All `transactions` rows fetched WHERE id = ANY(p_tx_ids) AND + // company_id = p_company_id (line ~115). + // - `journal_entries` row (link-existing branch) fetched WHERE id = + // p_existing_journal_entry_id AND company_id = p_company_id. + // - Every account_number in p_new_entry.lines validated against + // `chart_of_accounts` filtered by company_id + is_active (PR #610 + // round 2 added this allowlist). + // - `auth.uid()` resolves the caller; membership checked against + // `company_members.company_id = p_company_id`. + // The MCP execute() handler additionally pre-checks tx ownership + + // JE ownership at stage time to surface clean errors. This commit + // handler is a thin pass-through by design. + const txIds = params.tx_ids + const existingJeId = (params.existing_journal_entry_id as string | null | undefined) ?? null + const newEntry = (params.new_entry as Record | null | undefined) ?? null + if (!Array.isArray(txIds) || txIds.length === 0) { + return { error: 'tx_ids is required (non-empty array)', status: 400 } + } + if ((existingJeId == null) === (newEntry == null)) { + return { + error: 'Provide exactly one of existing_journal_entry_id or new_entry', + status: 400, + } + } + const { data, error } = await supabase.rpc('bulk_book_transactions', { + p_tx_ids: txIds, + p_existing_journal_entry_id: existingJeId, + p_new_entry: newEntry, + p_company_id: companyId, + }) + if (error) { + // Sanitised log (A.8.11, CC7.2): only error code + message. + log.error('bulk_book_transactions RPC error', { + code: (error as { code?: string }).code, + message: error.message, + }) + return { error: error.message || 'Database error', status: 500 } + } + const result = data as { ok: boolean; code?: string; details?: unknown; journal_entry_id?: string; mode?: string; linked_tx_count?: number; docs_linked?: number } + if (!result || !result.ok) { + return { + error: result?.code || 'bulk_book_transactions failed', + status: 400, + data: result?.details as Record | undefined, + } + } + // Structured audit-trail entry on success (compliance-swarm V16). + log.info('bulk_book_transactions committed', { + companyId, + operationType: 'bulk_book_transactions', + journalEntryId: result.journal_entry_id, + mode: result.mode, + txCount: result.linked_tx_count, + docsLinked: result.docs_linked, + }) + return { data: result as unknown as Record, status: 200 } +} + +async function commitLinkTransactionJournalEntry( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const transactionId = params.transaction_id as string | undefined + const journalEntryId = params.journal_entry_id as string | undefined + const invoiceId = (params.invoice_id as string | undefined) ?? undefined + + if (!transactionId || !journalEntryId) { + return { error: 'transaction_id and journal_entry_id are required', status: 400 } + } + + const outcome = await linkTransactionToJournalEntry(supabase, userId, companyId, { + transactionId, + journalEntryId, + invoiceId, + }) + + if (!outcome.ok) { + const entry = getErrorEntry(outcome.code) + const httpStatus = entry?.httpStatus ?? 500 + return { + error: entry?.message_en ?? outcome.code, + status: httpStatus, + data: outcome.details as Record | undefined, + } + } + + // Structured audit-trail entry on success (compliance-swarm V16, SOC 2 CC4.1). + // Mirrors commitMatchBatchAllocate / commitBulkBookTransactions — IDs only, + // no amounts or counterparty PII. invoiceId is logged as boolean to avoid + // leaking which invoices are touched while still distinguishing the two + // code paths (link-only vs link+settle). + log.info('link_transaction_journal_entry committed', { + companyId, + operationType: 'link_transaction_journal_entry', + transactionId: outcome.result.transactionId, + journalEntryId: outcome.result.journalEntryId, + settledInvoice: outcome.result.invoiceId != null, + }) + + return { + data: { + transaction_id: outcome.result.transactionId, + journal_entry_id: outcome.result.journalEntryId, + voucher_label: outcome.result.voucherLabel, + invoice_id: outcome.result.invoiceId, + invoice_status: outcome.result.invoiceStatus, + paid_amount: outcome.result.paidAmount, + remaining_amount: outcome.result.remainingAmount, + }, + } +} + // ── Public dispatcher ──────────────────────────────────────────── /** @@ -2756,6 +2948,15 @@ export async function commitPendingOperation( case 'generate_agi': result = await commitGenerateAgi(supabase, userId, companyId, pendingOp.params) break + case 'match_batch_allocate': + result = await commitMatchBatchAllocate(supabase, companyId, pendingOp.params) + break + case 'bulk_book_transactions': + result = await commitBulkBookTransactions(supabase, companyId, pendingOp.params) + break + case 'link_transaction_journal_entry': + result = await commitLinkTransactionJournalEntry(supabase, userId, companyId, pendingOp.params) + break default: return { status: 'failed', diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index b863cc7d..bffa6a08 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -92,6 +92,22 @@ export const OPERATION_RISK_TIERS: Record = { // staged. create_salary_run: 'medium', generate_agi: 'high', + + // ── Multi-tx flows (PRs #603/#606/#608/#610) ─────────────────────── + // Allocate 1 bank tx across N customer or supplier invoices into one + // combined verifikat. Reversible via storno + invoice_payments delete, + // so 'medium' (same tier as match_transaction_invoice — its single- + // invoice counterpart). + match_batch_allocate: 'medium', + // Bulk-book N bank txs into 1 verifikat. The create-new branch posts + // a verifikat with caller-supplied lines (template-expanded or manual), + // the same compliance-critical surface as create_voucher. 'high'. + bulk_book_transactions: 'high', + // Link a single bank tx to an already-posted verifikat (no new JE created). + // Reversible by clearing transactions.journal_entry_id and deleting any + // invoice_payments row — sits next to link_invoice_voucher semantically; + // both attach an existing booking to a different entity. + link_transaction_journal_entry: 'medium', } export function getRiskLevel(operationType: string): RiskLevel { diff --git a/lib/transactions/link-journal-entry.ts b/lib/transactions/link-journal-entry.ts new file mode 100644 index 00000000..ef29419e --- /dev/null +++ b/lib/transactions/link-journal-entry.ts @@ -0,0 +1,378 @@ +/** + * Link a bank transaction to an already-posted journal entry without creating + * new bookkeeping. Optionally settle a customer invoice in the same call by + * inserting an invoice_payments row pointing at the existing JE and flipping + * the invoice status with an optimistic-lock pattern. + * + * Shared between two callers: + * - REST: app/api/transactions/[id]/link-journal-entry/route.ts + * (duplicate-payment UI: user confirms the suggested existing voucher) + * - MCP commit handler: lib/pending-operations/commit.ts + * (gnubok_link_transaction_to_journal_entry — agent-staged operation) + * + * NEVER creates a new journal entry. The match log records + * 'linked_to_existing_voucher' for audit on success. + */ +import type { SupabaseClient } from '@supabase/supabase-js' +import { eventBus } from '@/lib/events/bus' +import { logMatchEvent } from '@/lib/invoices/match-log' +import { createLogger } from '@/lib/logger' +import type { Invoice, Transaction } from '@/types' + +const log = createLogger('transactions/link-journal-entry') + +// Codes returned by linkTransactionToJournalEntry. All map to entries in +// lib/errors/structured-errors.ts so both callers (REST route, MCP commit +// handler) can surface the right HTTP status and the localized message. +// The TX-not-found case reuses the shared TX_CATEGORIZE_TX_NOT_FOUND code +// rather than a link-specific one — it predates this route and is the +// canonical "bank tx not found in this company" envelope. +export type LinkTransactionJournalEntryErrorCode = + | 'TX_CATEGORIZE_TX_NOT_FOUND' + | 'LINK_TX_TX_ALREADY_LINKED' + | 'LINK_TX_JE_NOT_FOUND' + | 'LINK_TX_JE_NOT_POSTED' + | 'LINK_TX_INVOICE_NOT_FOUND' + | 'LINK_TX_INVOICE_NOT_OPEN' + | 'LINK_TX_INVOICE_CURRENCY_MISMATCH' + | 'LINK_TX_INVOICE_RACE' + | 'MATCH_INVOICE_RECORD_PAYMENT_FAILED' + | 'LINK_TX_DB_ERROR' + +export interface LinkTransactionJournalEntryParams { + transactionId: string + journalEntryId: string + invoiceId?: string +} + +export interface LinkTransactionJournalEntryResult { + transactionId: string + journalEntryId: string + voucherLabel: string + invoiceId: string | null + invoiceStatus: 'paid' | 'partially_paid' | null + paidAmount: number | null + remainingAmount: number | null +} + +export type LinkTransactionJournalEntryOutcome = + | { ok: true; result: LinkTransactionJournalEntryResult } + | { ok: false; code: LinkTransactionJournalEntryErrorCode; details?: Record } + +/** + * Canonical verifikat-label format: `${series}-${number}` (e.g. "A-12"). + * Centralised so the MCP staging preview and the committed result can't + * diverge — divergence is a BFL 5 kap 7§ traceability hazard because the + * verifikationsserie label that ends up in the audit trail must match the + * label the user saw at approval time. + * + * Fallbacks ('A' series, empty number) are defensive only; in practice a + * posted verifikat always has both. Callers should never construct this + * string inline — import this helper instead. + */ +export function formatVoucherLabel( + voucherSeries: string | null | undefined, + voucherNumber: number | string | null | undefined, +): string { + const series = voucherSeries ?? 'A' + const num = voucherNumber ?? '' + return num === '' ? series : `${series}-${num}` +} + +export async function linkTransactionToJournalEntry( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: LinkTransactionJournalEntryParams +): Promise { + const { transactionId, journalEntryId, invoiceId } = params + + // Data minimization (GDPR Art.5(1)(c)): pull only the columns needed for + // validation, optimistic-lock invoice update, invoice_payments insert, and + // the compensating-rollback path. No select('*'). + const { data: transaction, error: fetchTxError } = await supabase + .from('transactions') + .select( + 'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id' + ) + .eq('id', transactionId) + .eq('company_id', companyId) + .single() + + if (fetchTxError || !transaction) { + return { ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND' } + } + + if (transaction.journal_entry_id) { + return { + ok: false, + code: 'LINK_TX_TX_ALREADY_LINKED', + details: { existingJournalEntryId: transaction.journal_entry_id as string }, + } + } + + const { data: journalEntry, error: fetchJeError } = await supabase + .from('journal_entries') + .select('id, status, voucher_series, voucher_number, entry_date') + .eq('id', journalEntryId) + .eq('company_id', companyId) + .single() + + if (fetchJeError || !journalEntry) { + return { ok: false, code: 'LINK_TX_JE_NOT_FOUND' } + } + + if (journalEntry.status !== 'posted') { + return { + ok: false, + code: 'LINK_TX_JE_NOT_POSTED', + details: { currentStatus: journalEntry.status as string }, + } + } + + type FetchedInvoice = Pick< + Invoice, + | 'id' + | 'status' + | 'total' + | 'paid_amount' + | 'remaining_amount' + | 'currency' + | 'exchange_rate' + | 'paid_at' + | 'invoice_number' + > & { customer?: { name?: string } | null } + let invoice: FetchedInvoice | null = null + let newPaidAmount = 0 + let newRemaining = 0 + let isFullyPaid = false + let newStatus: 'paid' | 'partially_paid' = 'paid' + + if (invoiceId) { + // Data minimization (GDPR Art.5(1)(c) / SOC 2 CC6.1): explicit column + // list rather than select('*, customer:customers(name)'). Adding new + // PII columns to invoices won't silently widen this fetch. + const { data: invoiceRow, error: fetchInvError } = await supabase + .from('invoices') + .select( + 'id, status, total, paid_amount, remaining_amount, currency, exchange_rate, paid_at, invoice_number, customer:customers(name)' + ) + .eq('id', invoiceId) + .eq('company_id', companyId) + .single() + + if (fetchInvError || !invoiceRow) { + return { ok: false, code: 'LINK_TX_INVOICE_NOT_FOUND' } + } + + if ( + invoiceRow.status !== 'sent' && + invoiceRow.status !== 'overdue' && + invoiceRow.status !== 'partially_paid' + ) { + return { + ok: false, + code: 'LINK_TX_INVOICE_NOT_OPEN', + details: { currentStatus: invoiceRow.status as string }, + } + } + + invoice = invoiceRow as unknown as FetchedInvoice + + // BFL 5 kap 2§ + currency-integrity guard: invoices.paid_amount and + // remaining_amount are stored in the INVOICE'S currency. Mixing a + // foreign-currency tx.amount into those columns silently corrupts the + // ledger (a 230 SEK payment would record "230 USD paid" on a USD + // invoice). This link path is for the same-currency case only; + // cross-currency payments must go through /api/transactions/[id]/match- + // invoice which routes through buildInvoicePaymentClearingLines and + // posts the FX diff on 3960/7960. Reject here to keep the contract clear. + if (transaction.currency !== invoice.currency) { + return { + ok: false, + code: 'LINK_TX_INVOICE_CURRENCY_MISMATCH', + details: { + transactionCurrency: transaction.currency as string, + invoiceCurrency: invoice.currency, + }, + } + } + + const paidAmount = transaction.amount as number + newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100 + const currentRemaining = + invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0) + newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100) + isFullyPaid = newRemaining <= 0 + newStatus = isFullyPaid ? 'paid' : 'partially_paid' + } + + // Snapshot tx state so the compensating-rollback path can restore the row + // if a subsequent step fails — otherwise a partial state would persist + // (tx linked, invoice unchanged, no payment row). + const priorTxState = { + journal_entry_id: transaction.journal_entry_id, // validated null above + invoice_id: transaction.invoice_id, + potential_invoice_id: transaction.potential_invoice_id, + potential_supplier_invoice_id: transaction.potential_supplier_invoice_id, + is_business: transaction.is_business, + } + + const { error: updateTxError } = await supabase + .from('transactions') + .update({ + journal_entry_id: journalEntryId, + invoice_id: invoiceId ?? null, + potential_invoice_id: null, + potential_supplier_invoice_id: null, + is_business: true, + }) + .eq('id', transactionId) + .eq('company_id', companyId) + .is('journal_entry_id', null) + + if (updateTxError) { + return { ok: false, code: 'LINK_TX_DB_ERROR', details: { reason: updateTxError.message } } + } + + async function rollbackTxLink(reason: string): Promise { + // SOC 2 PI1.3 (processing integrity): if a rollback itself fails, the + // ledger ends up in a partial state — tx pointing at the existing + // verifikat with no invoice_payments row, or the invoice row at an + // intermediate paid_amount. We surface the rollback failure (IDs only, + // no amounts or counterparty names) so a reconciliation job can + // detect and repair the divergence. The original failure code still + // goes back to the caller as the proximate cause. + const { error: rollbackErr } = await supabase + .from('transactions') + .update(priorTxState) + .eq('id', transactionId) + .eq('company_id', companyId) + if (rollbackErr) { + log.warn('failed to roll back transaction link after subsequent step failed', { + companyId, + transactionId, + journalEntryId, + reason, + rollbackError: rollbackErr.message, + }) + } + } + + const now = new Date().toISOString() + + if (invoice && invoiceId) { + const { data: updatedRows, error: updateInvError } = await supabase + .from('invoices') + .update({ + status: newStatus, + paid_at: isFullyPaid ? now : null, + paid_amount: newPaidAmount, + remaining_amount: newRemaining, + }) + .eq('id', invoiceId) + .eq('company_id', companyId) + .in('status', ['sent', 'overdue', 'partially_paid']) + .select('id') + + if (updateInvError) { + await rollbackTxLink('invoice update errored') + return { ok: false, code: 'LINK_TX_DB_ERROR', details: { reason: updateInvError.message } } + } + + if (!updatedRows || updatedRows.length === 0) { + await rollbackTxLink('invoice optimistic lock returned 0 rows') + return { ok: false, code: 'LINK_TX_INVOICE_RACE' } + } + + // BFL 5 kap 2§ + ML 8 kap 21–23§: the payment row must record the rate + // effective on the PAYMENT date, not the invoice-creation date. If + // transaction.exchange_rate is null (SEK tx, no rate needed), leave the + // payment row's rate null too — a downstream Riksbanken lookup can + // populate it lazily if reporting needs it. Falling back to + // invoice.exchange_rate would silently record the wrong (invoice-date) + // rate, which corrupts the FX-diff figures in any later VAT or income + // reporting. + const paymentExchangeRate = transaction.exchange_rate ?? null + + const { error: paymentInsertError } = await supabase + .from('invoice_payments') + .insert({ + user_id: userId, + company_id: companyId, + invoice_id: invoiceId, + payment_date: transaction.date, + amount: transaction.amount, + currency: invoice.currency, + exchange_rate: paymentExchangeRate, + journal_entry_id: journalEntryId, + transaction_id: transactionId, + notes: 'Kopplad till befintlig verifikation (ingen ny bokföring skapad)', + }) + + if (paymentInsertError && paymentInsertError.code !== '23505') { + const { error: invRevertErr } = await supabase + .from('invoices') + .update({ + status: invoice.status, + paid_at: invoice.paid_at ?? null, + paid_amount: invoice.paid_amount ?? 0, + remaining_amount: invoice.remaining_amount ?? invoice.total, + }) + .eq('id', invoiceId) + .eq('company_id', companyId) + if (invRevertErr) { + log.warn('failed to revert invoice status after payment insert failed', { + companyId, + invoiceId, + rollbackError: invRevertErr.message, + }) + } + await rollbackTxLink('invoice_payments insert failed') + return { ok: false, code: 'MATCH_INVOICE_RECORD_PAYMENT_FAILED' } + } + } + + logMatchEvent(supabase, userId, transactionId, 'linked_to_existing_voucher', { + invoiceId, + newState: { + journal_entry_id: journalEntryId, + invoice_id: invoiceId ?? null, + invoice_status: invoice ? newStatus : null, + }, + }) + + if (invoice && invoiceId) { + try { + eventBus.emit({ + type: 'invoice.match_confirmed', + payload: { + invoice: invoice as Invoice, + transaction: transaction as Transaction, + userId, + companyId, + }, + }) + } catch { + /* non-critical */ + } + } + + const voucherLabel = formatVoucherLabel( + journalEntry.voucher_series as string | null, + journalEntry.voucher_number as number | null, + ) + + return { + ok: true, + result: { + transactionId, + journalEntryId, + voucherLabel, + invoiceId: invoiceId ?? null, + invoiceStatus: invoice ? newStatus : null, + paidAmount: invoice ? newPaidAmount : null, + remainingAmount: invoice ? newRemaining : null, + }, + } +} diff --git a/supabase/migrations/20260603120000_pending_operations_add_batch_allocate_and_bulk_book.sql b/supabase/migrations/20260603120000_pending_operations_add_batch_allocate_and_bulk_book.sql new file mode 100644 index 00000000..0228556f --- /dev/null +++ b/supabase/migrations/20260603120000_pending_operations_add_batch_allocate_and_bulk_book.sql @@ -0,0 +1,62 @@ +-- Expand pending_operations.operation_type to include the multi-tx +-- bookkeeping ops introduced in PRs #603, #606, #608, #610: +-- +-- match_batch_allocate — 1 bank tx → N customer or N supplier +-- invoices (samlingsbetalning per +-- BFL 5 kap 6§ st 3). Calls the SQL RPC +-- `match_batch_allocate(tx_id, allocations, +-- company_id)` on approval. Risk: medium +-- (reversible via storno of the JE + +-- deletion of the invoice_payments rows). +-- +-- bulk_book_transactions — N bank txs on the same date → 1 +-- combined verifikat (samlingsverifikation +-- per BFL 5 kap 6§). Two branches: +-- link-to-existing (no new JE; pure +-- junction rows) or create-new (caller- +-- supplied or template-expanded lines). +-- Risk: high (creates a posted verifikat +-- with arbitrary lines, same surface as +-- create_voucher). + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', -- backfilled (was missing from prior expansions) + 'match_batch_allocate', -- PR #603/#607: 1 tx → N invoices + 'bulk_book_transactions' -- PR #606/#610: N txs → 1 verifikat + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260603121000_pending_operations_add_salary_run_and_agi.sql b/supabase/migrations/20260603121000_pending_operations_add_salary_run_and_agi.sql new file mode 100644 index 00000000..5d59b548 --- /dev/null +++ b/supabase/migrations/20260603121000_pending_operations_add_salary_run_and_agi.sql @@ -0,0 +1,56 @@ +-- Backfill `create_salary_run` and `generate_agi` into the +-- pending_operations.operation_type CHECK constraint. +-- +-- Same bug class as `undo_sie_import` (fixed in 20260603120000): both +-- ops have a risk-tier entry (lib/pending-operations/risk-tiers.ts) +-- and a commit executor (lib/pending-operations/commit.ts) but were +-- never added to the CHECK constraint. Production currently has no +-- pending rows of either type — confirmed via SELECT operation_type, +-- COUNT(*) FROM pending_operations — so this is a forward-looking +-- fix, not a hot repair. +-- +-- Flagged by swedish-compliance review on PR #614. + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', + 'match_batch_allocate', + 'bulk_book_transactions', + 'create_salary_run', -- backfilled (swedish-compliance PR #614) + 'generate_agi' -- backfilled (swedish-compliance PR #614) + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260604120000_pending_operations_add_link_transaction_journal_entry.sql b/supabase/migrations/20260604120000_pending_operations_add_link_transaction_journal_entry.sql new file mode 100644 index 00000000..40eb9f4f --- /dev/null +++ b/supabase/migrations/20260604120000_pending_operations_add_link_transaction_journal_entry.sql @@ -0,0 +1,59 @@ +-- Backfill `link_transaction_journal_entry` into the +-- pending_operations.operation_type CHECK constraint. +-- +-- Same bug class as 20260603121000 (which backfilled create_salary_run + +-- generate_agi): the op has a risk-tier entry, a commit executor, and a +-- new MCP tool (gnubok_link_transaction_to_journal_entry, PR #614), but +-- the CHECK constraint was last enumerated in 20260603121000 and did +-- not include this value. Without this migration, any INSERT staged +-- by the new MCP tool would be rejected with constraint violation, +-- silently preventing the verifikat-link audit-trail row required by +-- BFL 5 kap 6–7§ (every affärshändelse must have a verifikation with +-- a logged match event). +-- +-- Flagged by swedish-compliance review on commit 5b884c3a (PR #614). + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', + 'match_batch_allocate', + 'bulk_book_transactions', + 'create_salary_run', + 'generate_agi', + 'link_transaction_journal_entry' -- backfilled (swedish-compliance PR #614 round 5) + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 98793a8f..e86a7a10 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1560,6 +1560,12 @@ export type PendingOperationType = | 'generate_agi' // Mark invoice paid by linking an existing posted verifikat (no new JE) | 'link_invoice_voucher' + // PR #603/#607: allocate 1 bank tx across N customer or supplier invoices + | 'match_batch_allocate' + // PR #606/#610: bulk-book N bank txs into 1 combined verifikat + | 'bulk_book_transactions' + // PR #614: link a single bank tx to an already-posted verifikat (no new JE) + | 'link_transaction_journal_entry' export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected' export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron'