feat(mcp): expose multi-tx RPCs (match_batch_allocate + bulk_book_transactions) (#614)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-30 13:36:46 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 28f7cefc86
commit 13be0c569a
20 changed files with 2209 additions and 299 deletions
@@ -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()
})
@@ -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 },
@@ -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<string, string> } }>(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()
})
})
@@ -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({
@@ -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) {
+34 -10
View File
@@ -338,7 +338,10 @@ export default function InvoiceMatchDialog({
</div>
</div>
{/* 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 && (
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">{t('invoice_label')}</p>
@@ -354,7 +357,7 @@ export default function InvoiceMatchDialog({
</span>
<span className="font-medium">
{formatCurrency(
transaction.potential_invoice!.total,
transaction.potential_invoice!.remaining_amount ?? transaction.potential_invoice!.total,
transaction.potential_invoice!.currency,
)}
</span>
@@ -385,17 +388,29 @@ export default function InvoiceMatchDialog({
</div>
)}
{/* 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 (
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning/10 text-warning-foreground">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">{t('amounts_differ')}</p>
<p>
{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')
)}
</p>
</div>
</div>
@@ -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)
})
})
+528
View File
@@ -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).',
+3
View File
@@ -144,6 +144,9 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
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',
@@ -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)
})
})
})
+207
View File
@@ -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 }
}
+14
View File
@@ -346,6 +346,13 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
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<string, StructuredErrorEntry> = {
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<string, StructuredErrorEntry> = {
@@ -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>): 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)
})
})
+201
View File
@@ -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<string, unknown>
): Promise<ExecutorResult> {
// 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<string, unknown> | 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<string, unknown>, status: 200 }
}
async function commitBulkBookTransactions(
supabase: SupabaseClient,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
// 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<string, unknown> | 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<string, unknown> | 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<string, unknown>, status: 200 }
}
async function commitLinkTransactionJournalEntry(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
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<string, unknown> | 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',
+16
View File
@@ -92,6 +92,22 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// 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 {
+378
View File
@@ -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<string, unknown> }
/**
* 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<LinkTransactionJournalEntryOutcome> {
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<void> {
// 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,
},
}
}
@@ -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';
@@ -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';
@@ -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';
+6
View File
@@ -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'