13be0c569a
* 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>
363 lines
18 KiB
TypeScript
363 lines
18 KiB
TypeScript
import crypto from 'crypto'
|
|
import { createClient } from '@supabase/supabase-js'
|
|
|
|
const KEY_PREFIX = 'gnubok_sk_'
|
|
const REFRESH_TOKEN_PREFIX = 'gnubok_rt_'
|
|
|
|
// ── API Key Scopes ──────────────────────────────────────────
|
|
|
|
export const API_KEY_SCOPES = {
|
|
'transactions:read': { label: 'Transaktioner — läs', description: 'Lista transaktioner, mallförslag, kategoriförslag (3 verktyg)' },
|
|
'transactions:write': { label: 'Transaktioner — skriv', description: 'Kategorisera, av-kategorisera, kvittomatchning, koppling mot faktura (4 verktyg)' },
|
|
'customers:read': { label: 'Kunder — läs', description: 'Lista kunder (1 verktyg)' },
|
|
'customers:write': { label: 'Kunder — skriv', description: 'Skapa kunder (1 verktyg)' },
|
|
'invoices:read': { label: 'Fakturor — läs', description: 'Lista fakturor (1 verktyg)' },
|
|
'invoices:write': { label: 'Fakturor — skriv', description: 'Skapa, skicka, markera betald/skickad (4 verktyg)' },
|
|
'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor (2 verktyg)' },
|
|
'suppliers:write': { label: 'Leverantörer — skriv', description: 'Godkänn och kreditera leverantörsfakturor (2 verktyg)' },
|
|
'reports:read': { label: 'Rapporter — läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export (12 verktyg)' },
|
|
'bookkeeping:write': { label: 'Bokföring — skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar' },
|
|
'payroll:read': { label: 'Löner — läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' },
|
|
'payroll:write': { label: 'Löner — skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' },
|
|
// v1 REST API — added Phase 1
|
|
'companies:read': { label: 'Företag — läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' },
|
|
'events:read': { label: 'Händelser — läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' },
|
|
'webhooks:manage': { label: 'Webhooks — hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' },
|
|
'operations:read': { label: 'Operationer — läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' },
|
|
'documents:read': { label: 'Dokument — läs', description: 'Lista och hämta dokumentbilagor' },
|
|
'documents:write': { label: 'Dokument — skriv', description: 'Ladda upp och koppla dokument till verifikationer' },
|
|
'compliance:read': { label: 'Compliance — läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet' },
|
|
'agent:read': { label: 'Agent — läs', description: 'Specialiserad bokföringsassistent: profil, laddade specialister/atomer, minnen (briefing + skill-katalog)' },
|
|
'pending_operations:read': { label: 'Stagade operationer — läs', description: 'Lista pending_operations (staged writes awaiting approval)' },
|
|
'pending_operations:approve': { label: 'Stagade operationer — godkänn', description: 'Godkänn eller avvisa stagade operationer via API/MCP — agenten ersätter web-UI:s granskning' },
|
|
} as const
|
|
|
|
export type ApiKeyScope = keyof typeof API_KEY_SCOPES
|
|
|
|
export const ALL_SCOPES: ApiKeyScope[] = Object.keys(API_KEY_SCOPES) as ApiKeyScope[]
|
|
|
|
/** The read-only scopes assigned to keys with no explicit scopes (legacy/null). */
|
|
export const DEFAULT_SCOPES: ApiKeyScope[] = [
|
|
'transactions:read',
|
|
'customers:read',
|
|
'invoices:read',
|
|
'suppliers:read',
|
|
'reports:read',
|
|
]
|
|
|
|
/**
|
|
* Default scope grant for OAuth-issued keys when the client did not pass an
|
|
* explicit `scope` parameter at /authorize. Read-only by design — every
|
|
* write or approval scope must be requested explicitly by the client AND
|
|
* affirmatively ticked by the user on the consent screen.
|
|
*
|
|
* Rationale (do not weaken without a documented security decision):
|
|
* - GDPR Art. 25(2) data-protection-by-default: the minimum-necessary
|
|
* access set must be the silent baseline.
|
|
* - ISO 27001:2022 A.5.18 / A.8.2 / SOC 2 CC6.3: privileged capabilities
|
|
* (write, approve) must not be bundled into a default grant.
|
|
* - Segregation of Duties (findStageApproveConflict below): granting any
|
|
* STAGING_SCOPES member together with `pending_operations:approve` on a
|
|
* single key lets an automated agent both stage AND commit financial
|
|
* postings without a human-in-the-loop review. Keeping the default
|
|
* read-only prevents this combination from being silently issued.
|
|
* - BFL 5 kap 5§ / BFNAR 2013:2 behandlingshistorik: write paths that
|
|
* create or modify verifikationer must be opt-in at the authorization
|
|
* layer; conversational acknowledgement at the agent layer is not an
|
|
* auditable substitute.
|
|
*/
|
|
export const DEFAULT_OAUTH_SCOPES: ApiKeyScope[] = [
|
|
'transactions:read',
|
|
'customers:read',
|
|
'invoices:read',
|
|
'suppliers:read',
|
|
'reports:read',
|
|
'companies:read',
|
|
'events:read',
|
|
'operations:read',
|
|
'documents:read',
|
|
'compliance:read',
|
|
'payroll:read',
|
|
'pending_operations:read',
|
|
]
|
|
|
|
/**
|
|
* Scopes advertised in the RFC 8414 authorization-server metadata document
|
|
* (/.well-known/oauth-authorization-server). Restricted to the same set that
|
|
* /authorize will grant by default — destructive scopes still work when
|
|
* requested explicitly, they just aren't enumerated for unauthenticated
|
|
* callers (defense-in-depth against scope-escalation reconnaissance).
|
|
*/
|
|
export const PUBLIC_OAUTH_METADATA_SCOPES: ApiKeyScope[] = [...DEFAULT_OAUTH_SCOPES]
|
|
|
|
/**
|
|
* Scopes that allow staging a pending_operation. Used to detect a
|
|
* segregation-of-duties conflict when paired with `pending_operations:approve`
|
|
* on the same API key (ISO 27001:2022 A.5.3, SOC 2 CC6.1).
|
|
*/
|
|
export const STAGING_SCOPES: ApiKeyScope[] = [
|
|
'transactions:write',
|
|
'customers:write',
|
|
'invoices:write',
|
|
'suppliers:write',
|
|
'bookkeeping:write',
|
|
'payroll:write',
|
|
'documents:write',
|
|
]
|
|
|
|
/**
|
|
* Detect a segregation-of-duties conflict between staging and approval scopes
|
|
* on the same key. Returns the offending staging scope, or null when the
|
|
* combination is clean. Callers may choose to block, warn, or record an
|
|
* acknowledged risk acceptance.
|
|
*
|
|
* Granting both stage+approve to the same actor lets an automated agent both
|
|
* stage AND commit financial postings without a human-in-the-loop review,
|
|
* which is the explicit control surface for BFNAR 2013:2 (behandlingshistorik)
|
|
* and BFL 5 kap 5§ traceability requirements.
|
|
*/
|
|
export function findStageApproveConflict(scopes: ApiKeyScope[]): ApiKeyScope | null {
|
|
if (!scopes.includes('pending_operations:approve')) return null
|
|
return scopes.find((s) => STAGING_SCOPES.includes(s)) ?? null
|
|
}
|
|
|
|
/** Scope domain groups for UI rendering */
|
|
export const SCOPE_GROUPS = [
|
|
{ domain: 'transactions', label: 'Transaktioner', read: 'transactions:read' as const, write: 'transactions:write' as const },
|
|
{ domain: 'customers', label: 'Kunder', read: 'customers:read' as const, write: 'customers:write' as const },
|
|
{ domain: 'invoices', label: 'Fakturor', read: 'invoices:read' as const, write: 'invoices:write' as const },
|
|
{ domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: 'suppliers:write' as const },
|
|
{ domain: 'reports', label: 'Rapporter', read: 'reports:read' as const, write: null },
|
|
{ domain: 'bookkeeping', label: 'Bokföring', read: null, write: 'bookkeeping:write' as const },
|
|
{ domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const },
|
|
{ domain: 'pending_operations', label: 'Stagade operationer', read: 'pending_operations:read' as const, write: 'pending_operations:approve' as const },
|
|
] as const
|
|
|
|
/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */
|
|
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
|
// Transactions
|
|
gnubok_list_uncategorized_transactions: 'transactions:read',
|
|
gnubok_list_transactions_without_documents: 'transactions:read',
|
|
gnubok_create_transactions: 'transactions:write',
|
|
gnubok_categorize_transaction: 'transactions:write',
|
|
gnubok_receipt_matcher: 'transactions:write',
|
|
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',
|
|
gnubok_create_customer: 'customers:write',
|
|
// Invoices
|
|
gnubok_list_invoices: 'invoices:read',
|
|
gnubok_create_invoice: 'invoices:write',
|
|
gnubok_send_invoice: 'invoices:write',
|
|
gnubok_mark_invoice_as_paid: 'invoices:write',
|
|
gnubok_mark_invoice_as_sent: 'invoices:write',
|
|
// Suppliers
|
|
gnubok_list_suppliers: 'suppliers:read',
|
|
gnubok_list_supplier_invoices: 'suppliers:read',
|
|
// Reports
|
|
gnubok_get_trial_balance: 'reports:read',
|
|
gnubok_get_vat_report: 'reports:read',
|
|
gnubok_vat_review_widget: 'reports:read',
|
|
gnubok_vat_close_check: 'reports:read',
|
|
gnubok_get_kpi_report: 'reports:read',
|
|
gnubok_get_income_statement: 'reports:read',
|
|
gnubok_list_accounts: 'reports:read',
|
|
gnubok_get_balance_sheet: 'reports:read',
|
|
gnubok_get_general_ledger: 'reports:read',
|
|
gnubok_query_journal: 'reports:read',
|
|
gnubok_get_ar_ledger: 'reports:read',
|
|
gnubok_get_supplier_ledger: 'reports:read',
|
|
gnubok_list_fiscal_periods: 'reports:read',
|
|
gnubok_get_reconciliation_status: 'reports:read',
|
|
// Document inbox
|
|
gnubok_upload_document: 'transactions:write',
|
|
gnubok_list_inbox_items: 'transactions:read',
|
|
gnubok_get_inbox_item: 'transactions:read',
|
|
gnubok_list_unmatched_documents: 'transactions:read',
|
|
gnubok_get_document_content: 'transactions:read',
|
|
gnubok_attach_document_to_transaction: 'transactions:write',
|
|
// Payroll
|
|
gnubok_list_employees: 'payroll:read',
|
|
gnubok_get_salary_run: 'payroll:read',
|
|
gnubok_get_salary_journal: 'payroll:read',
|
|
gnubok_create_salary_run: 'payroll:write',
|
|
gnubok_calculate_salary_run: 'payroll:write',
|
|
gnubok_generate_agi: 'payroll:write',
|
|
// Bookkeeping write (Stream 1 Phase 1) — high-risk, always staged
|
|
gnubok_close_period: 'bookkeeping:write',
|
|
gnubok_lock_period: 'bookkeeping:write',
|
|
gnubok_unlock_period: 'bookkeeping:write',
|
|
gnubok_run_year_end: 'bookkeeping:write',
|
|
gnubok_year_end_readiness: 'reports:read',
|
|
gnubok_set_opening_balances: 'bookkeeping:write',
|
|
gnubok_run_currency_revaluation: 'bookkeeping:write',
|
|
gnubok_explain_voucher_gap: 'bookkeeping:write',
|
|
gnubok_list_voucher_gaps: 'reports:read',
|
|
// Transaction reversal (medium-risk)
|
|
gnubok_uncategorize_transaction: 'transactions:write',
|
|
// SIE export (read-only) + import (write)
|
|
gnubok_export_sie: 'reports:read',
|
|
gnubok_audit_package: 'reports:read',
|
|
gnubok_import_sie: 'bookkeeping:write',
|
|
// Supplier CRUD
|
|
gnubok_create_supplier: 'suppliers:write',
|
|
// Supplier invoice lifecycle
|
|
gnubok_approve_supplier_invoice: 'suppliers:write',
|
|
gnubok_credit_supplier_invoice: 'suppliers:write',
|
|
gnubok_create_supplier_invoice_from_inbox: 'suppliers:write',
|
|
gnubok_set_inbox_extracted_data: 'suppliers:write',
|
|
// Invoice conversion + crediting
|
|
gnubok_convert_invoice: 'invoices:write',
|
|
gnubok_credit_invoice: 'invoices:write',
|
|
// Phase 4: arbitrary-line bookkeeping primitives (high-risk, always staged)
|
|
gnubok_create_voucher: 'bookkeeping:write',
|
|
gnubok_correct_entry: 'bookkeeping:write',
|
|
gnubok_reverse_journal_entry: 'bookkeeping:write',
|
|
// Agent surface (Phase 6 MCP parity): briefing tool exposes company-specific
|
|
// profile + memory so it's scoped; gnubok_list_skills / gnubok_load_skill
|
|
// stay unscoped (discovery + static Markdown bodies + globally-readable atom
|
|
// registry — no per-company data).
|
|
gnubok_get_agent_briefing: 'agent:read',
|
|
// Pending operations approval (mirrors the /pending web UI)
|
|
gnubok_list_pending_operations: 'pending_operations:read',
|
|
gnubok_approve_pending_operation: 'pending_operations:approve',
|
|
gnubok_reject_pending_operation: 'pending_operations:approve',
|
|
}
|
|
|
|
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
|
|
if (scopes === null || scopes === undefined) return null
|
|
if (!Array.isArray(scopes)) return null
|
|
const valid = scopes.filter((s): s is ApiKeyScope => s in API_KEY_SCOPES)
|
|
return valid.length > 0 ? valid : null
|
|
}
|
|
|
|
/**
|
|
* Create a Supabase service client that doesn't require cookies.
|
|
* Used for API key validation (MCP, webhooks) where there's no browser session.
|
|
*/
|
|
export function createServiceClientNoCookies() {
|
|
return createClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
)
|
|
}
|
|
|
|
export function generateApiKey(): { key: string; hash: string; prefix: string } {
|
|
const random = crypto.randomBytes(32).toString('base64url')
|
|
const key = `${KEY_PREFIX}${random}`
|
|
const hash = hashApiKey(key)
|
|
const prefix = key.slice(0, KEY_PREFIX.length + 8)
|
|
return { key, hash, prefix }
|
|
}
|
|
|
|
export function hashApiKey(key: string): string {
|
|
return crypto.createHash('sha256').update(key).digest('hex')
|
|
}
|
|
|
|
export function generateRefreshToken(): { token: string; hash: string } {
|
|
const random = crypto.randomBytes(32).toString('base64url')
|
|
const token = `${REFRESH_TOKEN_PREFIX}${random}`
|
|
const hash = crypto.createHash('sha256').update(token).digest('hex')
|
|
return { token, hash }
|
|
}
|
|
|
|
export function hashRefreshToken(token: string): string {
|
|
return crypto.createHash('sha256').update(token).digest('hex')
|
|
}
|
|
|
|
export function isRefreshToken(token: string): boolean {
|
|
return token.startsWith(REFRESH_TOKEN_PREFIX)
|
|
}
|
|
|
|
export function extractBearerToken(request: Request): string | null {
|
|
const authHeader = request.headers.get('authorization')
|
|
if (!authHeader?.startsWith('Bearer ')) return null
|
|
return authHeader.slice(7)
|
|
}
|
|
|
|
/**
|
|
* Validate an API key and enforce rate limiting.
|
|
* Uses the DB RPC for atomic check + increment.
|
|
* Returns the user_id, company_id, api_key_id, name, and effective scopes on
|
|
* success, or an error with HTTP status.
|
|
* null scopes in DB → DEFAULT_SCOPES (read-only).
|
|
*
|
|
* api_key_id and api_key_name are returned so callers (e.g. the MCP server)
|
|
* can record actor attribution on pending_operations and audit_log.
|
|
* They may be undefined when the deployed DB hasn't yet run the migration
|
|
* that adds them to the RPC return shape.
|
|
*/
|
|
/**
|
|
* Operating mode of the API key. 'live' keys see real company data; 'test' keys
|
|
* are bound to deterministic sandbox companies. Keys created before the Phase 1
|
|
* migration default to 'live' for backwards compatibility.
|
|
*/
|
|
export type ApiKeyMode = 'live' | 'test'
|
|
|
|
export async function validateApiKey(
|
|
key: string
|
|
): Promise<
|
|
| {
|
|
userId: string
|
|
companyId: string
|
|
apiKeyId?: string
|
|
apiKeyName?: string
|
|
scopes: ApiKeyScope[]
|
|
mode: ApiKeyMode
|
|
}
|
|
| { error: string; status: number }
|
|
> {
|
|
if (isRefreshToken(key)) {
|
|
return {
|
|
error: 'Refresh token cannot be used as access token; exchange it at /api/mcp-oauth/token',
|
|
status: 401,
|
|
}
|
|
}
|
|
|
|
if (!key.startsWith(KEY_PREFIX)) {
|
|
return { error: 'Invalid API key format', status: 401 }
|
|
}
|
|
|
|
const hash = hashApiKey(key)
|
|
const supabase = createServiceClientNoCookies()
|
|
|
|
const { data, error } = await supabase.rpc('validate_and_increment_api_key', {
|
|
p_key_hash: hash,
|
|
})
|
|
|
|
if (error || !data || data.length === 0) {
|
|
return { error: 'Invalid API key', status: 401 }
|
|
}
|
|
|
|
const row = data[0]
|
|
|
|
if (row.rate_limited) {
|
|
return { error: 'Rate limit exceeded', status: 429 }
|
|
}
|
|
|
|
return {
|
|
userId: row.user_id,
|
|
companyId: row.company_id,
|
|
apiKeyId: row.api_key_id,
|
|
apiKeyName: row.api_key_name,
|
|
scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES,
|
|
// `mode` may be undefined when the deployed DB hasn't yet run the Phase 1
|
|
// migration that adds it to the RPC return. Default to 'live' so existing
|
|
// keys behave unchanged.
|
|
mode: (row.mode === 'test' ? 'test' : 'live') as ApiKeyMode,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a given scope is allowed by the key's scopes.
|
|
*/
|
|
export function hasScope(keyScopes: ApiKeyScope[], required: ApiKeyScope): boolean {
|
|
return keyScopes.includes(required)
|
|
}
|