Commit Graph

251 Commits

Author SHA1 Message Date
Jakob Wennberg 2c59c3633f feat(invoices): cross-currency settlement + payment-status card (#615)
* feat(invoices): cross-currency settlement + payment-status card

Two changes both surfaced by user feedback after PR #614:

# 1. Invoice detail page: Betalningsstatus card

The customer-invoice detail page now shows paid_amount + remaining_amount
+ the individual payment events whenever an invoice is partially_paid or
paid (was previously only a single "Paid" line on fully-paid invoices,
and nothing at all on partially_paid). Mirrors the supplier-invoice
page's payment section. Each payment row links to its verifikat.

# 2. Cross-currency match-invoice settlement

Replaces the PR #614 round-9 block (MATCH_INVOICE_CURRENCY_MISMATCH)
with proper FX-aware settlement. Flow:

1. Preview route detects tx.currency !== invoice.currency, fetches the
   Riksbanken spot rate for invoice.currency on tx.date (ML 8 kap 21–23§),
   and returns fx_conversion = { rate, rate_date, paid_in_invoice_currency }.
   When the lookup fails it returns fx_conversion.error = 'rate_unavailable'.

2. InvoiceMatchDialog renders a new Valutaomräkning card showing the rate
   + invoice-currency-equivalent + projected post-payment state + a one-
   line kursvinst/kursförlust note. When the lookup failed it swaps in
   a manual-rate input the user fills from their bank statement; the
   Confirm button blocks until a positive rate is supplied.

3. POST route does the same lookup (or accepts manual_exchange_rate from
   the request body), then:
   - paidInInvoiceCurrency = bankSek / rate (4dp precision)
   - invoice.paid_amount/remaining_amount accumulate in invoice currency
   - invoice_payments row records amount + currency = invoice.currency,
     exchange_rate = the rate actually used (not invoice.exchange_rate)
   - buildInvoicePaymentClearingLines gets paidInInvoiceCurrency so it
     credits 1510 by that × invoice.exchange_rate (booking rate) and
     posts the FX-diff line on 3960 (gain) or 7960 (loss)

4. buildInvoicePaymentClearingLines gains an optional fourth param. When
   supplied: proportional FX-aware AR-leg + balanced FX-diff. When omitted:
   pre-existing fallback (full-clear gets FX, partials defer).

The change fixes the invoice.paid_amount accumulator bug that PR #614
round-9 worked around by blocking the case entirely. Now SEK→USD
settlements actually work, with the verifikat balanced to the öre and
the GL+sub-ledger in sync per BFL 5 kap 4–5§.

Tests:
- 3 new helper tests (paidInInvoiceCurrency happy path + edge cases)
- 3 new route tests (Riksbanken happy path, lookup failure, manual rate)
- All 4321 tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(invoices): align cross-currency match preview with commit + review cleanups

Addresses PR #615 review feedback.

Preview/commit divergence (Greptile P1): preview/route.ts computed
paidAmount / isFullyPaid / useCashEntry from the raw SEK transaction.amount
before the FX conversion ran. A 1 000 SEK payment against a 140 USD invoice
made max(0, 140 − 1000) = 0 → is_fully_paid=true, so a cash-method unbooked
invoice previewed a cash entry (Dr 1930 / Cr 30xx) while the POST handler —
which converts first — commits the clearing entry (Dr 1930 / Cr 1510). The
user approved one verifikat and a different one was booked. Move the FX
lookup above the paid/remaining math so paidAmount derives from the
invoice-currency conversion, mirroring the POST handler. Rate-unavailable
stays non-fully-paid so the cash shape is never previewed on a guess.

Add a preview-route regression test (cross-currency → clearing + not
fully paid; same-currency cash path still previews the cash entry).

Cleanups:
- Bound manual_exchange_rate with .max(100000) as a sanity ceiling against
  pasted/garbage input corrupting the FX-diff posting (swarm V2.3).
- Remove the invisible disabled placeholder retry button and its unused
  fx_manual_rate_retry i18n keys (Greptile P2).
- Remove the now-unreachable MATCH_INVOICE_CURRENCY_MISMATCH error code
  (Greptile P2 dead code; confirmed zero references).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(invoices): record FX rate provenance + cover kursförlust path

Follow-up to the PR #615 review (compliance swarm V16 / SOC 2 CC6.1 /
GDPR Art.5(1)(f); Swedish accounting review).

A manually-supplied cross-currency rate is a user-controlled money-path
override of the ML 8 kap 21–23§ obligation and was indistinguishable from
an automatic Riksbanken lookup in the audit trail. Tag the resolved rate
with source: 'manual' | 'riksbanken' and:
- write a "Manuell valutakurs <rate> <ccy>/SEK (betalningsdatum …)" note
  onto the existing invoice_payments.notes column when manual (BFL 5 kap
  6–7§ — the verifikation must reflect the actual affärshändelse);
- record rate_source + exchange_rate in payment_match_log.new_state.
No schema change — both are existing columns/JSON.

Tests:
- cover the kursförlust (7960 Dr) branch of the cross-currency
  paidInInvoiceCurrency path — previously only the 3960 gain was asserted;
- assert rate_source provenance ('manual' and 'riksbanken') reaches the
  match-log new_state on both FX paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 10:45:51 +02:00
Jakob Wennberg 13be0c569a 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>
2026-05-30 13:36:46 +02:00
Jakob Wennberg 28f7cefc86 feat(bulk-book): manual booking mode + document inheritance (#610)
* 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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 10:23:14 +02:00
Mattsson ea1bf01f1e Fix/m sprint fixes (#613)
* fix(dashboard): exclude ignored and already-triaged transactions from stale count

The "Gamla transaktioner" widget counted transactions that had been ignored
or already marked as is_business=true but not yet booked, so users saw a
nag for a row they had already dealt with — and the /transactions inbox
correctly hid it. Align the count with the inbox criterion (is_business
IS NULL, is_ignored = false) so the widget clears when the row leaves
the inbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(transactions): read entity_type from settings response wrapper

The transactions page read entityRes.entity_type directly, but
/api/settings returns { data: { entity_type, ... } }. The expression
was always undefined, so setEntityType never fired and entityType
stayed at its initial 'enskild_firma'. The template picker's
entity_type filter then dropped every aktiebolag-tagged user template
for AB customers — only entity_type='all' templates made it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* stale templates
bank sync
journal entry from transaction

* fixed pr comments

* fixed pr comment

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 01:28:41 +02:00
Jakob Wennberg fc7a46c3f2 fix(match-batch): cross-currency allocations + widened tolerance (#607)
* fix(match-batch): cross-currency allocations + widened tolerance

Reported by jakob testing PR #603's MatchAllocationDialog with a SEK
bank tx + a mix of SEK and USD invoices:

1. Tally rendered "1 USD + 1 SEK = 2 kr" — summing different currencies
   as if they were the same.
2. The 0.005 SEK tolerance blocked confirm on any FX rounding delta.

## What changed

**UI (MatchAllocationDialog.tsx)**
- Per-row amount input is explicitly in TRANSACTION currency (SEK for a
  Swedish bank import). Cross-currency rows show an "≈ X.XX (invoice
  currency)" hint under the input so the user can verify the FX result.
- Default amount for a cross-currency allocation is
  `invoice.remaining × invoice.exchange_rate` (booked SEK), so the user
  doesn't have to mental-math the FX.
- Overshoot tolerance widened from 0.005 SEK to `max(1 SEK, 0.5% × tx)`
  so bank-side FX rounding doesn't block confirm. A 2 400 kr tx now
  accepts ~12 kr of tolerance, a 100 kkr transfer accepts 500 kr.

**RPC (match_batch_allocate cross_currency migration)**
- BATCH_CURRENCY_MISMATCH dropped per-allocation. Mixed currencies now
  accepted with the convention that the cross-currency row pays the
  FULL invoice remaining (matches the single-tx match-supplier-invoice
  behavior). Partial cross-currency is out of scope for v1.
- AR/AP line is booked at `invoice.remaining × invoice.exchange_rate`
  (the SEK that was originally on 1510/2440). FX residual is posted
  to 7960 (Valutakursförluster) or 3960 (Valutakursvinster) per BAS.
- Sign conventions per direction documented inline:
    Customer: bank > booked → Cr 3960 (gain); bank < booked → Dr 7960
    Supplier: bank < booked → Cr 3960 (gain); bank > booked → Dr 7960
- New BATCH_FX_RATE_MISSING when the cross-currency invoice has no
  exchange_rate on file (would otherwise silently book at 0).
- New BATCH_FX_DEVIATION_TOO_LARGE when the user-entered amount
  deviates more than 10% from booked SEK — catches typos like "140"
  (USD invoice currency) when they meant "1390" (SEK equivalent)
  without rejecting genuine rate-day FX movement.

RPC patched on remote via Supabase MCP. Same-currency path is
byte-identical to the previous behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(match-batch): PR review — strict sum, bank line = tx_abs, FX validation

Round-1 review fixes on the cross-currency batch allocation flow:

UI (MatchAllocationDialog):
- Tighten tolerance to 0.005 SEK so the "balanced ✓" indicator matches
  what the server will accept. The previous widened tolerance (max 1 SEK
  or 0.5% × tx) created a reconciliation gap where the JE's bank line
  could legitimately disagree with the actual bank receipt.
- Require balanced before confirm — undershoot is now a blocking state
  with an explicit warning, not a silent "leave unallocated".
- Cross-currency default no longer caps at remainingTxBudget. Capping a
  USD invoice's default to the leftover SEK budget could silently
  trigger BATCH_FX_DEVIATION_TOO_LARGE on submit. The user re-balances
  the other rows to fit.
- Add explicit FX-rate validation (bound check 0 < rate < 100000).
- When a cross-currency invoice has no usable exchange_rate on file,
  leave the amount blank and surface a warning instead of guessing.

RPC (match_batch_allocate):
- New code BATCH_AMOUNT_BELOW_TX. Strict sum check on both sides means
  the server can't be coaxed by a direct API caller into the same
  broken state the UI now blocks.
- Bank line credit/debit = v_tx_abs (the actual bank movement) instead
  of sum-of-allocations. Same value within rounding under the strict
  sum check, but it makes intent legible and lets per-row FX diff lines
  absorb rounding.
- Defense-in-depth company_id filter on all re-queries / UPDATEs in
  the line-build + payment-row passes.
- Drop the v_booked_sek-aliasing-for-invoice.total foot-gun. Use a
  dedicated v_inv_total var.
- Truncate invoice_number to 32 chars in line_description.

Tests:
- pg-real: cross-currency happy path (USD invoice paid by SEK tx with
  FX loss to 7960, bank line = tx_abs).
- pg-real: BATCH_AMOUNT_BELOW_TX rejection on undershoot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(match-batch): PR review round 2 - caller user_id verification + FX bound

Compliance-swarm + swedish-compliance findings on round 1:

- CC6.3 (HIGH): p_user_id was caller-supplied and written into
  journal_entries.user_id / payment-row user_id without verifying it
  equals auth.uid(). Membership covered the company; nothing covered
  the user attribution. Two-layer fix: explicit guard rejects when
  p_user_id <> auth.uid(), and all writes now resolve v_caller =
  auth.uid() directly so the guard cant be silently bypassed.
- A.8.28 (MED): server-side FX upper-bound (0 < rate < 100000) matches
  the UI. Previously RPC only checked > 0, allowing the UI guard to
  diverge.
- V1.2.5 (LOW): truncate v_tx.date when concatenated into
  line_description (defense alongside round 1s invoice_number trunc).
- Symmetry: populate supplier_invoice_payments.exchange_rate (column
  existed, INSERT omitted it). Customer side already populated. Matches
  swedish-compliances traceability note on AP rorelseskulder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(match-batch): PR review round 3 - drop p_user_id, CHECK constraints, payment-day rate

Genuine round-2 review findings (compliance-swarm + swedish-compliance):

- V4.5: p_user_id dropped from RPC signature entirely. Round-2 added a
  guard; this removes the attack surface at the API boundary. Caller is
  resolved via auth.uid() inside the function. Route updated.
- V2.2: CHECK constraint on invoices.exchange_rate and
  supplier_invoices.exchange_rate (0 < rate < 100000). Three layers now
  enforce the bound: schema, RPC, UI.
- swedish-compliance traceability gap: payment_exchange_rate column on
  both invoice_payments and supplier_invoice_payments. Populated as
  v_alloc_amount / v_inv_remaining for cross-currency rows so FX diffs
  are reconstructible from the payment record alone (BFL 7 kap
  behandlingshistorik). NULL for same-currency. The existing
  exchange_rate column continues to store the invoicing rate.
- CC6.1: extract isValidExchangeRate() to lib/utils.ts. UI's three
  inline bound checks now share one validator.
- Dead code: drop unused leftover_note i18n key (sv + en).

Tests:
- pg-real signature updated (4-arg -> 3-arg) across all 9 call sites.
- Added payment_exchange_rate assertion to cross-currency happy path
  (invoicing rate 10.0 stays, payment-day rate stored as 10.5).

Migration applied to remote.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): missed 4th arg in BATCH_UNAUTHORIZED pg-real test

Round-3 dropped p_user_id from match_batch_allocate. The replace_all
caught the userId/companyId pattern but missed the BATCH_UNAUTHORIZED
test which uses outsiderId instead of userId. CI failed with
"bind message supplies 4 parameters, but prepared statement requires 3".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:30:42 +02:00
Jakob Wennberg 4da87e5e4c feat(transactions): bulk-book + is-booked predicate (#606)
* feat(transactions): bulk-book + is-booked predicate

Closes the second of the two multi-tx ↔ multi-voucher flows from the
original plan. Where PR #603's match_batch_allocate took 1 tx and
spread it across N invoices (samlingsbetalning), this PR takes N bank
transactions on the same day and rolls them up into ONE combined
verifikat (samlingsverifikation per BFL 5 kap 6§ st 3) — the kiosk
masshantering pattern the user explicitly asked for.

## Backend (Phase 3b)

- **PL/pgSQL RPC** bulk_book_transactions: two branches, both atomic.

  1. Link to existing posted verifikat (p_existing_journal_entry_id):
     no new JE. Validates the JE's 19xx net equals sum(tx.amount),
     inserts N transaction_voucher_links rows, and for N=1 also sets
     transactions.journal_entry_id (1:1 reader-path back-compat).

  2. Create new combined verifikat (p_new_entry with pre-computed
     balanced lines): the route's applyTemplate() has already done
     ratio + VAT expansion per the chosen mode. The RPC validates the
     lines balance and the 1930 net matches sum(tx.amount), then
     commits via commit_journal_entry.

  Same security pattern as match_batch_allocate: company-member check
  via auth.uid(), SELECT … FOR UPDATE on each tx in id order,
  deterministic fiscal-period resolution (ORDER BY period_start DESC).

- **Endpoint** POST /api/transactions/bulk-book — fetches template via
  RLS, expands per mode (one_line_per_tx | sum_per_account) using
  lib/bookkeeping/template-library.applyTemplate, passes the resulting
  lines to the RPC. On success emits one transaction.reconciled event
  per tx.

- **22 new BULK_BOOK_* error codes** (sv + en) covering all guard
  paths.

## UI (Phase 5b)

- **BulkBookDialog** — template picker + mode toggle (segmented
  control: en rad per transaktion / summera per konto) + live preview
  table with balance + bank-leg invariant indicators. Confirm only
  enabled when both pass.

- **Multi-select inbox** — sticky action bar gains a "Bokför i klump"
  button gated by same-date + same-direction across selected txs.
  Tooltip explains the disabled state.

## Phase 6: is-booked predicate

New lib/transactions/is-booked.ts. After multi-allocation and bulk-
book, tx.journal_entry_id can be NULL even though the tx is anchored
(via invoice_payments / supplier_invoice_payments /
transaction_voucher_links). The helper checks all three storage
locations so future readers don't falsely show multi-anchored txs as
"unbooked". Companion getPrimaryJournalEntryId() resolves the best
JE link to surface in UI. SQL mirror is_transaction_booked() exists
from the PR #602 foundation migration.

Existing readers (TransactionHistoryList, TransactionInboxCard) are
not yet refactored to use the helper — that's a follow-up that
touches per-tx JE links across multiple call sites. The helper is
documented + tested so subsequent refactors are mechanical.

## Tests

- tests/pg/bulk-book-transactions.pg.test.ts — 8 pg-real scenarios
  (happy path create-new with 3 txs, happy path link-existing, date
  mismatch, direction mismatch, amount mismatch, unbalanced lines,
  unauthorized).
- app/api/transactions/bulk-book/__tests__/route.test.ts — 5 unit
  tests (schema XOR, link path, create-new with template fetch +
  applyTemplate, structured-error mapping).
- lib/transactions/__tests__/is-booked.test.ts — 11 cases covering
  all three storage locations + primary-JE resolution.

26 unit tests pass on touched paths. RPC migration applied to remote
via Supabase MCP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bulk-book): PR #606 review round 1 + CI fixes

Closes the build failure and the two real Greptile findings.

## CI

- **core-only + Vercel build fail**: I used useMemo for
  selectedTransactions and bulkBookEligible on the transactions page
  without importing it. TypeScript build (`next build`) caught it
  with "Cannot find name 'useMemo'". Fixed the import.

## Review findings

- **(P1) Currency mismatch returned BULK_BOOK_DIRECTION_MISMATCH**
  whose user-facing message blames direction. Mixed SEK + EUR batches
  would show "All transactions must be the same direction" which is
  factually wrong. Introduced dedicated BULK_BOOK_MIXED_CURRENCY code
  (sv + en) explaining the actual constraint, and switched the route
  to use it.

- **(P1) Branch B (create-new) N=1 missed
  reconciliation_method='manual'**. Branch A's N=1 UPDATE sets it
  alongside journal_entry_id; Branch B's didn't, leaving the
  reconciliation_method NULL even though the single tx was reconciled
  via the same flow. Downstream readers (reconciliation reports,
  status indicators) would treat the two N=1 paths differently. New
  follow-up migration patches Branch B's final UPDATE.

RPC patch applied to remote via Supabase MCP. 26 unit 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>
2026-05-29 14:55:24 +02:00
Jakob Wennberg 7eb8715417 feat(transactions): split-payment allocator — 1 tx → N invoices (#603)
* fix(category-mapping): use leaf BAS accounts instead of group codes

3900, 5800, 6200 are BAS gruppkonton (header codes) and shouldn't carry
postings. Switched the default mappings to the matching leaf accounts:

  - income_other:     3900 -> 3999 (Övriga rörelseintäkter)
  - expense_travel:   5800 -> 5890 (Övriga resekostnader)
  - expense_telecom:  6200 -> 6230 (Datakommunikation)

The fallback for income_other inside getCategoryAccountMapping was also
hardcoded to '3900'; updated to '3999' for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(transactions): split-payment allocator — 1 tx → N invoices

Closes one of the two flows that motivated PR #602's foundation:
allocating a single bank transaction across multiple customer OR
multiple supplier invoices, with one combined verifikat
(samlingsverifikation per BFL 5 kap 6§ st 3).

## Backend (Phase 3a)

- **PL/pgSQL RPC** match_batch_allocate (~400 lines): locks the tx +
  each target invoice with SELECT … FOR UPDATE in id order, validates
  status/currency/remaining/direction before any write, builds the
  combined verifikat via commit_journal_entry (atomically assigns
  voucher_number + flips draft→posted), inserts N rows in
  invoice_payments or supplier_invoice_payments pointing at the same
  JE, advances paid_amount/remaining_amount/status per invoice. Returns
  { ok, journal_entry_id, voucher_number, allocations: [...] } on
  success or { ok: false, code, details } on guard failure. Mixed
  customer+supplier kinds are rejected (v1 scope).

- **Endpoint** POST /api/transactions/[id]/match-batch — thin wrapper
  around the RPC. Validates body via MatchBatchSchema (zod
  discriminatedUnion + superRefine to catch mixed-kinds at the schema
  layer). On RPC success, emits one invoice.match_confirmed or
  supplier_invoice.match_confirmed event per allocation so existing
  subscribers (reminders, automations, processing-history) keep
  working. Maps the structured RPC error envelope to
  errorResponseFromCode.

- **16 new BATCH_* error codes** (sv+en): BATCH_TX_NOT_FOUND,
  BATCH_TX_ALREADY_BOOKED, BATCH_OVERSHOOT, BATCH_AMOUNT_EXCEEDS_TX,
  BATCH_MIXED_KINDS_UNSUPPORTED, BATCH_DIRECTION_MISMATCH,
  BATCH_CURRENCY_MISMATCH, BATCH_PERIOD_LOCKED, BATCH_RPC_FAILED, etc.

## UI (Phase 5a)

- **MatchAllocationDialog** (components/transactions/) — direction-
  aware (positive tx → customer invoices, negative → supplier). Search
  + selectable list of open invoices. Per-row amount input with default
  = min(invoice.remaining, tx_remaining_budget). Live tally with
  green-check balanced state, red overshoot warning, gray leftover
  note. Confirm button disabled on overshoot. POSTs to /match-batch
  and on 200 triggers the same exit animation as single-tx match.

- **Inbox row** gains a second outline icon button (Split icon) next
  to the existing 1:1 match button, gated by the same
  showInvoiceMatchButton predicate. Tooltip explains the direction-
  aware split. Opens MatchAllocationDialog.

- **i18n** strings under tx_match_allocation namespace in sv.json
  and en.json (32 keys each).

## Tests

- tests/pg/match-batch-allocate.pg.test.ts — 5 pg-real tests covering
  combined verifikat shape, overshoot guard, already-booked tx,
  direction mismatch, mixed-kinds rejection.
- app/api/transactions/[id]/match-batch/__tests__/route.test.ts — 5
  unit tests covering schema validation, mixed-kinds, happy path,
  structured-error mapping, raw-error → BATCH_RPC_FAILED.

63 unit tests pass across the touched paths. The RPC migration was
already applied to remote in an earlier Phase 3a session (idempotent
CREATE OR REPLACE FUNCTION; the next replay is a no-op).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(match-batch): PR #603 review round 1 + CI fixes

Closes both CI failures and the three real review findings.

## CI fixes

- **pg-real failure**: the RPC declared
  `v_journal_entry_id uuid := uuid_generate_v4()` which fails in the CI
  Postgres image (uuid-ossp extension is off). Switched to
  `gen_random_uuid()` — the codebase standard already used by
  supplier_invoices, invoice_inbox, etc.
- **core-only failure**: my earlier BAS leaf-account commit
  (3900→3999, 5800→5890, 6200→6230) didn't update the matching
  `lib/bookkeeping/__tests__/category-mapping.test.ts` expectations,
  and `getDefaultAccountForCategory`'s fallback for `income_*` was
  still hardcoded to '3900'. Updated both.

## Review findings (greptile)

- **P1 deadlock-stable locking** (`match_batch_allocate.sql:11`): the
  validation `FOR UPDATE` loop ran in caller-supplied array order. Two
  concurrent calls with overlapping invoice sets in opposite orders
  could deadlock and one would abort with `BATCH_RPC_FAILED`. Now
  all three loops (validate, build lines, advance invoices) iterate
  via `SELECT … FROM jsonb_array_elements(…) ORDER BY
  COALESCE(invoice_id, supplier_invoice_id)`, giving a stable global
  lock order regardless of how the caller ordered the JSON array.

- **P1 duplicate-allocation detection** (`match_batch_allocate.sql:163`):
  the same invoice_id listed twice would pass the per-row overshoot
  guard (both iterations read the original `remaining_amount`) and
  the write loop would insert two `invoice_payments` rows for the
  same invoice. Added a `v_seen_ids text[]` check in the validation
  loop and a new `BATCH_DUPLICATE_ALLOCATION` error code (sv + en).
  The dialog already prevents this UI-side via `if (prev[candidate.id]
  return prev` — the RPC guard is the defense-in-depth layer.

- **P2 zod `.positive()`** (`schemas.ts:544`): allocation amount was
  `nonNegativeAmount` (allowing 0), passing schema validation only to
  be rejected by the RPC with `BATCH_INVALID_AMOUNT`. Now
  `z.number().positive(…)` so 0-amount entries fail at the schema
  layer with a per-field path, cleaner 400.

- **P2 strict `> 0` direction check** (`MatchAllocationDialog.tsx:82`):
  used `amount >= 0` to pick customer-side, but a zero-amount tx would
  load customer candidates only to hit `BATCH_TX_ZERO_AMOUNT` at
  submit time after the user has filled in allocations. Switched to
  `> 0` so 0-amount tx never reaches the dialog at all (it's rejected
  by the RPC immediately).

The fourth Greptile comment (the schema P2 about amount validation)
overlaps with the third; addressed in the same edit.

## Verification

- 112 unit tests pass across touched paths
- ESLint clean
- New pg-real test `tests/pg/match-batch-allocate.pg.test.ts` covers
  the dedupe scenario (same supplier invoice listed twice with summing
  amounts that individually pass per-row overshoot)
- RPC patch applied to remote via Supabase MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(match-batch): PR #603 review round 2 — compliance hardening

Addresses the actionable findings from compliance-swarm and
Swedish-accounting-compliance reviews. Six small RPC changes + two
TS-side guards, all bundled in one follow-up migration.

## Security

- **(GDPR Art.5(1)(f) / ISO A.8.2) Caller verification**: SECURITY
  DEFINER bypasses RLS, and the prior RPC accepted any
  (p_user_id, p_company_id) pair from the route. Now the function
  rejects with new `BATCH_UNAUTHORIZED` (sv+en, HTTP 403) if
  `auth.uid()` is not a member of `p_company_id`. Pattern lifted from
  `harden_invoice_number_rpcs` (#20260510140000).
- **(OWASP V4.2) Allocation cap**: `MatchBatchSchema.allocations` now
  carries `.max(100)` to prevent DoS via unbounded FOR UPDATE locks.

## Swedish accounting correctness

- **source_type per direction**: was hardcoded to `'invoice_paid'` for
  both customer + supplier batches, mis-routing behandlingshistorik
  filters. Customer batches keep `'invoice_paid'`, supplier batches now
  write `'supplier_invoice_paid'`.
- **Fiscal-period determinism**: `LIMIT 1` on the period lookup was
  non-deterministic on overlap (e.g. corrected broken year). Added
  `ORDER BY period_start DESC` so the most recent matching period
  wins.
- **Tolerance harmonisation**: cross-allocation sum used `+0.01`
  tolerance while per-row used `+0.005`. Both now `+0.005` so a
  multi-row batch can't drift ~0.01 SEK while each row passes
  individually.
- **`transactions.category` no longer overwritten**: was forced to
  `'income_services'` (→ BAS 3001 at 25% VAT) for any customer batch,
  misrepresenting reduced-rate / export / EU-service invoices. The
  category is only meaningful 1:1 with a single invoice; batches now
  leave it as-is, mirroring the supplier-side `ELSE category` branch.

## Tests

- `tests/pg/match-batch-allocate.pg.test.ts` now wraps every RPC call
  in `withUserContext(userId)` so `auth.uid()` resolves to the seeded
  owner. Without this the new membership check would have failed all
  existing tests.
- New pg-real test: `rejects with BATCH_UNAUTHORIZED when caller is
  not a member of the company` — outsider user gets explicit refusal.
- New happy-path assertion: `source_type = 'supplier_invoice_paid'`
  on the combined verifikat for supplier batches.

15 unit tests pass on the touched paths. RPC patch applied to remote
via Supabase MCP. Out-of-scope mcp-server changes still parked locally.

Skipped findings (documented in PR comment thread):
  - V8.2.1 ownership pre-check at route layer (RPC enforces it)
  - V4.5 / Art.5(1)(b) narrower API response and event payload —
    typed contracts require the full shapes
  - V2.4 rate-limiting — system-level, applies to all match endpoints
  - A.8.28 client-side RLS reliance — documented architectural choice
  - Direction pre-check at API layer (RPC catches with cleaner code)
  - V16 + Art.32 + Art.5(1)(b) low-severity logging nits

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:45:41 +02:00
Jakob Wennberg 7bcd46d503 feat(transactions): match overshoot guards + supplier voucher linking (#602)
* feat(transactions): match overshoot guards + supplier voucher linking

Three changes that together close the "I can't link a bank transaction
to an already-booked verifikat on the supplier side" gap and fix a
latent data-corruption bug on the per-tx match endpoints.

1. fix: clamp paid_amount on match endpoints when tx > remaining

   /api/transactions/[id]/match-{invoice,supplier-invoice} previously
   used transaction.amount wholesale as the paid amount, pushing
   invoice.paid_amount past invoice.total whenever the bank tx was
   larger than what was owed. Both endpoints now reject with
   MATCH_AMOUNT_EXCEEDS_REMAINING / MATCH_SI_AMOUNT_EXCEEDS_REMAINING
   and a structured { transaction_amount, remaining_amount, excess }
   payload that points the user at the future split-payment flow.
   FX branch already clamps to invoice.remaining_amount and is
   unchanged.

2. feat: supplier-side "link existing verifikat" (mirror of #591)

   lib/invoices/supplier-voucher-matching.ts mirrors the customer
   voucher-matching module: finds posted JEs that debit 2440
   (Leverantörsskulder), validates currency + remaining-amount, and
   atomically links them as supplier_invoice_payments rows. New
   /api/supplier-invoices/[id]/{voucher-candidates,link-to-voucher}
   routes wrap it. LinkVoucherPicker gains a mode='supplier_invoice'
   prop so the same component renders both flows. The supplier-invoice
   mark-paid dialog now uses Tabs ("Ny betalning" / "Befintlig
   verifikation") to match the customer-side UX.

3. infra: transaction_voucher_links junction + denorm guard

   Foundation migration for upcoming multi-tx ↔ multi-voucher flows.
   Adds the junction table (with RLS, updated_at, indexes), a
   block_contradictory_invoice_denorm trigger on transactions that
   refuses to set invoice_id/supplier_invoice_id to a value that
   contradicts an existing payment row, and is_transaction_booked(uuid)
   as a single source of truth for "is this tx anchored?" once
   multi-allocation leaves denorm columns NULL. No application code
   uses these yet — they unlock the batch allocation and bulk-book
   flows in follow-up PRs.

Tests: 98 unit tests pass across the touched paths (match-invoice,
match-supplier-invoice, supplier-voucher-matching, link-to-voucher).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): PR review — atomic link RPC, computeRemaining edge case, pg-real tests

Addresses the three real issues raised by Greptile on PR #602.

1. (P1) Atomic supplier voucher linking — new
   link_supplier_invoice_to_voucher PL/pgSQL RPC. The TS-side
   linkSupplierInvoiceToVoucher() previously did UPDATE-then-INSERT with
   a manual unconditional rollback. Under concurrent linking against the
   same invoice, request A's rollback could overwrite a sibling B's
   successful write while leaving B's payment row in place. Moving both
   writes into a single PG transaction (one RPC call) lets PG's own
   rollback handle the failure path correctly. TS wrapper now just
   translates the structured RPC return into the lib's Result type.

2. (P1) pg-real tests — tests/pg/transaction_voucher_links.pg.test.ts.
   CLAUDE.md mandates *.pg.test.ts for any PR adding a trigger, RPC, or
   RLS. The Phase 1A foundation migration added all three but had no
   pg-real coverage. Tests now cover:
     - trg_block_contradictory_invoice_denorm refusing contradictory
       UPDATEs on invoice_id and supplier_invoice_id
     - the same trigger PERMITTING a matching UPDATE (no false positives)
     - is_transaction_booked() returning true via journal_entry_id, via
       invoice_payments, and via transaction_voucher_links rows.

3. (P2) computeRemaining edge case — trust remaining_amount whenever
   the column is non-null (including the legitimate 0 for fully-paid
   invoices). The old "> 0" guard fell through to total - paid_amount,
   which under rounding drift could compute a tiny positive residue and
   slip a fully-paid invoice past LINK_SI_VOUCHER_INVOICE_FULLY_PAID.

The fourth Greptile comment (overdue invoices silently get no
candidates) was a misread: 'overdue' IS in the open-state list at
route.ts:35. No code change needed there.

Tests: 100 unit tests pass (16 in the directly-touched paths).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): PR review round 2 — broaden AP range, log event failures

Addresses the actionable findings from the compliance-swarm and
Swedish-accounting-compliance bot reviews on PR #602.

1. (swedish-accounting-compliance, high) AP account hardcoded to 2440
   rejected legitimate samlingsverifikationer that debit 2441
   (Leverantörsskulder i utländsk valuta), 2443 (Skuldfakturor), etc.
   BAS 2026 reserves the full 2440–2449 range for Leverantörsskulder.
   The TS-side AP_ACCOUNT constant becomes AP_ACCOUNT_PREFIX ('244')
   used with .like() and .startsWith(). The PL/pgSQL RPC's
   account_number filter becomes LIKE '244%'. The
   LINK_SI_VOUCHER_NO_AP_DEBIT error message updates to reference the
   244x range with examples.

2. (ISO 27001:2022 A.8.15 / OWASP V16) Empty catch on the
   supplier_invoice.paid event emission now logs with log.warn so a
   failure in the downstream reminder/audit subscriber leaves an
   auditable trail without blocking the response.

3. (GDPR Art.5(1)(c)) Documented design rationale for retaining
   select('*') on the post-link invoice re-fetch: the
   supplier_invoice.paid event payload is typed as
   `supplierInvoice: SupplierInvoice` in lib/events/types.ts, narrowing
   would break the subscriber contract. The event stays in-process
   and consumers legitimately need the full context.

Skipped findings:
  - V8.2.1 ownership concerns: route + RPC already filter by
    company_id from withRouteContext; the RPC's WHERE clause covers it.
  - DELETE policy scoping: matches the gnubok pattern across all
    company-scoped tables — any member with write access manages records.
  - transaction_id = NULL on the voucher-link path: by design — the
    flow has no bank tx (the voucher's 1930 line represents it).
  - Reverse-charge VAT (2614/2647) validation on linked vouchers:
    real concern but invasive change; tracked for follow-up.
  - Storno-chain integrity (linking the original of a storno pair):
    edge case; tracked for follow-up.

Tests: 26 unit tests pass in the directly-touched paths. RPC patch
applied to remote via Supabase MCP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:31:19 +02:00
Jakob Wennberg a586cc8a58 feat(transactions): show all library templates in picker; fix PSD2 seed-row collision (#596)
* feat(transactions): show all library templates in picker; fix PSD2 seed-row collision

- Booking template picker: surface every active library template, not
  only the convertible 2-account shapes. Multi-leg/complex templates
  route to the manual journal editor pre-filled via applyTemplate
  instead of being hidden. Drop direction filtering for user
  templates (inferred direction is unreliable); the curated static
  catalog still respects it. Add an "Aktivera och bokfor" recovery
  toast for TX_CATEGORIZE_INVALID_ACCOUNT mirroring the existing
  ACCOUNTS_NOT_IN_CHART flow. CreateTemplateForm reflows to one card
  per line so trash buttons stop colliding on narrow screens.

- cash_accounts.upsertFromPsd2: the seed_default_cash_account
  migration plants a manual (bank_connection_id IS NULL) row on the
  same ledger_account, so the first PSD2 sync's upsert on
  (company_id, bank_connection_id, external_uid) cannot match it
  (NULL != NULL) and falls through to INSERT, tripping the
  (company_id, ledger_account) UNIQUE constraint. Look up and
  promote the seed row in place first.

- Tests: pin the TX_CATEGORIZE_INVALID_ACCOUNT error shape the
  recovery toast parses; cover applyTemplate on shapes the
  converter rejects (split-expense and all-'business'-typed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(transactions): PR review — currency metadata, MRU ordering, observable promote, BAS validation

- TransactionBookingDialog.buildInitialLinesFromTemplate: attach
  buildCurrencyMetadata to settlement lines for foreign-currency
  transactions so the journal entry retains the original currency,
  amount, and exchange_rate. Without this, non-SEK transactions
  routed through a non-convertible template were recorded in SEK
  only with no foreign-currency annotation. (Greptile #2)

- TemplatePicker.handleSelectLibraryRaw: only bump the MRU after
  confirming the click will actually do something (i.e. converted
  OR a callback is wired). Future consumers that omit
  onPickLibraryTemplate would otherwise corrupt MRU ordering for
  templates the user never successfully applied. (Greptile #1)

- cash_accounts.upsertFromPsd2 promote-seed: add .select('id') so
  a zero-row UPDATE is observable. If the seed row vanishes
  between the SELECT and UPDATE (concurrent ops), fall through to
  the normal upsert instead of silently returning success without
  persisting anything. (Greptile #3, compliance A.8.9)

- transactions/page.tsx TX_CATEGORIZE_INVALID_ACCOUNT toast:
  validate accountNumber against /^\d{4}$/ before embedding in any
  fetch URL/body. Defense-in-depth against a malformed server
  error envelope. (compliance V8.2.1)

- categorize route test: switch the not-in-chart fixture from
  '4535' (Inköp av varor från annat EU-land — reverse-charge) to
  '5420' (Programvaror) so the example doesn't imply a domestic
  override against an EU-reverse-charge account would be valid
  without its paired moms legs. (Swedish compliance review #3)

Other review items deliberately not addressed in this PR:
- Validate-on-save that every VAT-rated template has a 'vat'
  line — overrides existing CreateTemplateForm UX, separate PR.
- vat_rate enum guard in applyTemplate — defensive; the editor
  dropdown only surfaces legal rates and is the only write path
  in production today.
- Imbalance UI warning — already handled: JournalEntryForm
  computes isBalanced and gates submission; DB trigger
  check_journal_entry_balance enforces BFL 5 kap server-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:57:59 +02:00
Jakob Wennberg ccdfed5fea feat: voucher linking, recovery ops, and salary overrides (#591)
* feat: voucher linking, recovery ops, and salary overrides

Adds reversible/correction-style write paths that customers and agents have
been asking for, plus per-run salary employee overrides.

Invoice → voucher linking
- POST /api/invoices/[id]/link-to-voucher and
  GET /api/invoices/[id]/voucher-candidates
- lib/invoices/voucher-matching.ts with full + pg test coverage
- LinkVoucherPicker UI in PaymentBookingDialog
- pending_operations.operation_type expanded with link_invoice_voucher
  (medium risk) and a (journal_entry_id, invoice_id) unique guard
- MCP: gnubok_find_voucher_candidates_for_invoice and
  gnubok_link_invoice_to_voucher tools

SIE undo
- POST /api/import/sie/[id]/undo + undo_sie_import RPC
- sie_imports.status gains 'undone'
- ImportResultStep surfaces the action; structured error SIE_UNDO_FAILED

Edit-recreate journal entries
- POST /api/bookkeeping/journal-entries/[id]/edit-recreate
- Bookkeeping detail page wires it into the existing edit flow

Delete-last-voucher clears IB link
- Trigger + pg test ensure deleting the last voucher of a period nulls the
  opening_balance_journal_entry_id link so a re-import lands cleanly

Salary employee overrides
- salary_run_employees gains per-run override fields + migration
- lib/salary/effective-values.ts centralises resolved values; all payslip,
  payment, AGI, KU, and booking routes read through it
- SalaryOverridePanel on the employee detail page

Account classifier
- lib/bookkeeping/account-classifier.ts + tests; AddAccountDialog uses it
- backfill-import-accounts script updated

Misc
- toast: minor styling tweak
- AGI generate-declaration: respect effective values
- structured-errors: new LINK_INVOICE_VOUCHER namespace

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add link_invoice_voucher operation type to pending_operations

* feat: refactor salary run calculations and update error handling for SIE imports

* fix: PR review feedback on voucher linking and SIE recovery

pg-real (blocking):
- tests/pg/delete-last-voucher-ib: drop posted_at = now() from the seed
  UPDATE — journal_entries has no posted_at column.
- lib/invoices/__tests__/voucher-matching.pg: seed the posted voucher
  before closing the fiscal period so enforce_period_lock doesn't block
  the INSERT during setup.

voucher-matching error codes and rollback:
- Add LINK_VOUCHER_DB_ERROR (HTTP 500) and return it on real invoice
  UPDATE / payment INSERT failures. Previously these returned
  LINK_VOUCHER_VOUCHER_NOT_FOUND (404) which the pending-op dispatcher
  auto-rejects on transient DB errors.
- Log rollback failures explicitly so an invoice left in a half-linked
  state (advanced status, no payment row) surfaces for manual
  reconciliation instead of disappearing silently.

resyncNextPeriodOpeningBalance ordering:
- Create the new IB first, relink the period FK, then storno the old IB.
  Previously the storno ran first; if createJournalEntry failed the next
  period was left with a reversed IB and nothing to replace it, and
  executeSIEImport swallows the error as a non-fatal warning.

replace_period_opening_balance_link:
- Tighten role check to owner/admin (was owner/admin/member). Matches
  delete_last_voucher and undo_sie_import.

Data minimisation:
- /api/invoices/[id]/voucher-candidates and the matching MCP tools now
  project only the invoice and customer fields the matcher reads, instead
  of returning the full customer row.

Schema bounds:
- SalaryEmployeeOverrideSchema caps each numeric override at 10 MSEK to
  catch typos before they reach the ledger or AGI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): supply user_id when seeding voucher_sequences

voucher_sequences.user_id is NOT NULL (per the multi-tenant refactor in
20260330130000). The previous test seed only set company_id /
fiscal_period_id / voucher_series, which made the seed fail with a
constraint violation on the latest pg-real run. Pass the same userId
used elsewhere in the seed helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): scope delete-last-voucher RPC assertions inside the tx

withUserContext always ROLLBACKs, so any DELETE the RPC performs is
discarded when the callback returns. The previous test then queried
journal_entries via a fresh getPool() connection that only saw the
pre-RPC committed seed state — hence "expected '1' to be '0'".

Move every post-RPC assertion (entry count, period FK clear,
opening_balances_set flip, audit log entry, sie_imports clear) inside
the same withUserContext callback so they observe the uncommitted state
before ROLLBACK fires.

Also fix the sie_imports INSERT: the column is `filename`, not
`file_name`, and `sie_type` is NOT NULL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): assert against the IB-marker audit row directly

DELETE on journal_entries fires two audit_log writes: the generic
write_audit_log() trigger row ("Deleted journal_entries record") and the
delete_last_voucher RPC's explicit "(was period IB)" entry. Both land
at the same statement_timestamp(), so ORDER BY created_at DESC LIMIT 1
returned the trigger row non-deterministically in CI.

Switch to a presence check with a LIKE filter on the IB marker so the
test verifies what it actually cares about — that the RPC's IB-aware
audit row exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(db): set company_id on delete_last_voucher audit_log rows

20260528120000_delete_last_voucher_clears_ib_link.sql inserts directly
into audit_log without setting company_id. audit_log's SELECT policy
filters company_id IN user_company_ids(), so those rows landed with
company_id=NULL and were invisible to every reader — only the generic
write_audit_log() trigger row remained visible. That broke BFL audit-
trail intent: the "(was period IB)" provenance row was never readable.

Republish delete_last_voucher with p_company_id populated on both
audit_log INSERTs (draft path and posted path). Behavior is otherwise
unchanged; the pg-real test for the IB-clear flow now sees the
RPC-written marker row as expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-05-28 21:09:43 +02:00
Mattsson cb7eac90b1 fix(dashboard): update Recapt integration to load globally and streamline widget rendering (#592) 2026-05-28 16:41:44 +02:00
Mattsson a9aff5a120 Bug/template and sandbox (#589)
* Enhance booking template functionality and add sandbox extraction checks

* Implement Recapt integration for feedback submission and user identification

* Add Recapt identification component and bank sync status chip; update crontab entries

* Update .gitignore to ignore the entire scripts directory

* Refactor Recapt integration: add loader component, update privacy policy, and enhance bank sync status messages

* Fix .gitignore to correctly ignore the scripts directory
2026-05-28 15:43:48 +02:00
Jakob Wennberg 20989379bb feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand (#585)
* feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand

Sandbox now ships with seeded suppliers, supplier invoices, an asset,
a verified agent_profile, and pending operations so the demo company
exercises every prod surface. Server-side `guardSandbox()` short-
circuits any AI or paid-external API call (Bedrock chat/composer,
Resend invoice send, Riksbanken FX, VIES, etc.) and the AgentSheet
swaps in a SandboxAgentPreview that explains what's gated and offers
a register CTA. DashboardContent no longer mounts the
NewUserChecklist when the agent is already built, fixing the path
that let sandbox users still trigger /onboarding/agent.

Visible branding flips from Gnubok to Accounted: new BrandWordmark
component (Hedvig Letters Serif 700), new app/icon.png + PWA icons
generated from the accounted icon, default appName updated. URLs,
header names, API key prefixes, hostnames, and event/cookie/
localStorage keys keep `gnubok` — the rebrand is visual only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sandbox): hardcode supplier-invoice arrival numbers in seed

get_next_arrival_number is MAX(arrival_number) + 1 against the same
table we're about to insert into. Calling it twice before either row
lands made both calls return 1, which then violated the
(company_id, arrival_number) unique index — POST /api/sandbox/seed
500'd on first sandbox start.

The seeded company is brand new in this branch so 1 and 2 are
guaranteed unused; hardcoding side-steps the race entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sandbox): set paid_amount=0 on unpaid supplier invoice

PostgREST normalizes the column set across rows in a bulk insert, so
the second supplier invoice (Espresso House, status=registered) was
being sent with paid_amount=null because the first row (Telia, paid)
set it. supplier_invoices.paid_amount is NOT NULL DEFAULT 0; the
default only kicks in when the column is *absent* from the payload,
not when it's explicitly null. Set it inline to side-step the
normalization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sandbox): set actor_type=agent_chat on seeded pending_operations

pending_operations only allows user-scoped INSERTs via the
`pending_operations_chat_insert` policy, which requires
actor_type='agent_chat' alongside auth.uid()=user_id +
company membership. The seed was inserting with the default
actor_type='user', tripping the RLS check.

Also lift risk_level from preview_data (where it was unused) onto
the row itself, matching the column added in
20260430120000_pending_operations_actor_and_risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pr-review): address PR #585 review feedback

Fixes called out by the core-only CI check, Greptile, and the
compliance + Swedish-accounting bots:

- AGI Programnamn pinned back to 'gnubok' (CI blocker). The XML
  Skatteverket receives must keep the stable software identifier
  regardless of the visual rebrand — same rule as the v1 health
  endpoint's `service: 'gnubok'` literal.
- handleCreateAccount in SandboxAgentPreview + ChatEmptyState now
  wraps signOut() in try/catch so a transient Supabase failure
  doesn't strand the user on a dead button (greptile P2 × 2).
- /api/currency/rate hard-fails on missing companyId instead of
  conditionally skipping the sandbox guard (greptile P2 / compliance
  V8.2.1).
- topUpSandboxAdditions now delegates to ensureSandboxAgentProfile;
  the assistant persona lives in exactly one place across the seed,
  layout backfills, and top-up path (greptile P2 outside-diff /
  compliance SOC2 CC6.1).
- ensureSandboxAgentProfile drops the userId param and sets
  verified_by_user_id to NULL — synthetic seed data should not
  attribute verification to a real user (compliance V8.2.1 /
  GDPR Art. 25(2)). Errors now logged via the structured logger
  instead of being silently swallowed (V16).
- Sandbox seed swaps real-world company names (Telia, Espresso
  House) for clearly-synthetic Demo-prefixed brands using the
  5559... documentation org-number range (compliance A.8.33).
  Asset cost bumped 24 000 → 35 000 SEK so the demo clears the
  förbrukningsinventarier threshold and illustrates capitalization
  unambiguously (swedish-asset-accounting).
- Representation pending-operation preview corrected: VAT label
  fixed from 6% → 12%, and input VAT split between the avdragsgill
  (2641) and ej-avdragsgill (5811) portions to match
  swedish-vat / ML 8 kap rules (swedish-vat).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pr-review): seed preview consistency + AGI Programnamn constant

Two last review-bot items before merge:

- Sandbox seed: the representation pending-operation preview was
  splitting the 240 SEK café meal 60/180 between 5810 and 5811,
  which is wrong for a single attendee under the 300 SEK / person
  avdragsgill cap (ML 8 kap) — the entire amount is fully
  avdragsgill in that case. Collapse the preview to a single 5810
  + 2641 + 2440 entry so it matches the supplier_invoice_items row
  1:1 and stops teaching demo users an incorrect bookkeeping
  pattern.
- Hoist the AGI Programnamn 'gnubok' literal into a named constant
  with a comment pointing to potential future Skatteverket vendor
  registration (per the swedish-compliance bot's nit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sandbox): avoid BFL duplicate-verification on pending op + fix VAT cap comment

Swedish-compliance bot caught two final nits:

- The pending operation for the Demokafé representation was using the
  same supplier_invoice_number as the already-seeded supplier_invoices
  row (88245). If the sandbox user approved the staged operation, the
  insert would have created (or attempted) a duplicate verification —
  BFL 5 kap. requires each affärshändelse be recorded exactly once.
  Swap the staged operation's invoice number to a distinct value
  (INKOMMANDE-2026-001) so approval cleanly creates a new row.
- The preview comment described the 300 SEK threshold as an
  "avdragsgill cap". The actual rule (ML 8 kap. 9 §) caps the
  deductible VAT at 25 % × 300 SEK × antal_personer = 75 SEK per
  person — the 300 SEK is the tax base, not the total. Math here is
  correct either way, but the comment now states the correct formula
  so future seed edits don't propagate the wrong understanding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:19:41 +02:00
Jakob Wennberg f53725b20a Agent v1 bundle: TIC v2 onboarding, in-app assistant gating, sidebar nav, MCP fixes (#584)
* fix(sie-import): accept tab as field separator (Bollbok exports)

The SIE 4 spec allows either space or tab between fields, but
splitSIELine() only treated space (0x20) as a separator. Bollbok
exports tab-separated lines for every record except #RAR, which
silently swallowed all #IB / #UB / #KONTO / #KTYP / #VER / #TRANS
records — imports appeared empty even though the file was well-formed.

Also adds a parser-side diagnostic that emits a warning when raw #IB
or #VER lines are present in the input but parsing produced none. The
previous silent failure is how this bug stayed hidden; the warning
gives the import preview something visible to surface next time.

Verified against two real reproducer files (Sean / Erik Hellqvist):
  erik h 2025.SE (UTF-8): 166 accounts, 66 IB, 4 UB, 11 RES, 95 vouchers, 198 TRANS.
  erik h 2026.SE (CP437): 166 accounts, 66 IB, 4 UB, 0 vouchers.
Both now parse with zero warnings/errors.

Tests:
  + 8 Bollbok-shape tab-separated fixtures (2025 + 2026 quoting variants).
  + 4 silent-failure diagnostic-warning tests.
  All 74 sie-parser tests pass; 155/155 in lib/import; 64/64 downstream callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sie-import): address PR #513 review — strip #KTYP quotes, suppress redundant aggregate warning

Two non-blocking P2 findings from Greptile review on PR #513:

1. #KTYP handler stored fields[2] directly, so Bollbok 2026 exports
   (#KTYP\t1510\t"T") stored '"T"' with literal quotes instead of 'T'.
   Latent defect — accountType is unused downstream today, but my tab-
   separator fix made the quoted-value path reachable. Now routes through
   parseStringField so both Bollbok 2025 (unquoted T) and 2026 (quoted "T")
   land as 'T'.

2. The aggregate "kontrollera fältavskiljare och teckenkodning" warning
   fired alongside per-record 'error'-severity issues for malformed #IB /
   #VER records, producing a misleading hint when the parser had already
   pinpointed the structural problem. Now suppressed when an error-severity
   issue with the same tag already exists.

Test coverage:
  + accountType asserted to be 'T' (not '"T"') in both 2025 + 2026 shapes.
  + VER aggregate-warning test now uses #VER lines without { } blocks
    (silent loss, no per-record error) — the canonical case the diagnostic
    is designed for.
  + New suppression test: bare #VER produces per-record errors AND the
    aggregate warning is absent.

75/75 sie-parser tests pass; 156/156 in lib/import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wip: agent chat + composer + memory + document extraction

In-progress work on this branch beyond the SIE-import fixes:
- Specialized accountant agent (composer + intents + chat loop)
- Persistent agent_conversations/messages, agent_profiles, agent_memory
- /chat surface + /onboarding/agent + /settings/agent-memory
- document-extraction extension with status hooks
- MCP server staging refactor + new skills (atoms, bank reconciliation,
  customer onboarding, kreditfaktura)
- pending_operations rejection feedback (category + reason) + realtime
- TIC company profile cached snapshot on companies
- 17 migrations (all additive — see prior conversation analysis)

Parked while branch waits for review/merge. Migrations are already
applied to prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(tic): migrate company-data client from api-core v1 to Lens v2

Swaps the seven TIC company-data endpoints we call from the api-core
paths (`/datasets/companies/{companyId}/...`, `/search/companies`) to
the Lens equivalents (`/companies/{id}/...`, `/search-public/companies`).
Hard cutover; proxy pattern preserved.

Schema shifts handled inside the extension so consumers (TicWorkspace,
Step2CompanyDetails) don't need changes:

- `/companies/{id}/bank-accounts` now returns Bankgirot only — map to
  the existing `{ type, accountNumber, bic }` shape, drop terminated.
- `/companies/{id}/industries` returns a discriminated array — filter
  to `companyIndustryCodeType === 'sni2007'` to preserve v1 behavior.
- `/companies/{id}/phone-numbers` renamed the field to
  `phoneNumberFormatted` (fall back to `e164PhoneNumber`).
- `/companies/{id}/documents` replaces `/financial-report-summaries`;
  filter `type === 'annualReport'` and read nested
  `financialReportMetadata` to rebuild the legacy summary shape.
- `isCeased` is now a top-level boolean; `activityStatus` is an enum.
  Translate enum -> 'ceased' for the workspace's existing check.

BankID identity flow (id.tic.io) is untouched — separate TIC product.

Note: deploy gated on the TIC proxy being flipped to lens-api.tic.io
with an `x-api-key` Lens key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tic): expose v2 onboarding & workspace data

Adds six new Lens (v2) fetchers on top of the migration that already
landed in this branch, surfacing the data through /lookup and /profile.

New fetchers in lib/tic-client.ts:
- getFiscalYears          /companies/{id}/fiscal-years
- getAccountingPeriods    /companies/{id}/accounting-periods
- getPayrolls             /companies/{id}/payrolls
- getSignatory            /companies/{id}/signatory
- getRepresentatives      /companies/{id}/representatives
- getCompanyStatus        /companies/{id}/status

/lookup gains a fiscalYear field (current fiscal-year configuration)
so onboarding Step 2 can skip manual MM-DD entry. CompanyLookupResult
extended with optional fiscalYear; consumers without it keep working.

/profile gains five new sections on TICCompanyProfile:
- fiscalYear + fiscalYearHistory   current + deduped period list
- signatory                        firmateckning descriptions
- board + representatives          board-composition summary + active
                                   officers (positionEnd in future)
- payrolls                         payroll2 array newest-first, with
                                   deviation vs annual-report
- statuses                         current+historical status entries
                                   with red/yellow/green/neutral color

TicWorkspace renders the new data as four cards (Status, Fiscal year +
Signatory, Board + Representatives, Payroll history) plus a Badge
mapping for the traffic-light status color.

Tests: 52 -> 60 passing. Added unit tests for the new fetchers' v2
paths, fiscal-year auto-fill in /lookup, and full v2 profile coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding,agent): lean on TIC v2 to skip Steps 1 & 3 and sharpen Opus

Three small wins that unlock more of the v2 cutover. No new endpoints — the
data was already in the snapshot, just not flowing where it should.

Step 1 (entity_type) — deep-link path only:
- /lookup now returns `legalEntityType` and `registrationDate` (added to
  CompanyLookupResult).
- /onboarding/page.tsx does a server-side /lookup prefetch when
  ?org_number= is present (BankID picker path), maps "AB"/"EF" to the
  EntityType enum, and seeds Step 1's radio. Falls through silently for
  unsupported codes (HB, KB, …) and on TIC errors.
- WelcomeOnboarding hydrates ticLookup state from the server prefetch so
  Step 2's debounced client fetch and Step 3's first-year inference both
  have data on first render — no flash.

Step 3 (is_first_fiscal_year) — every path:
- deriveFirstYearDefaults() parses ticLookup.registrationDate and returns
  { isFirstFiscalYear, firstYearStart } when registered <12 months ago.
  Step 3's initialData picks it up; the user only confirms the end date.
- Settings value wins when present so existing users with a saved choice
  don't get overridden.

Composer prompt:
- redactTic allowlist was the bottleneck — it stripped beneficialOwners,
  signatory, board, representatives, payrolls, statuses, fiscalYear
  before Opus ever saw the JSON. Existing filterRedundantQuestions
  ownership logic was effectively dead because the data path was severed.
  Expanded allowlist to include those v2 sections; kept bankAccounts/
  email/phone/fiscalYearHistory/financialReports out (token cost > signal).
- SYSTEM_PROMPT now documents each v2 section and the rules Opus should
  apply: payroll signal switches from "registration.payroll" to "actual
  payrolls[] filings" (kills the false-positive swedish-payroll selection
  for newly registered employers); beneficialOwners[] becomes the
  authoritative ownership source (single owner → FMB modifier; multiple →
  multi-owner); statuses[] isCeased/red triggers an uncertainty_note.

Tests: 4112 unchanged. Build: green. No schema or migration changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): onboarding polish + composer signal fixes from first-run feedback

UX:
- AgentOnboarding: drop the 10s "Hoppa över — fortsätt med standardval"
  escape hatch. The fallback path runs automatically on timeout; the
  manual skip just teased users into a degraded build.
- ReviewCard step 2 title: "Stämma av detaljerna" → "Stäm av detaljerna"
  (imperative form matches the rest of the steps).
- Drop em-dashes from user-visible Swedish strings in AgentOnboarding +
  ReviewCard (fallback labels, subtitles, placeholder, error message,
  final CTA). Em-dashes survive in code comments only.
- "Fråga min revisor" → "Fråga min assistent" everywhere it surfaced:
  AgentTrigger, AgentSparkleButton, ReviewCard preview, ReviewCard
  fallback comment, general.help intent buttonLabel + prompt text.
- AgentTrigger / AgentSparkleButton / EmptyState.AgentHelpLink /
  TransactionInboxCard ask-button all gated on identity.isVerified.
  Pre-onboarding users no longer see the floating FAB or per-page
  Sparkle buttons. AgentSheetProvider.identity gained an isVerified
  field; (dashboard)/layout.tsx selects agent_profiles.verified_at and
  passes it through.

TIC verksamhetsbeskrivning:
- tic/index.ts /profile: /companies/{id}/purposes returns every
  historical verksamhetsföremål filing. Picking [0] was returning the
  oldest "äga och förvalta" holding-company boilerplate for companies
  whose later filings narrowed the purpose ("tillhandahålla
  företagskrediter och finansiella teknologilösningar"). Sort the
  array by lastUpdatedAtUtc desc and take the most recent non-empty
  purpose.

Composer banking signal:
- loadBankingSummary now reads journal_entry_id alongside
  description/amount/date and returns per-counterparty `direction`
  ('in' | 'out' | 'mixed') and `has_unbooked` (any row not yet booked).
  Aggregate `unbooked_count` accompanies the rollup.
- buildUserPrompt emits each counterparty as
  `Name: 12 345 kr (ut, OBOKFÖRD)` so Opus can tell income from cost
  on sight and tell which counterparties are still open questions.
- SYSTEM_PROMPT now explicitly forbids verification questions about
  counterparties whose direction is unambiguous AND status is 'bokförd'.
  Should kill the regressions from the first agent build:
  * "Konsult, J 98 565 kr — intäkt eller kostnad?" when the amount is
    clearly negative.
  * "ALMI AB 493 000 kr — lån eller bidrag?" when the transaction is
    already categorized.

Tests: 4112 unchanged. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent,ui): representation needs deltagare+syfte, drop duplicate doc icon

Representation booking:
- transaction-categorization prompt now requires the agent to capture
  participants (name + company) AND purpose before staging a
  representation categorization. SKV's representationsregler + ML 8 kap
  require the verifikation to document who attended and what the
  meeting was about; without that the avdrag is denied and the post
  should be booked as non-deductible / personalkostnad.
- The agent confirms back in plain text (audit trail in the chat),
  writes the deltagare + syfte to gnubok_remember_fact (long-term),
  THEN stages. Saknas deltagare/syfte: explicitly tell the user the
  avdrag won't go through and offer the non-deductible alternative.
- Known gap (followup, not this commit): the staged op's journal entry
  description doesn't yet carry the deltagare text. Until we add a
  `notes` field to gnubok_categorize_transaction, the audit trail
  lives in chat + agent_memory only.

TransactionInboxCard duplicate attachment indicator:
- Drop the FileCheck2 "open document" button from the trailing slot.
  TransactionAttachmentIndicator (Paperclip) next to the description
  already opens the underlag on click. Two icons doing the same thing
  was noise. Cleaned up the unused state (isOpeningDoc, hasAttachment,
  handleOpenAttachment) and dropped now-unused imports (FileCheck2,
  useToast).

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent,nav): notes on verifikation + redesigned sidebar

Audit-trail notes for representation:
- gnubok_categorize_transaction gains an optional `notes` string.
  Threaded through stagePendingOperation → commitCategorizeTransaction →
  createTransactionJournalEntry, which now appends notes to the entry's
  description (capped at 500 chars). The verifikation an external auditor
  reads now carries deltagare + syfte directly — not just chat history /
  agent_memory.
- transaction-categorization prompt updated: representation flow now
  REQUIRES the agent to pass deltagare+syfte via the notes parameter.
  Without it the booking is non-deductible / personalkostnad per SKV.

DashboardNav redesign:
- Top section: flat, no header — Hem (/chat), Underlag (was
  Dokumentinkorg), Transaktioner, Granskning. Always visible; the inline
  badge on /pending shows the count when there are pending ops.
- Mid section: four collapsible dropdowns (Försäljning, Inköp,
  Redovisning, Personal). Each auto-expands when the active route lives
  inside it. KPI moved from main to Redovisning. Extension nav items
  (TIC workspace, etc.) fold into Redovisning.
- Bottom-left: new account popover (DropdownMenu, opens upward) holding
  CompanySwitcher, Inställningar, Hjälp, Support, Logga ut. Replaces
  the old top company-switcher card + the bottom Support/Logout block.
- Mobile drawer mirrors the new structure: top items as flat list,
  same four dropdown groups, separate "Tillägg" section when
  extensions exist, "Mitt konto" section at the bottom.
- i18n: invoice_inbox label renamed "Dokumentinkorg" → "Underlag"
  ("Documents" in en). New keys: mitt_konto, group_extensions.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nav): unhide Leverantörer under Inköp

The /suppliers entry existed in navItems but was marked hidden — leftover
from when the supplier list lived elsewhere in the IA. Removing the
hidden flag puts Leverantörer in the Inköp dropdown alongside
Leverantörsfakturor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nav): CompanySwitcher back to top-left, user account moves bottom-left

The previous pass collapsed both concepts into the bottom popover. They
mean different things: the company is the org context everything below
operates against (top-of-sidebar, scannable); the user is the
account-holder (bottom-of-sidebar, where settings/logout live).

- (dashboard)/layout.tsx: fetch profiles.full_name alongside the
  existing identity queries; pass userName + userEmail into
  DashboardNav.
- DashboardNav: restore CompanySwitcher at the top of the sidebar
  (pre-redesign placement). Bottom-left popover trigger now shows the
  signed-in user's name + single-letter initial (accountInitial helper
  falls back to email's first char, then "?"). Popover header carries
  full name + email; items unchanged (Inställningar, Hjälp, Support,
  Logga ut). CompanySwitcher removed from inside the popover — nested
  dropdowns were awkward and the top placement is where it belongs.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pending): trim the agent context strip

The row-level AgentContextStrip on /pending was rendering the model
name (eu.anthropic.claude-sonnet-4-6) and the full atoms array
(horizontal/swedish-vat, vertical/konsult-it, …) inline, which made
each row 60–80 chars of mostly-the-same metadata. Reviewers never
scan that text; they scan amounts and decide approve/reject.

Now the strip shows only the conversation deep-link
(Konversation #<short id>) — the one piece that's actually useful for
diving into context. Model + atoms remain available in agent_metadata
for debugging surfaces; they're just not in the list view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): shared ground rules + paragraph breaks after tool calls

Two regressions surfaced in real usage. Both are systemic.

Shared agent ground rules:
- /chat surface (general.help) was happily inventing four-digit BAS
  account numbers ("Debet 6212 - Molntjänster…", "Kredit 2614 - Ingående
  moms…") and proposing booking decisions on invoices it had never
  seen, with no follow-up questions about currency/scope/etc.
- transaction-categorization had those rules baked into its prompt;
  general-help / bokslut-step / invoice-draft / supplier-invoice-review
  / verifikation-draft / vat-review never inherited them.
- Extracted lib/agent/intents/shared-rules.ts with five cross-cutting
  rules: underlag first (check inbox + ask user to upload to
  Dokumentinkorgen when missing), ask follow-ups when ambiguous, never
  write four-digit BAS account numbers in chat (category names only),
  cite atoms / load skills (don't guess), check counterparty history
  before proposing.
- Injected renderAgentGroundRules() into all six intents above.
  transaction-categorization left alone — it has more detailed inline
  rules tied to its specific underlag-flow.

Paragraph break after tool calls:
- text_delta from the model often resumes after a tool call without a
  leading newline ("kategoriseras." → gnubok_query_journal runs → "Inget
  historik hittades…" appended directly). Markdown rendered the
  concatenation as one paragraph.
- AgentChat text_delta handler now inserts \n\n when (a) the buffer
  ends with text content, (b) the incoming delta starts with text
  content, (c) at least one tool call has run, and (d) the buffer
  doesn't already end with a blank line.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nav): default-open dropdown groups; closing is per-user

Dropdowns started collapsed which meant first-time users had to open
each group to discover what's inside. Inverted the state: default open,
user can collapse, active route still forces a group open.

- manualExpanded → manualCollapsed (semantics flip)
- toggleGroup unchanged externally; flips the bit
- isGroupExpanded returns !manualCollapsed[g] || hasActiveChild

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): rate-safe v1→v2 TIC upgrade, counterparty defaults, profile settings

Three pre-ship quality wins.

Rate-limit-safe TIC v2 upgrade:
- The /profile endpoint fans out to ~13 Lens calls; the account has a
  ~3000/mo ceiling. Force-refreshing every pre-v2 (v1) snapshot across
  the customer base would blow the budget.
- ensureTicSnapshot gains an `upgradeV1` flag. A cached snapshot still
  inside the 7-day window is re-fetched only when (a) the caller passes
  upgradeV1 AND (b) the snapshot is v1-shaped (missing the v2-only
  `statuses` key). Gated to the two agent-onboarding call sites — a
  deliberate, once-per-company action and the only consumer of the v2
  sections. Workspace + signup keep the natural 7-day staleness, so the
  v1→v2 migration is lazy and bounded to companies actually building an
  agent.

Known-counterparty defaults (shared-rules):
- Agent now proposes a sensible default for well-known counterparties
  instead of asking the same question monthly: Almi → lån, Tillväxtverket/
  Vinnova/EU-stöd → bidrag, Skatteverket → skatt/avgift or återbäring,
  Bolagsverket → avgift, Försäkringskassan → ersättning, EF private
  withdrawal → eget uttag. Stated as an assumption the user can correct,
  not a hard rule — underlag/history still wins.

Företagsprofil settings page:
- New /settings/agent-profile (Företagsprofil / "Company profile"):
  view + edit the agent's company profile after onboarding — assistant
  name + avatar, the profile summary the agent reasons from, and a
  read-only chip view of loaded specialities (atoms). Backed by the
  existing GET/PATCH /api/agent/profile.
- New GET /api/agent/atom-titles?ids= resolves atom slugs → human titles
  for the chips (registry is globally-readable reference data).
- Added to SettingsSidebar; i18n keys agent_profile (sv "Företagsprofil"
  / en "Company profile").

Note: /chat already redirects unverified users to / (chat layout guard),
and / renders WelcomeGate → /onboarding/agent. No redirect work needed.
AgentSetupBanner.tsx is orphaned dead code (WelcomeGate superseded it).

Tests: 4112. Build: green. Both new routes compile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(nav,agent): Hem=Översikt + separate Assistent button; memory dedup

Nav restructure:
- "Hem" now points to / (Översikt dashboard) again, not /chat. The agent
  chat gets its own top-level nav entry "Assistent" (Sparkles icon) → /chat.
  Mobile bottom nav mirrors this (Hem / Assistent / Transaktioner).
- / restored to render DashboardContent (the Översikt) for built-agent
  users instead of redirecting to /chat. Users who haven't built their
  assistant yet still get WelcomeGate (the build-agent checklist); once
  verified, / shows the dashboard. Chat is reachable anytime via its nav
  entry. Restored main's dashboard data-fetch; added an agent_profiles
  verified_at probe to drive the WelcomeGate branch.
- i18n: nav.assistant ("Assistent" / "Assistant").

agent_memory dedup (gnubok_remember_fact):
- The agent re-remembers the same fact constantly (e.g. "Vercel = omvänd
  skattskyldighet" on every Vercel categorization), which would bloat
  agent_memory with paraphrases over months.
- Before insert, compare the incoming fact against the 300 most-recent
  active memories by word-set Jaccard similarity (lowercased, punctuation-
  stripped, stopwords dropped). A near-duplicate (≥0.82) is treated as
  already-known: bump its relevance toward the new score + refresh
  updated_at instead of writing a new row. Embedding-free, zero added
  latency beyond one bounded SELECT.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent,nav): företagsprofil=Bolagsuppgifter, avatar nav icon, dedupe greeting

Företagsprofil settings page (the right content this time):
- Replaced the agent atoms/summary panel with CompanyProfileView — a
  read-only "Bolagsuppgifter" view of the cached TIC company snapshot
  (name, org-nr, form, address, F-skatt/Moms/Arbetsgivare, SNI, bank,
  verksamhet, employees, latest financials, status traffic-lights,
  fiscal year, firmateckning, företrädare). Server component reads the
  companies.tic_snapshot column directly — no extension import, stays
  inside the core-build boundary.
- Route renamed /settings/agent-profile → /settings/company-profile.
  Removed the old AgentProfilePanel + the now-unused /api/agent/atom-titles
  endpoint.

"Assistent" nav icon = the agent's chosen avatar:
- DashboardNav reads agent identity from AgentSheetProvider and renders
  the onboarding-chosen avatar for the /chat ("Assistent") entry across
  desktop sidebar, mobile drawer, and mobile bottom nav. Falls back to
  the Sparkles glyph pre-onboarding (no avatar yet).

Nav cleanup:
- Dropped the beta badge from Underlag.
- Filtered the TIC workspace (/e/general/tic, "Företagsprofil") out of
  the nav — the same Bolagsuppgifter now lives under Inställningar →
  Företagsprofil, so it shouldn't appear in two places.

Doubled intake greeting fix:
- /chat/intake fires an invoke with no conversation_id, then swaps the
  URL to /chat/[id] the instant the `conversation` event lands — which
  can beat the greeting being persisted. /chat/[id] then hydrated with 0
  messages and, because the auto-fire guard keyed on (id && messages>0),
  fired a SECOND invoke on the same conversation → two greetings.
  Guard now keys on conversation-id presence alone: a set id means
  resume, never bootstrap. Closes the race.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): paragraph-break-after-tool split words mid-stream

The earlier "insert \n\n when text resumes after a tool call" heuristic
re-evaluated on EVERY text_delta (any delta not starting/ending with
whitespace, once a tool had run). Streaming deltas arrive in sub-word
chunks, so it injected breaks between fragments of the same word:
"minnes\n\nno\n\nterna", "kund\n\nrep\n\nresentation".

Replace the per-delta heuristic with a consume-once ref:
- tool_use sets breakBeforeNextTextRef = true
- the next text_delta consumes it: prepends \n\n exactly once (only when
  the buffer has content, doesn't already end in whitespace, and the
  delta doesn't start with whitespace), then clears the flag

So the break fires once per tool→text resume, never mid-word.

Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): much shorter replies, representation headcount + VAT cap, dot separator

Brevity (system-prompt Svarsformat — affects every reply):
- Hard "korthet är regel nummer ett": aim for 2-4 sentences, lead with
  the answer/action, no warm-up ("Här är vad som gäller…"), don't derive
  VAT in prose, don't restate what the approval card shows, one question
  at a time. The agent was writing textbook-length essays.

Representation rule now in shared-rules (so verifikation-draft, vat-review,
etc. all get it — previously only transaction-categorization had it, which
is why the verifikation flow guessed 25% VAT and skipped the cap):
- Require ANTAL deltagare (headcount), not just one name — the moms
  deduction is per person (underlag cap 300 kr/person ex moms).
- Use the receipt's ACTUAL VAT rate (usually 12% on food), never assume
  25%.
- Meal representation isn't income-tax deductible (post-2017); whole cost
  booked as non-deductible representation.

Verifikation description separator:
- createTransactionJournalEntry appended notes with an em-dash
  ("Utlägg Eatnam — Deltagare:…"), violating house style. Switched to a
  middle dot " · ". journal_entries has no separate notes column — the
  description IS the BFL verifikationstext / audit field, so deltagare +
  syfte correctly live there.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): tidy Bolagsuppgifter — no status colours, clean firmateckning

From first-look feedback on the Företagsprofil page:

- Status: dropped the coloured traffic-light badges (red/yellow/green).
  Per the design system semantic colour is data-only, never chrome, so
  status now renders as plain label + date. Also filtered to dated
  entries only — Bolagsverket emits flags like "Har aldrig varit verksam"
  with no date that read as noise next to the real status. Ceased status
  gets muted destructive text (the one chrome colour the system keeps).

- Firmateckning: the source text carries ">" list markers and crams
  several rules onto one line, and repeats "Firman tecknas av styrelsen"
  across rows. cleanSignatory() strips the markers, normalises whitespace,
  splits run-on "Firman tecknas …" clauses onto separate lines, and the
  render dedupes — so each rule reads as its own sentence.

Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): inbox items expose all terminal links + processed flag

The Eatnam receipt was booked against its bank transaction (so the inbox
row had matched_transaction_id + created_journal_entry_id set), yet the
agent reported it as loose/unmatched and a duplicate risk. Root cause:
gnubok_list_inbox_items only selected and returned matched_supplier_id +
created_supplier_invoice_id — the supplier-invoice path. The
transaction-match and direct-journal-entry paths were invisible, so any
receipt cleared via /transactions looked unprocessed.

- list_inbox_items now selects + returns matched_transaction_id and
  created_journal_entry_id alongside the supplier fields, plus a derived
  `processed` boolean (true when ANY of the three terminal links is set).
- New unprocessed_only=true input filters to items with no terminal link
  — the "what still needs handling" view that prevents the agent from
  flagging already-booked docs as duplicates. (Fetches a wider window
  then filters client-side so limit applies post-filter.)
- Description updated to document the processed semantics, within the
  280-char tool-description budget.

The DB linkage itself already worked: /transactions attach-document sets
matched_transaction_id, and commitCategorizeTransaction stamps
created_journal_entry_id. This was purely a read/surface gap.

Tests: 4112 (+ MCP description guard). Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): repair stage-but-never-commit tools + consolidate tool surface

- post_annual_depreciation AND reverse_entry were never in the pending_operations operation_type CHECK, so both staged then died with check_violation at INSERT. Add the CHECK migration, a commitPostAnnualDepreciation executor (reusing commitAnnualPostings), risk tier, and the PendingOperationType union member.
- Salary tools de-risked: calculate_salary_run calls runSalaryCalculation() directly (no self-fetch/forged cookie); create_salary_run uses a transactional create-run helper with compensating delete; generate_agi actually generates + persists the declaration.
- import_sie parses + validates at stage time with a content-rich preview (company, fiscal year, voucher/account counts, balance) instead of a blind byte count.
- batch-match-invoices passed user.id where companyId was expected (silently matched zero).
- VAT report+widget merged behind render_ui; gnubok_search_tools ranks by relevance; gnubok_feedback readOnlyHint corrected; tools/list instruction text fixed; income decision-tree + GL/query_journal cross-refs added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): load skill atom bodies from the DB so they survive the build

Skill bodies were read from disk at runtime (.claude/skills/**/SKILL.md); on Vercel the dynamic readFile path isn't traced into the lambda and on Docker .claude/ is excluded, so atoms loaded EMPTY in production — a despecialized agent. Inline the bodies into agent_atom_registry instead:
- Migration adds body + mcp_exposed columns; a build-time generator (scripts/generate-skill-bodies.ts) emits a deterministic dollar-quoted seed migration with a content-hash manifest + --check CI guard.
- Read sites (mcp-server atoms.ts, chat system-prompt.ts, composer prewarm) read body from the DB, with a dev-only disk fallback. mcp_exposed curates which atoms the MCP exposes (swarm-* never become atoms).
- The seed script + generator share scripts/lib/atom-discovery.ts; estimated_tokens now reflects SKILL.md only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): safe the in-app assistant — gating, FAB de-confliction, rate limit, friendly errors

- Hide all agent entry points until verified_at: the Assistent nav tab (sidebar + mobile) and the agent-memory settings tab now match the floating FAB's gate.
- FAB de-confliction: /kpi -> kpi.explain and /bookkeeping/year-end -> bokslut.step so the floating button opens the SAME assistant as the page button (no two-agents-on-one-page).
- Generous per-user rate limit (30/min, 1000/day) on /api/agent/invoke, /onboarding/stream, /composer via a new agent_rate_counters table + check_and_increment_agent_quota RPC; fails open. Bounds runaway Bedrock spend without touching normal users.
- Friendly errors: Bedrock 429/timeout/5xx normalized to Swedish (friendlyModelError) in run-turn + the invoke route; the chat client surfaces the server's friendly message instead of a raw HTTP status.
- /chat/new validates ?intent= against the registry so bad deep-links fall back to general.help instead of rendering a broken-looking error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): keep /chat read-only — redirect categorization + swap the "categorize" suggestion for a VAT-report question

general.help (the /chat assistant) is read-only, but it still gave per-transaction bokföringsförslag in prose and asked "godkänner du dessa?" — an analysis the user can't act on (no write tool, no per-tx underlag). Strengthen the prompt to redirect categorization/bokföring to the per-transaction flow (open the transaction -> "Fråga om denna transaktion", where the agent sees the underlag and stages a real ApprovalCard); a short overview is still allowed. Add a guard test locking in no-write-tools + the redirect language. Swap the /chat empty-state "Hjälp mig kategorisera" chip (which lured users into exactly this dead-end) for a VAT-report question the read-only assistant can actually answer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(pending): declutter the review queue rows + header

Fold the conversation deep-link onto the actor label (drop the separate
"Konversation #xxxx" strip and its icon), hide the quick-pick when there's
only one operation type (it duplicated "Markera alla"), and drop the "(0)"
from the disabled bulk-approve button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(vat): enhance VAT handling by integrating document validation and improving error messaging

* feat(settings): add assistant knowledge surface + consolidate settings tabs

Expose the agent's skill atoms (agent_atom_registry) in a read-only surface
beside the existing memory view, and tighten the settings tab bar from 14 to
10 tabs.

- New GET /api/agent/skills + AgentSkillsPanel: lists active, mcp_exposed
  atoms grouped by tier (Kärnkompetens / bransch / bolagssituation), flags
  which are active for the company from agent_profiles, and lazy-loads each
  SKILL.md body on expand.
- New /settings/assistant tab with a Minne/Kompetens toggle (?view=skills);
  /settings/agent-memory and /settings/agent-skills redirect into it.
- Merge Företagsprofil (TIC snapshot) into the Företag tab via
  CompanyProfileSection; /settings/company-profile redirects.
- Merge Skatteverket-anslutningen into the Skatt tab — OAuth returnTo and the
  callback toast now target /settings/tax; /settings/skatteverket redirects.
- Drop the Säkerhetsbackup tab (already under Importera/Exportera).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(inbox): keep booked underlag out of the unmatched queue + widen match window

- categorize: after booking an inbox underlag onto a verifikat, backfill the
  inbox row's matched_transaction_id + created_journal_entry_id so it stops
  showing as unmatched (mirrors the /attach-document paperclip path).
- TransactionMatchPicker: bias the candidate window forward (60d before →
  180d after the invoice date) so late payments aren't dropped before scoring,
  and widen the ranking date tolerance to 120d so the true match floats to the
  top instead of collapsing to "Svag match". Fix "okatigoriserade" typo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wip: bundle in-progress branch work + agent onboarding chat optimizations

Captures the uncommitted work-in-progress on this branch so it lives on the
remote. Heterogeneous changeset — bundled as one commit since the work was
already entangled across files.

Headline change in this commit (from this session):
- Remove the double interview in agent onboarding. Phase B's verification-
  question form stepper is gone — the Phase C chat (onboarding.intake) now
  owns the entire interview and reads the composer's verification_questions
  server-side as its question bank.
- ReviewCard collapses from 3 steps to 2 (meet → review-and-confirm) with
  value-first ordering: profile + "vad jag kan hjälpa dig med" + facts +
  optional seed note. CTA reads "Möt {namn}" to signal the chat follows.
- ChatIntakeStarter handoff subcopy updated to match reality (assistant
  greets first; user can leave anytime).
- Stamp agent_profiles.intake_completed_at server-side in
  app/api/agent/invoke/route.ts on the first user-typed reply in any
  onboarding.intake conversation (idempotent IS NULL guard, best-effort).
  Closes the previously dead-write column and unlocks the opportunistic-
  follow-up hook the migration anticipated.

Plus in-progress branch work being carried forward (not introduced here):
agent runtime + intent prompts, composer + atom-discovery scripts, MCP
server skills surface, onboarding flow components, dashboard/inbox tweaks,
two new agent_atom_registry migrations, additional agent-chat tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(agent): drop inline "Fråga assistenten" affordances — rely on the FAB

The bottom-right "Fråga {namn}" FAB (AgentTrigger) is already route-aware
and picks the right intent per page, so duplicating it as inline page-
header buttons and empty-state links is noise. Removed:

- EmptyState `agentHelp` link ("Eller fråga {namn} hur du kommer igång")
  + the AgentHelpLink component + agent_default_name/agent_ask_link i18n
  keys + the agentHelp props on EmptyInvoices/EmptyCustomers/EmptyTransactions.
- AgentSparkleButton on /bookkeeping (verifikation.draft) and /kpi
  (kpi.explain) page headers.

The FAB stays — when verified, it appears on those routes and routes to
the right intent automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): gate the last two ungated "Fråga assistenten" affordances

Both surfaces previously called useAgentSheet directly without checking
identity.isVerified, so they appeared pre-onboarding (everywhere else the
FAB / sparkle buttons / /chat / Assistent nav are all gated on verified_at).

- Settings page header: remove the "Fråga {namn}" pill entirely. The FAB
  covers /settings routes route-aware (settings.help) — no need for a
  duplicate inline trigger.
- Invoice inbox transaction picker: hide the "Fråga assistenten" button
  when the agent isn't built. Done at the parent (InvoiceInboxWorkspace)
  by passing onAskAssistant only when identity.isVerified is true; the
  child renders the button only when the callback is present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tic,onboarding,agent): single-call TIC lookup + director-aware narrative voice

- TIC: collapse the company lookup from 6 endpoint calls to 1
  (search-public already exposes sniCodes, bank accounts, emails, phones,
  and registration flags). Derive fiscal-year MM-DD from
  mostRecentFinancialSummary; newly-registered companies fall through to
  the client's first-year defaults.
- Onboarding: BankID picker no longer auto-provisions companies. Every
  pick routes through the wizard with orgnr (and entity_type via the
  CompanyRoles match) prefilled; F-skatt/VAT/address get confirmed in
  steps 2-4 instead of being auto-fetched. createCompanyFromOnboarding
  reuses CompanyLookupResult and adds a defensive top-level catch so
  server-action errors surface to the UI instead of being redacted.
- Agent composer: loadUserDirectorship() checks BankID CompanyRoles for
  a director-like position (ceo/boardMember/chairman/externalSignatory,
  active) before the narrative uses second-person ownership voice
  ("Du driver…"); unknown users get neutral third-person voice so we
  never put ownership words in the user's mouth.

Tests cover loadUserDirectorship, narrative voice, tic-fetch path,
onboarding page, and updated TIC client + lookup/profile suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tic): extend agent-onboarding TIC budget to 10s + backfill stranded org_numbers

The 5s TIC fetch timeout aborted client-side before the upstream Lens
fan-out (~13 calls) could complete, but the in-flight upstream calls
still counted against quota — actions.ts already documents ~530 wasted
calls from this in May. Same bug still applied to the agent-onboarding
stream path. Adds an optional `timeoutMs` to `ensureTicSnapshot` so
deliberate wait-screen callers (agent onboarding stream) can run with
10s while background/dev callers stay on the conservative 5s default.

Page-level server fetch (page.tsx) intentionally stays at 5s to avoid
blocking TTFB without a visible progress affordance.

Backfill migration mirrors `company_settings.org_number` to
`companies.org_number` for the 105 cases where it's safe (after dedup
+ conflict filtering). 56 of those are on active companies — unblocks
duplicate guards, SIE/SRU exports, and TIC fallback chain. Zero TIC
API calls — pure data move. Idempotent.

Also sweeps a pre-existing SSRF guard on the stream route's origin
derivation that was sitting unstaged in the working tree — it lives in
the same diff hunks as the TIC budget change and couldn't be split cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wip: bundle in-progress branch work

Sweep up uncommitted agent/MCP/RLS work-in-progress so the branch is fully
backed up to origin. Not reviewed in detail — committed as-is to preserve
working state alongside the TIC fixes in the previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): tag the "Bygg din bokföringsassistent" CTA as Beta

Adds a Beta badge next to the assistant-setup heading on the dashboard
banner, dashboard inline card, and onboarding checklist row. Also drops
the stale "Gratis i 30 dagar" subline from the dashboard card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(build,migrations): PendingOperationType salary ops + resolve migration version collisions

PR #584 went red on three things:

1. core-only build / Vercel: `lib/pending-operations/commit.ts:2666` switched on
   'create_salary_run' and 'generate_agi' but `PendingOperationType` was missing
   both literals. Add them to the union.

2. Supabase preview: migration version 20260526120000 collided with main's
   newly-merged 20260526120000_fix_replace_sie_import_hard_delete.sql.
   Bump the branch's pair to 20260526120050 / 20260526120051 — still ahead of
   20260526120100_restvardeavskrivning so ordering is preserved.

3. 20260527170000 was used twice on this branch
   (_agent_rls_with_check + _journal_entry_no_doc_required). Bump the second
   to 20260527170100 so the pair stays orderable and Supabase doesn't choke
   on the duplicate schema_migrations PK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): reword comment so core-only guard stops flagging it

The "Check no core imports from extensions" step greps for the literal
\`from '@/extensions/\` across lib/, app/api/, components/. A comment in
lib/agent/composer/tic-fetch.ts quoted the exact pattern verbatim to
explain *why* the file does a self-fetch instead of importing the TIC
extension directly — which the grep matched even though no actual
import exists.

Rewrite the line to keep the same meaning without the literal pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-05-28 11:27:01 +02:00
Mattsson a9b43ebeb7 Bug/vat selection warning (#583)
* refactor: update VAT handling logic for non-registered sellers and improve related comments

* chore: gate automated email flows behind 503 responses

Disables user-facing access to invoice payment reminders and salary
payslip email sending. Underlying lib code (reminder-processor,
PDF templates, notification_settings) is preserved for easy re-enable.

- Invoice reminders cron route returns 503; settings UI section removed.
- Payslip send route returns 503; original implementation kept as
  _sendPayslipsImpl for future re-enable.
- Push notifications were already extension-disabled, no change needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: remove Recapt feedback widget

Strips the third-party Recapt SDK and its floating feedback bubble from
the app. The in-app contact form keeps working via the existing email
channel (/api/support/contact). Drops the Recapt entries from the CSP
and the subprocessor list in the privacy policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: reject meaningless rättelser in correctEntry

Guard against zero-economic-effect corrections in the storno engine:
- Reject when proposed lines net to zero on every account (e.g. 1930
  debit 100 / 1930 credit 100), which would erase the original posting
  without representing any affärshändelse (BFL 5 kap. 5 §).
- Reject when proposed lines are an exact multiset match of the original
  entry — a rättelse must actually change something.

New MeaninglessCorrectionError wired through bookkeepingErrorResponse
(HTTP 400) and the Swedish error translator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add date-range picker to resultat- and balansrapport

Adds optional from/to date filtering to the four operational financial
reports (resultatrapport, balansrapport, income-statement, balance-sheet)
so users can view a month, quarter, or custom range inside a fiscal year
without leaving the report. Defaults to YTD; "Hela året" preserves the
prior full-period behaviour (URL-identical, cache-stable).

- trial-balance engine accepts optional fromDate/toDate, rolling prior
  in-period activity into IB and clamping period activity to the window
- 12 API routes accept and validate from_date/to_date query params
- ReportDateRange chip picker persists preset per company, only renders
  on the four relevant tabs
- FiscalYearSelector now emits the period object so the range picker
  has bounds without an extra fetch
- PDF/XLSX filenames reflect the chosen range
- Resultatrapport drops the prior-year column when narrowed (full-year
  vs partial-year would mislead)
- 11 new tests (engine + parser); all existing report tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add support for marking journal entries as "no document required"

- Introduced a new sidecar table `journal_entry_no_doc_required` to track entries that do not require separate documentation (e.g., bank fees, interest).
- Implemented API routes for creating and deleting exemptions, including validation and authorization checks.
- Added a toggle component in the UI to allow users to mark entries as exempt, with an optional reason.
- Updated relevant tests to cover the new functionality, including RLS checks and cascading deletes.
- Enhanced existing schemas and types to accommodate the new `vat_amount` field for supplier invoice items.

* fix: address PR review findings on no-doc-required + VAT changes

- pg-real cascade test wraps DELETE in gnubok.allow_delete='true' txn so the
  immutability trigger bypass fires (mirrors delete_last_voucher RPC).
- Clamp supplier-invoice item vat_amount to <= line_total * vat_rate via Zod
  refinement (with 1-öre rounding tolerance) so the manual override can't
  inflate the 2641 debit beyond the statutory ceiling.
- groupVatByRate falls back to line_total * rate when stored vat_amount is 0
  with a positive rate, so legacy/import paths leaving the column at its
  NOT NULL DEFAULT 0 don't silently understate ruta 48.
- ReportDateRange todayIso() and preset endpoints use local date components
  instead of toISOString() (UTC) — fixes the midnight-to-02:00 off-by-one
  that truncated a day from YTD / this-month / this-quarter for Swedish
  users.
- NoDocRequiredToggle restores the previous reason on failed POST/DELETE so
  the rolled-back toggle state stays consistent with the rendered reason.
- Document the company-scoped (not user-scoped) DELETE authorization policy
  on the no-document-required route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:56:09 +02:00
Mattsson 8979f6eda3 Bug/year end failure (#575)
* feat: implement findNextPeriod function and integrate into year-end closing logic

* feat: add integrity check for PDF documents and enhance user feedback for corrupt files

* Refactor year-end service and period creation logic for improved UTC handling and error messaging

- Update `validateYearEndReadiness` to assert on stable warning messages without interpolating period names.
- Modify `createNextPeriod` to ensure date calculations are performed in UTC, preventing DST-related issues.
- Enhance error handling in `validateYearEndReadiness` and `executeYearEndClosing` to avoid exposing database details.
- Introduce structured error messages for year-end processes in `structured-errors.ts`.
- Add tests for document integrity checks, ensuring proper authentication and error handling.
- Implement GUC checks in document versioning to prevent unauthorized modifications and ensure company membership.
- Update migration scripts to reflect changes in document immutability enforcement.

* fix: add comment to clarify GUC behavior in document supersession logic
2026-05-27 14:19:30 +02:00
Mattsson a2a556d837 Bug/UI wrong display (#573)
* fix(dashboard): exclude credit notes from unpaid invoices widget

Credit notes (status='sent', negative total) were summed into the
"Att få betalt" widget, producing confusing negative totals like
"2 st, -38 625 kr". Filter them out via credited_invoice_id IS NULL,
matching the existing pattern in reminder-processor and the AR ledger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(documents): harden PDF preview and upload validation

- JournalEntryAttachments: switch inline PDF preview from <iframe> to
  <object type="application/pdf">. Mirrors the AttachmentPreviewSheet
  fix from #572 — Chrome's frame pipeline intermittently surfaced
  "Det här innehållet har blockerats" on iframes even with permissive
  CSP. <object> invokes the PDF plugin directly. crbug.com/271452.

- /api/documents/:id/inline: resolve Content-Type via file extension
  when mime_type is null or application/octet-stream. Legacy uploads
  landed with empty File.type from some drag sources; combined with
  the new X-Content-Type-Options: nosniff header on this route,
  Chrome refused to render valid PDFs. Extension fallback covers
  every legacy row without a DB backfill.

- /api/documents POST: surface DB-trigger period-lock errors as a
  400 DOC_UPLOAD_PERIOD_LOCKED with a Swedish reason. Previously
  every catch was bucketed into DOC_UPLOAD_STORAGE_FAILED (500 /
  "Filen kunde inte sparas") which hid the real cause from users
  attaching to verifikationer in closed/locked fiscal periods.

- document-service: add validateDocumentMagicBytes() that inspects
  the first bytes for valid PDF/PNG/JPEG/WebP headers (PDF tolerates
  a leading UTF-8 BOM). Wired into uploadDocument() and
  createNewVersion() so every upload path is protected — UI, MCP,
  and future email/webhook ingestion. Defends against agents that
  send a base64-encoded text placeholder instead of real binary
  bytes via the gnubok_upload_document MCP tool, which produced
  tiny (15-561 byte) "PDFs" that failed to render in Chrome and
  in external viewers.

Tests use a minimal valid PDF buffer (%PDF-1.4 … %%EOF).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(arsredovisning): emit ÅRL-required notes and FTE-weighted medelantal

Five compliance gaps fixed in the K2 and K3 noter builders:

- Anläggningstillgångar roll-forward per ÅRL 5:8 § — per-category IB
  anskaffningsvärde, tillkommande, avgående, UB and accumulated
  avskrivningar movement (was only emitting avskrivningstider).
- Långfristiga skulder förfallande efter mer än fem år per ÅRL 5:13 §.
- Ställda säkerheter and Eventualförpliktelser as separate notes per
  ÅRL 5:14-15 § (K2 previously combined them).
- Koncernförhållanden per BFNAR 2016:10 kap. 19 / BFNAR 2012:1 kap. 8.

Replaces medelantal anställda — the old query filtered employees by an
is_active column that doesn't exist, so the note never emitted. Now
uses an FTE-weighted day-based average per ÅRL 5:20 §.

Six disclosure fields persist on arsredovisning_narratives as per-period
overrides; the UI extends the existing förvaltningsberättelse editor
with a "Lagstadgade upplysningar" subsection sharing the same Spara
button — no new pages, no settings changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(invoices): respect vat_registered=false and hide personnummer for B2C

- PDF address block no longer prints org_number for individual customers
  (GDPR data minimization; ML 17 kap 24§ requires name + address only).
- Wire company_settings.vat_registered through the rule helpers, invoice
  creation API, preview-pdf API, and the new-invoice form so a non-VAT-
  registered seller cannot charge VAT (ML 1 kap. 1§). The PDF suppresses
  the empty "Moms 0%" row and shows a dedicated "Företaget är inte
  momsregistrerat" notice instead of the ML 3 kap. exempt notice.
- Engine unchanged: 'exempt' treatment already routes to 3004/3100 and
  skips VAT lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): remove approval rules from sidebar and routes

* fix(invoices): ensure vat_registered defaults to true for invoice previews and API

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:01:53 +02:00
Mattsson 32d9978f1b Fix/chrome pdf preview csp (#572)
* feat: add option to exclude year-end closing entries in SIE export and related reports

* delete docs

* fix: allow Chrome's PDF viewer in verifikat document preview

The /api/documents/:id/inline route shipped with
`object-src 'none'` in its CSP, which blocked Chrome's built-in PDF
viewer (it renders inline PDFs via an internal <embed>). Users on
Chrome saw "Det här innehållet har blockerats" when expanding a PDF
attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own
viewer) were unaffected, and JPGs worked because <img> isn't subject
to object-src.

Drops the CSP for this route to the minimum needed for embeddability:
`frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the
fixed Content-Type from the handler already block MIME confusion;
X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(auth): add webmail deep link to email confirmation screens

Mirrors Stripe's signup UX: after asking the user to verify their email,
detect their webmail provider from the domain and show a button that
opens the inbox in a new tab. Gmail gets a from:<sender> search
pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly.
Unknown / custom domains fall back to the existing copy.

Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM
(default noreply@gnubok.se) so white-label installs can match their
Supabase Auth SMTP config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): unblock first-time password set for BankID users with MFA

Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session
is required" whenever a TOTP factor is enrolled. BankID magic-link logins
produce AAL1, and middleware skips MFA enforcement for bankid_linked users,
so they had no path to AAL2 — leaving them unable to set a backup password
or disable MFA without going through the email-recovery escape hatch.

- /api/account/password: branch on app_metadata.has_password. First-time set
  writes via service.auth.admin.updateUserById (no existing credential to
  protect, AAL2 guard does not apply). Change-password keeps the user-session
  updateUser so AAL2 still fires for credential rotation.
- /mfa/verify: accept a safeReturnTo query param and route there after
  successful verify, so step-up flows can land back where they came from.
- SecuritySettings: detect the AAL2 error from both change-password and
  mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account
  instead of toasting a dead-end error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add tests and rounding utility for öre precision in bokslut calculations

- Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations.
- Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries.
- Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency.
- Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies.
- Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios.

* fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility

* fix: enhance security by rejecting data URIs in safeReturnTo function tests

* fix: improve rounding logic in roundOre function and add customer_type migration

* fix: add customer_type column to customers and enforce CHECK constraint

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:29:41 +02:00
Mattsson 50e5520b6d Add/preview invoice image (#565)
* feat: enhance invoice preview functionality with mock customer support

* feat: update invoice preview logic to handle mock customers and improve error handling
2026-05-24 16:59:36 +02:00
Jakob Wennberg 951bdb4e66 feat(bookkeeping): show per-account saldo on journal entry form (#562)
* feat(bookkeeping): show per-account saldo on journal entry form

Adds a "Saldo" column to the journal entry form so bookkeepers can see
the current balance of each account as of the entry date while drafting
a voucher. Useful context for booking bank withdrawals, VAT clearings,
and other balance-sensitive operations.

- New GET /api/bookkeeping/account-balances?accounts=...&as_of=...
  returns per-account net (debit - credit) over posted entries up to
  and including the requested date. Batched in chunks of 200 entry IDs
  to stay under PostgREST IN-list limits.
- JournalEntryForm fetches balances debounced 150ms on changes to the
  set of selected account numbers or the entry date; carries forward
  previously-known values so the cell doesn't flash to a skeleton on
  re-fetch.
- Saldo is reference-only: it reflects "balance before this entry" and
  intentionally ignores the draft lines the user is currently editing.
- Renders in both desktop (table column) and mobile (per-line caption)
  layouts. Tabular-nums, muted, right-aligned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): correct saldo semantics — IB + period-only, BS vs P&L

Address Swedish compliance review on PR #562:

1. P&L accounts (class 3-8) no longer show a since-inception cumulative
   sum. They reset each räkenskapsår per BFNAR 2013:2; the saldo now
   reflects current-period activity only, matching trial-balance
   semantics. BS accounts (class 1-2) continue to include IB.

2. Opening balances are now sourced via the canonical
   getOpeningBalances() helper, which reads the explicit
   opening_balance_entry_id set by year-end closing or SIE import.
   Previously, summing journal_entry_lines from inception returned 0
   for SIE-imported companies whose IB lives in a separate entry that
   the old query happened to include — and the wrong value once
   year-end ran and an OB entry was set without exclusion logic.

3. Relabel "Saldo" -> "Saldo (före)" / "Balance (before)" so the UI
   communicates that the figure excludes the draft being edited
   (BFNAR 2013:2 kap 8 self-documentation requirement).

4. Stop forwarding raw Supabase error.message to the client; log
   server-side via the structured logger and return a generic
   'Internal server error' to avoid leaking schema details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): reject future as_of dates on account-balances endpoint

Both compliance reviewers on PR #562 flagged this independently: a
future as_of date would include posted entries dated after today in
the activity window, producing a misleading "balance before this
entry" hint that could drive incorrect verifikat entries
(swedish-compliance-review-bot) or be used for future-date probing
(SOC 2 PI1.1, GDPR Art.25(2)).

- AccountBalancesQuerySchema.as_of now refines to <= today.
- JournalEntryForm collapses the loading skeleton to 0 on any non-OK
  response so the saldo column doesn't get stuck spinning when a
  user enters a future entry_date (which the form's separate period
  validation already handles).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): compare as_of guard against Europe/Stockholm date

swedish-compliance-review-bot caught this on the previous fix: the
future-date guard used new Date().toISOString().slice(0, 10), which is
UTC. Between 00:00–02:00 CET (or 00:00–03:00 CEST), a Swedish
bookkeeper's local "today" is one day ahead of UTC, so entering their
Stockholm-local date would be rejected as a future date.

Compare against Europe/Stockholm-local date via toLocaleDateString
('sv-SE'), which renders YYYY-MM-DD natively, so string comparison
remains correct across DST.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:21:31 +02:00
Mattsson 78c91e00e4 feat: add language preference for customers to support invoice locali… (#561)
* feat: add language preference for customers to support invoice localization

- Introduced language support for invoices, allowing customers to choose between Swedish and English.
- Updated invoice PDF generation to reflect the selected language for titles, labels, and messages.
- Enhanced email templates to generate content in the customer's preferred language.
- Added migration to include a language column in the customers table with a default value of Swedish.
- Updated tests to verify correct language usage in invoice emails and PDFs.

* fix: debounce API requests in InvoicePreviewCard and update F-skatt terminology in email templates
2026-05-22 15:21:05 +02:00
Mattsson 64bbeb4021 Fixed user issues (#559)
* Fixed user issues

* feat: add personal_number column to customers for individual identification

* feat: add personal_number field to makeCustomer function for enhanced customer identification

* feat: add personal_number column with constraint check for customer identification
2026-05-22 12:22:15 +02:00
Jakob Wennberg f8f49f8426 Inbox page-count gate + DataList/DropdownMenu primitives (#554)
* feat(inbox): skip AI extraction for multi-page PDFs (#553)

Bedrock churns for minutes on multi-page PDFs (sales reports, bank
statements, contracts) and returns nothing useful. Above 3 pages we now
skip extraction entirely and mark the row with extraction_skipped=true;
the document still lands in the inbox and can be attached or converted
manually. Same gate applies to the /items/:id/attach path. Client can
also opt out via skip_extraction=true (skip_reason=client_opt_out).

The InvoiceInboxWorkspace renders an "Inte AI-tolkad" badge for skipped
rows, distinct from the "Felaktig" failure state (status='error').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(ui): introduce DataList + DropdownMenu primitives, roll out across list pages

DataList replaces the per-row Card pattern across Granskning, Transactions,
Invoices, Supplier invoices, and Pending. One bordered container with
hairline rows matches the flat-with-hairlines aesthetic in CLAUDE.md —
no shadows, no state-tinted borders, secondary token for selected/hover.

DropdownMenu fills the gap for row-level action menus on TransactionInboxCard,
TransactionHistoryList, and the page-level action menus on /transactions and
/pending. Replaces ad-hoc Popover + buttons constructions.

Migrates list pages and the transaction inbox/history components onto the
new primitives. No behavior change beyond the visual unification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add agent skills + gnubok domain skills, gitignore compliance reports

.agents/skills/ + skills-lock.json + symlinks under .claude/skills/ check in
the vercel-labs/agent-skills set pinned by the local skill manager
(deploy-to-vercel, vercel-cli-with-tokens, react-best-practices,
composition-patterns, react-native-skills, react-view-transitions,
web-design-guidelines). Keeps the team on the same versions.

.claude/skills/industry/ + .claude/skills/modifier/ are hand-authored
vertical and entity-modifier skills for the specialized accountant agent
— industries (konsult-it, e-handel, bygg-hantverk, reklambyra, saas-ai)
and entity overlays (holding-ab, single-shareholder-ab-fmb, mixed-
verksamhet). Project-owned content; lives in the repo by design.

Also gitignores .compliance-reports/ — those are large generated
SARIF/dossier artifacts from the compliance scanner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(transactions): unify inbox/history chrome, drop swipe flow

The transactions page mixed two-tier filtering, a swipe-view detour, and
per-row Card chrome that didn't carry its weight. This pass collapses
those into a single editorial-list surface and removes the unused swipe
path entirely.

User-visible changes:
- Mode toggle (Att bokföra / Alla transaktioner) moved from a Tabs row
  under the header into a dropdown to the right of a unified search bar.
  Search now persists when switching modes.
- Removed the swipe categorization view ("Gå igenom alla") and its
  trigger button. The 800-line SwipeCategorizationView component is
  deleted; suggestion-fetching shrinks to what the template picker
  still consumes.
- Inbox rows now show one primary action: invoice/supplier-invoice
  match shortcut when auto-detected, else "Bokför". A new visible Link2
  icon button opens the customer or supplier invoice picker manually
  (chosen by amount sign). Delete becomes a plain trash button — no
  overflow menu since it only ever held one item.
- Bulk action bar swaps "Markera som privat" for "Ta bort" with a
  single combined confirmation.
- Built SupplierInvoicePicker mirroring InvoicePicker so expense
  transactions can be matched to supplier invoices from the inbox.
  Wired through /api/transactions/{id}/match-supplier-invoice.
- Template picker dialog renamed to "Bokför transaktion"; "Bokför
  manuellt…" and "Matcha med faktura…" promoted from muted ghost
  buttons at the bottom to outline buttons at the top, above the
  template list.
- Breathing room: row padding py-3 → py-4, primary text text-sm →
  text-base, amount text-base, button heights h-8 → h-9, trailing
  gap-2 → gap-3 (in the DataList primitive itself, so every list
  benefits slightly).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: align package-lock.json with merged package.json

The merge resolution took origin/main's package-lock.json (which dropped
pdf-lib) but kept our package.json (which still requires pdf-lib for the
invoice-inbox extension's PDFDocument import). `npm ci` rejected the
mismatch.

Regenerate the lock from the merged package.json so both files agree.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: regenerate package-lock.json with npm@10 for CI compat

Local npm@11 produced a lock that npm@10 (CI) rejected with
"Missing: @swc/helpers@0.5.21". Regenerated with npm@10
--package-lock-only so CI can install.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(transactions): supplier-invoice status-leak guard + drop dead prop

Two follow-ups from the merge-risk audit:

- SupplierInvoicePicker now mirrors InvoicePicker's status-leak guard:
  if a supplier invoice is still 'approved'/'overdue' but already has a
  payment voucher attached (journal_entry_id on supplier_invoice_payments),
  hide it. Closes a UX race window between payment and status flip.
  Partially-paid invoices still pass through.
- Drop the unused onMarkPrivate prop on TransactionInboxCard and the
  matching handleMarkPrivate wrapper in the parent. Both became dead
  when the swipe-categorisation flow was removed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:12:21 +02:00
Mattsson e4488a900b feat: add user locale preference to user_preferences table (#555)
* feat: add user locale preference to user_preferences table

- Introduced a new column 'locale' in the user_preferences table to store per-user UI language preferences.
- Added a CHECK constraint to ensure only supported locales ('sv', 'en') are allowed.
- Triggered a schema reload notification for the changes.

chore: declare CSS module support in TypeScript

- Added a declaration for CSS modules in globals.d.ts to enable TypeScript support for importing CSS files.

* feat: add Swish as an invoice payment method in company settings
2026-05-21 21:33:22 +02:00
Jakob Wennberg cc351158f8 Invoicing & account-security polish bundle (#550)
* feat: invoicing & account-security polish bundle

Five independent improvements bundled to ship together:

- BankID/password lockout fix: BankID-only users could enroll MFA and
  brick themselves (Supabase requires AAL2 to change password or unenroll
  MFA, and AAL2 needs a password sign-in). New app_metadata.has_password
  flag tracks this; middleware gates /mfa/enroll behind it, /account/set-
  password is the unlock path, SecuritySettings shows a banner, and
  /api/account/password is the single write path that flips the flag.
  Backfill script for existing users.

- Swish invoice payment method: company_settings.swish + invoice_show_swish
  columns, validation in lib/api/schemas.ts (accepts 123XXXXXXX företag or
  07XXXXXXXX mobile, strips whitespace/hyphens), rendered on invoice PDFs.

- Send-reminders kill switch: per-company company_settings.send_invoice_
  reminders toggle in PdfPrintSettings/Automatisering. Reminder processor
  also tightened: positive status allowlist (sent + overdue) so terminal
  statuses can never match; skip when customer already responded via
  reminder link; race-window re-check before send.

- First-invoice logo prompt: one-shot dialog when creating the first
  invoice without a logo (issue #520). Self-limits via head-only count.

- SIE export opening-balance fallback: route IB through getOpeningBalances
  so the compute_prior_opening_balances RPC supplies #IB after multi-year
  imports where opening_balance_entry_id is intentionally NULL. Previously
  #IB silently went to zero and #UB collapsed to current-period movements.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(account-polish): address PR review feedback

- BankID-link path (extensions/general/tic/index.ts): read-merge-write
  app_metadata instead of passing { bankid_linked: true } alone.
  updateUserById REPLACES app_metadata wholesale, so the previous code
  would have wiped has_password for any user who later linked BankID,
  causing the set-password banner to (incorrectly) reappear and blocking
  the standard MFA enrollment button. The comment is now corrected.

- Middleware (lib/supabase/middleware.ts): thread inner returnTo through
  the /mfa/enroll → /account/set-password redirect so the user lands on
  their original destination after the full chain completes, not on /.

- safeReturnTo helper (lib/auth/safe-return-to.ts): replace the
  starts-with-/-but-not-// guard on mfa/enroll and set-password pages.
  The previous guard let /\evil.com and /@evil.com through. The new
  helper parses against a synthetic base origin and verifies it matches.

- set-password page (app/(auth)/account/set-password/page.tsx): remove
  CLAUDE.md design system violations — bg-gradient-to-b on page bg,
  inline shadow-md style on the card, space-y-5, font-medium on the h1,
  rounded-xl on the card. Flat surface, hairline border, font-display
  h1 per the design tokens.

- Swish dedup (lib/payments/swish.ts): extract normaliseSwish() and
  isValidSwish() helpers and use them in lib/api/schemas.ts,
  components/settings/BankDetailsForm.tsx, and the invoicing settings
  page. Single source of truth for the regex.

- Password route (app/api/account/password/route.ts): emit a structured
  success log so the audit pipeline can detect password-set events, not
  just failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:44:09 +02:00
Jakob Wennberg 39204cc0de UX polish bundle: Enable Banking lookback + sync progress, invoice inbox, matching previews (#548)
* fix(import): dedup opening-balance rows when account numbers differ only in whitespace

The parser's merge map keyed on the post-strip account_number, but rows like
"1930", " 1930 " and "1.930" could leak as separate entries when the
upstream string contained non-breaking spaces or zero-width chars that the
old .replace(/[^0-9]/g, '') ran on already-stripped output. Strip those
explicitly in the raw string and use /\D/g for the digit extraction.

Also adds defense-in-depth dedup inside OpeningBalanceEditStep so any
duplicates that survive the parser collapse before the user sees them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(enable-banking): anchor lookback picker to fiscal year, not days

Replaces the 90/180/365 days dropdown on the account-selection screen
with three explicit modes:

- "Senaste 90 dagar (snabbt)" — fastest path, matches PSD2 ceiling
- "Sedan räkenskapsårets början" (default) — resolves via
  fiscal_year_start_month, surfaces the literal date inline
- "Anpassat datum" — free date picker OR "Föregående räkenskapsårets start"

When the resulting range exceeds 90 days, the picker now surfaces a
quiet helper that points users at the SIE/bankfil import for older
history, so they don't waste an account-selection round-trip discovering
that banks usually cap at ~90 days.

The PATCH /accounts handler accepts initial_lookback_from_date alongside
initial_lookback_days; the new helper getCurrentFiscalYearStart() in
lib/company/fiscal-year.ts is reused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(enable-banking): dedicated sync progress modal replaces silent spinner

After the user confirms account selection, transactions fetch in the
background for 30–60 seconds. Previously this showed only the Spara-button
spinner with no indication of duration or what was happening — users
described being stuck on the page.

The new BankSyncProgressDialog opens immediately on Save, lists the enabled
accounts being synced, and disables manual close until the PATCH resolves.
On completion it shows the imported count and the actual date range the
bank returned, plus an amber escape hatch to SIE/bankfil import when the
returned range was truncated by >7 days from what was requested.

Failure path surfaces in the same modal rather than as a destructive toast
that disappears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(invoice-inbox): drop duplicate Skapa leverantör button

The inbox detail panel had its own supplier-creation button that fired
/api/suppliers + match-supplier. The same action is reachable from the
supplier-invoice form's "Skapa & välj" card (showAISupplierHint), which
also prefills more fields. The duplicate button is gone; a quiet inline
hint replaces it so the user still knows why no supplier matched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(invoice-inbox): surface currency and totals above the long metadata tail

Move Valuta / Totalt / Moms in FIELD_DEFS so they sit immediately under
Leverantör / Org.nr / VAT-nr. These are the fields the user reads first
when triaging an inbox item; burying them after nine metadata fields
forces unnecessary scrolling on every single invoice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(invoice-inbox): accept .eml forwards and log rejected attachments

Gmail's "Forward as attachment" packages the original email as message/rfc822,
which our MIME allowlist silently dropped. Adds mailparser so we can unwrap
the inner attachments and ingest them under the inner email's subject/from.

Also persists every rejected attachment as an invoice_inbox_items row with
status='error', so users can see what was dropped instead of guessing why
nothing showed up in their inbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): redirect back to inbox after creating from invoice-inbox

When the leverantörsfaktura form is opened from an invoice-inbox item, every
successful create previously kicked the user out to /supplier-invoices or the
just-created invoice's detail page — derailing the "process the next
document" workflow. The Tillbaka button likewise routed to the
supplier-invoice list rather than the inbox they came from.

Adds an afterCreate helper that lands inbox-originated submissions at
/e/general/invoice-inbox and preserves the original target everywhere else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(pending): show transaction/document context for match-and-attach reviews

The granskning page previously rendered attach_document_to_transaction and
match_transaction_invoice operations through the generic key/value preview,
so reviewers saw "document file name: Faktura.pdf / transaction amount:
-216 USD" without any visual indication of which two things were being
paired. The MCP tool already returns enriched preview data; we just
needed dedicated layouts.

Adds:
- AttachDocumentPreview — two-card layout (Transaktion | Dokument) with
  a "Visa dokument" button that fetches a signed download URL on demand
- MatchTransactionInvoicePreview — same layout (Transaktion | Faktura)
- DocumentViewButton — reusable signed-URL opener

Also tightens the matching tools' descriptions so AI clients are nudged
to verify human-readable context before staging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address PR #548 review feedback

- invoice-inbox: hoist mailparser to a static import. The extension system
  generates a static import tree via setup:extensions and disallows dynamic
  imports — await import('mailparser') worked in dev but could fail in
  production standalone builds.
- enable-banking AccountPickerDialog: guard the Save path when "Anpassat
  datum" + "Specifikt datum" is selected with an empty date. Without this,
  lookback.body resolves to null and the PATCH silently falls back to the
  backend's 120-day default, ignoring the user's intent.
- enable-banking BankSyncProgressDialog: drop the empty-body useEffect.
  Close-prevention is already handled inline via the onOpenChange guard +
  onPointerDownOutside + onEscapeKeyDown handlers.
- lib/company/fiscal-year: pin both operands of daysBetween() to UTC when
  parsing ISO date strings. Mixing a UTC-parsed date with new Date() (local
  time) drifts by one day in any timezone east of UTC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address compliance swarm + Swedish review feedback

Three actionable items from the post-fix compliance scan; the rest were
false positives or out of scope.

- enable-banking PATCH /accounts: reject future initial_lookback_from_date
  with 400 instead of silently falling through to the 120-day default.
  Compliance V2.2.
- AttachDocumentPreview: promote the overwrite warning to a destructive
  banner with BFL 7 kap context when the existing document is marked as
  räkenskapsinformation. A muted footnote was too easy to skip past for
  a verifikationsunderlag replacement.
- MatchTransactionInvoicePreview: surface transaction_date + invoice_date
  in the staged preview so reviewers can spot date drift before approving
  (BFL 5 kap 6§ — verifikation date must align with affärshändelse). Also
  shows a quiet hint when the two dates differ by > 31 days. Tool's SELECT
  + stage payload extended accordingly.

Skipped (with rationale):
- V5.3 inner.filename path traversal — lib/core/documents/document-service.ts
  already sanitizes filenames before constructing storage paths.
- V5.2 magic-number MIME — pre-existing pattern for all email attachments;
  scope is codebase-wide.
- V1.2 att.id composite ID — only used as a DB column value, never a path.
- V13.1 / CM-8 SBOM/SCA — repository-wide policy, not this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address second-round compliance + Swedish review feedback

Compliance Swarm (defense-in-depth + valid finds):
- invoice-inbox: sanitise .eml inner attachment filenames and content-types
  before they flow into uploadAndExtract or the raw_email_payload JSONB.
  document-service already strips bad chars before constructing storage
  paths, but the swarm flagged the upstream input as unsanitised — easier
  to add a thin sanitiseFilename/sanitiseMime layer than to argue about
  defense-in-depth. Caps lengths too.
- DocumentViewButton: validate documentId as a UUID before interpolating
  into /api/documents/:id — staged preview_data is Record<string, unknown>
  on the wire, so refusing junk early gives a clearer error and keeps the
  internal API from seeing oddly-shaped path segments. (Compliance V1.2.)

Swedish review:
- MatchTransactionInvoicePreview: drop the BFL 5 kap 6§ citation from the
  date-drift hint — that section governs verifikationsinnehåll, not a 31-day
  tolerance. The hint stays (the practical concern is real) but no longer
  pretends to quote a legislated threshold.
- fiscal-year: document the implicit assumption that entity_type reflects
  the company's current tax-year status, not a mid-conversion state.

Skipped (with rationale):
- V5.2 magic-number MIME — pre-existing pattern across all email attachments.
- A.8.12 signed URL via window.open — pre-existing pattern shared with
  JournalEntryAttachments.tsx; refactor to server-side redirect is broader scope.
- A.8.15 logRejection failure path — pre-existing console.error pattern.
- CC9.2 mailparser vendor review / SBOM — out of PR scope.
- CC6.1 IDOR — /api/documents/:id already enforces company_id; false positive.
- Swedish #1 räkenskapsinformation flag origin — server-side already derives
  the flag from document_attachments.journal_entry_id in the staging tool;
  not caller-trusted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): fail-safe BFL warning + preserve merge validation errors

Two findings from the third compliance pass; both worth addressing.

- AttachDocumentPreview: treat an absent
  existing_document_is_rakenskapsinformation flag as räkenskapsinformation
  rather than as "safe to overwrite". The MCP staging tool sets the flag
  deterministically from document_attachments.journal_entry_id today, but
  a future code path that forgets it would silently downgrade the BFL 7
  kap warning. Only an explicit `=== false` from the server keeps the
  muted note path.
- Opening-balance merge: union validation_errors when collapsing duplicate
  account_number rows, both in the parser and the EditStep useState
  initializer. Previously a warning that fired on row 5 (e.g. BAS-class
  mismatch) was silently dropped if row 2 of the same account had no error,
  risking misclassified IB data downstream. Added a parser test covering
  the union behaviour for two rows of a class-3 (resultatkonto) account.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:10:12 +02:00
Mattsson e71b4a9138 Add/mcp and visma (#547)
* fix: simplify COMING_SOON_PROVIDERS to include only bjornlunden and briox

* feat: add supplier creation functionality and related operations

* feat: reorder and enhance OAuth scopes in Visma integration

* feat: implement create supplier functionality with validation and risk tier management
2026-05-21 01:25:18 +02:00
Mattsson 239261a0be Bug/mcp bas lag (#543)
* fix(mcp): update workflow descriptions for transaction categorization and approval processes

* feat: implement account validation for transaction categorization to handle inactive accounts

* fix(tests): stub findMissingAccountsMock to ensure no missing accounts during batch-categorize tests

* fix(errors): ensure deterministic sorting of account numbers in AccountsNotInChartError
2026-05-20 12:11:27 +02:00
Mattsson 566ed72984 Bug/mcp connection issue (#541)
* feat(api): implement caching and logging in health check endpoint

- Added in-memory caching for health check responses to reduce load on Postgres.
- Introduced logging for error handling in health check.
- Updated response structure to exclude error details from public responses.

feat(api): enhance OAuth consent UI and scope handling

- Improved consent UI to reflect exact requested scopes and added better user guidance.
- Updated scope handling logic to ensure least-privilege access.
- Enhanced styling for better user experience and accessibility.

chore(docker): improve security and resource management in Docker setup

- Updated Docker Compose configuration to enforce read-only file systems and resource limits.
- Added health checks and logging options for better observability.
- Introduced optional Caddy reverse proxy for TLS termination.

fix(migrations): resolve ambiguity in create_company_with_owner function

- Dropped orphaned 3-arg overload of create_company_with_owner function.
- Recreated canonical 4-arg version with cash account seeding logic.
- Ensured proper permissions for function execution in Postgres.

* feat: enhance security checks for team membership in company creation

* test: add CSP tests for OAuth authorization endpoint

* feat: enhance error handling and reporting in bank file import process
2026-05-20 10:08:32 +02:00
Mattsson 8a6ce7093e feat: implement skattekonto drift detection and alerting (#525)
* feat: implement skattekonto drift detection and alerting

- Add skattekonto drift computation logic to compare Skatteverket's saldo with GL 1630 sum.
- Implement alerting mechanism for significant drift changes, with throttling to prevent alert spamming.
- Introduce database functions to sum GL 1630 entries and list unbooked skattekonto rows.

feat: create own account transfer detection

- Develop logic to detect transfers between a company's own cash accounts based on counterparty IBAN.
- Implement tests to validate detection logic under various scenarios, including matching and non-matching IBANs.

feat: establish cash accounts as a first-class entity

- Create cash_accounts table to manage routable cash accounts, replacing ad-hoc JSONB structures.
- Implement functions for listing, upserting, and managing cash accounts, including primary account designation.

feat: enhance GL line reconciliation functionality

- Modify get_unlinked_1930_lines RPC to accept any account number for reconciliation, improving flexibility for different currencies.
- Update related functions to ensure compatibility with the new cash_accounts structure.

feat: capture counterparty IBAN in transactions

- Add counterparty_iban column to transactions table to facilitate intra-account transfer detection.
- Create index for efficient lookups based on counterparty IBAN.

* feat: Enhance cash account handling and reconciliation processes

- Updated reconciliation routes to enforce cash account validation for all account numbers, including '1930'.
- Improved error handling for unknown cash accounts in reconciliation status and unmatched entries routes.
- Changed CashAccountSelector to use sessionStorage instead of localStorage for better data privacy.
- Fixed mapping for employer payroll taxes to route to the correct account (2730 instead of 2731).
- Added safety checks for company IDs in the guessCounterAccount function to prevent injection vulnerabilities.
- Introduced atomic RPC for setting primary cash accounts to avoid intermediate states during updates.
- Seeded default cash accounts for new companies to ensure reconciliation routes are accessible from day one.
- Updated email notifications for drift detection to avoid exposing sensitive financial data.
- Enhanced bank reconciliation logic to handle multi-currency transactions correctly.
- Renamed and updated tests to reflect changes in the underlying RPCs and ensure accurate coverage.
- Migrated existing cash account rules to correct mappings in compliance with Swedish accounting standards.
2026-05-19 16:10:18 +02:00
Mattsson e211ab31be UI/settings api mcp (#524)
* feat(voucher): add create voucher and correct entry previews; update commit methods

* feat: add support for pending operations in API key scopes and OAuth client management

- Introduced new API key scopes for reading and approving pending operations.
- Updated the scope groups to include pending operations.
- Added new tools for listing and managing pending operations.
- Implemented OAuth client registration and revocation endpoints.
- Created a UI panel for managing OAuth clients, including registration and revocation.
- Added tests for pending operations tools and OAuth allowlist functionality.
- Implemented a database migration for OAuth client registrations with appropriate policies and constraints.

* feat: Implement OAuth client registration rate limiting and enhance security measures

- Added IP-based rate limiting to the OAuth client registration endpoint to prevent enumeration attacks.
- Introduced a service-role client for allowlist lookups, ensuring trust boundaries are maintained.
- Updated error responses to be uniform across different types of redirect URI validation failures.
- Enhanced tests to reflect changes in OAuth scope handling, ensuring fallback to read-only scopes when no scopes are provided.
- Improved handling of high-risk pending operations, requiring explicit confirmation for approvals.
- Added audit logging for OAuth client revocations and pending operation approvals/rejections to maintain a security audit trail.
- Refactored API key scope management to include default read-only scopes for OAuth-issued keys and added segregation-of-duties checks.

* feat: add recurring invoice scheduling functionality

- Implemented recurring invoice schedules with a new database schema.
- Created API routes for managing recurring invoices (GET and POST).
- Added cron job to automatically generate invoices based on schedules.
- Developed service functions for computing next run dates and executing schedules.
- Added tests for the new functionality, including validation and success cases.
- Introduced error handling for various scenarios in the invoice creation process.

* feat: refine VAT rate validation and enhance recurring invoice handling
2026-05-19 13:48:32 +02:00
Mattsson 16164ea14c Fix/mcp fixes and bugs (#518)
* feat(voucher): add create voucher and correct entry previews; update commit methods

* feat: add support for pending operations in API key scopes and OAuth client management

- Introduced new API key scopes for reading and approving pending operations.
- Updated the scope groups to include pending operations.
- Added new tools for listing and managing pending operations.
- Implemented OAuth client registration and revocation endpoints.
- Created a UI panel for managing OAuth clients, including registration and revocation.
- Added tests for pending operations tools and OAuth allowlist functionality.
- Implemented a database migration for OAuth client registrations with appropriate policies and constraints.

* feat: Implement OAuth client registration rate limiting and enhance security measures

- Added IP-based rate limiting to the OAuth client registration endpoint to prevent enumeration attacks.
- Introduced a service-role client for allowlist lookups, ensuring trust boundaries are maintained.
- Updated error responses to be uniform across different types of redirect URI validation failures.
- Enhanced tests to reflect changes in OAuth scope handling, ensuring fallback to read-only scopes when no scopes are provided.
- Improved handling of high-risk pending operations, requiring explicit confirmation for approvals.
- Added audit logging for OAuth client revocations and pending operation approvals/rejections to maintain a security audit trail.
- Refactored API key scope management to include default read-only scopes for OAuth-issued keys and added segregation-of-duties checks.
2026-05-18 19:02:42 +02:00
Mattsson d27c3dd3dc Bug/customer cron job (#516)
* fix(reminder-processor): filter out credited invoices in overdue reminders

* feat: implement linking of transactions to journal entries

- Added POST endpoint for linking a bank transaction to an existing journal entry without creating new bookkeeping.
- Implemented validation for required fields and error handling for various scenarios (e.g., missing journal_entry_id, transaction already linked, journal entry not found).
- Created tests for the new endpoint to cover various cases including successful linking, error responses, and invoice handling.
- Introduced duplicate payment detection logic to prevent double-booking of bank receipts.
- Added a new component for correction affordance in the UI to facilitate user corrections on journal entries.

* feat(invoice-matching): enhance force matching with expected journal entry validation
2026-05-18 13:17:15 +02:00
Jakob Wennberg 831920fede fix(arcim): allow Fortnox re-sync to replace prior SIE import per fiscal year (#512)
* fix(arcim): allow Fortnox re-sync to replace prior SIE import per fiscal year

A user reported a sync failure when retrying Fortnox after adding more
verifications:

  Import failed: Failed to create pending import record:
  duplicate key value violates unique constraint
  "sie_imports_company_id_file_hash_active_idx"

Root cause: Fortnox embeds the export-time #GEN date in every SIE export,
so the file hash always differs between syncs. The wizard's hash-based
duplicate detection treated each sync as a brand new file, but the engine
still rejected the insert because the per-period import slot was held by
the prior 'completed' row.

This change reframes Fortnox re-sync as a replace operation rather than a
fresh import:

- New executeSIEImport option `onExistingPeriod: 'block' | 'replace'`.
  Manual SIE upload at /api/import/sie keeps default 'block' (current
  behavior, no regression). The Fortnox /import-sie endpoint passes
  'replace', which runs replaceSIEImport on any overlapping completed
  import before insert. Imported journal entries from the prior import are
  cancelled per BFL 5 kap 5§; user-created entries (manual, transaction,
  invoice) are untouched.

- /sie-data switches from hash-based to period-based duplicate detection
  and returns previousImport metadata per fiscal year.

- Wizard drops the alreadyImported skip filter, surfaces an amber callout
  in the confirm dialog listing fiscal years that will be replaced, and
  shows "ersatte N tidigare importerade verifikationer" per year.

- createPendingImportRecord translates 23505 partial-index violations to
  a clear Swedish recovery message instead of leaking the raw constraint
  name.

- cleanupStaleImportRecords drops the 1-hour age gate and also cleans
  status='mapped' orphans. SIE imports are single-flight per company so
  the gate just made legitimate retries fail.

- After replace, the fiscal_periods row's opening_balances_set and
  opening_balance_entry_id are cleared (only when they pointed at the
  cancelled prior IB entry), so the new IB import isn't skipped.

Schema-drift migration captures the partial unique index
sie_imports_company_id_file_hash_active_idx that already exists in
production (added out-of-band) and drops the now-superseded plain
sie_imports_company_id_file_hash_key constraint. Both statements are
idempotent — verified no-op against production.

Tests: new pg-real test covers the partial index admit-replaced
semantics, source_type='import'-only cancellation in replace_sie_import,
and the post-replace insert path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(arcim): address PR review — restore 5-min cleanup gate, fix test source_type

Greptile P2: `cleanupStaleImportRecords` was deleting `pending` rows
unconditionally, which could wipe a concurrent in-flight import in
another tab/session. Restored a 5-minute age gate (long enough for any
normal interactive import, short enough that legitimate retries after
a crash still succeed). Also dropped `mapped` from the cleanup — it is
defined in SIEImportStatus but no code path writes it, so including it
was both unnecessary and added the concurrent-session risk Greptile
flagged.

pg-real test: insertPostedEntry used `source_type='transaction'` which
is not a valid value per the journal_entries_source_type_check
constraint (migration 20260516060000). Switched to `'bank_transaction'`
— the actual source_type emitted when a user categorizes a bank
transaction in gnubok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:25:43 +02:00
Jakob Wennberg fd4274787c feat(bokslut): Phases 4-7 — accruals + årsredovisning + EF + MCP tools (#509)
* feat(bokslut): Phases 4-7 — accruals + årsredovisning + EF + MCP tools

Builds on the Phase 1-3 PR (#508). Same K2-AB scope decisions hold (iXBRL
deferred, K3 deferred, koncernredovisning out of scope) — what ships:

PHASE 4 — Periodiseringar
- lib/bokslut/accruals/accrual-detector.ts: auto-proposes the vacation-
  liability change (delta on 2920 against 7090 + 31,42 % avgifter on
  7519/2940) by reading lib/reports/vacation-liability.ts. Manual prepaid
  (17xx) / accrued (29xx) / audit-fee builders for entries the heuristic
  can't derive — supplier-invoice service_period detection is deferred
  until the data model grows the field.
- /api/bookkeeping/fiscal-periods/[id]/accruals (GET + POST) — posts each
  accrual as a separate manual-source journal entry with the next-day
  reverse date embedded in the description.
- New AccrualsStep wizard step between Preflight and Dispositions.
- Auto-reversal cron is follow-up infra; for now reverses_on is metadata
  + a visible UI badge.

PHASE 5 — Årsredovisning PDF + signing
- lib/bokslut/arsredovisning/{types,build-data,arsredovisning-pdf,signature
  -service}: pre-fills flerårsöversikt from prior 3 fiscal periods, eget-
  kapital-förändring from journal data, K2-minimum noter with
  avskrivningstider auto-derived from the asset register and medelantal
  anställda from the employees table. PDF via @react-pdf/renderer mirroring
  the income-statement/pdf pattern.
- Migration 20260516170000: arsredovisning_signature_requests with RLS,
  signed-immutability trigger, and DELETE policy that blocks signed rows.
  Signature-service exposes list/create/markSigned/isFullySignedOff.
- BankID call itself is not wired here — the table + service make the
  request layer available so a follow-up can hook lib/auth/bankid.ts to the
  sign action without rework.
- /api/.../arsredovisning (data + pdf + signatures) endpoints.
- /bookkeeping/year-end/arsredovisning page with editable narrative,
  flerårsöversikt table, signer slots, PDF download, and Bolagsverket
  Mina Sidor link. Explicit warning about the FY2026 iXBRL mandate.

PHASE 6 — Enskild firma NE-bilaga UI
- lib/bokslut/enskild-firma/{egenavgifter,rantefordelning,periodiseringsfond
  -ef,expansionsfond}-calculator.ts. All declaration-only — never produce
  a journal entry. Egenavgifter 28,97 % / pensionärssats 10,21 % / passive
  SLP 24,26 %, schablonavdrag 25/10/20 %. Räntefördelning SLR+6 / SLR+1
  with the -500 000 negative threshold. P-fond EF cap 30 % (vs 25 % för
  AB). Expansionsfond 125,94 % av kapitalunderlag, 20,6 % skatt.
- EfDeclarationSection mounted inside DispositionsStep when entity_type is
  enskild_firma — live recompute as the user adjusts kapitalunderlag,
  prior-year amounts, p-fond desired, expansionsfond change. Each card
  shows the NE-bilaga ruta the number lands in. NE-bilaga preview link.

PHASE 7 — Agent-native MCP tools
- gnubok_propose_dispositioner: read-only AB dispositions proposal
- gnubok_propose_accruals: read-only accruals proposal
- gnubok_propose_annual_depreciation: read-only depreciation per asset
- gnubok_post_annual_depreciation: stages depreciation commit (high-risk)
- gnubok_preview_arsredovisning: structured K2 ÅR preview
- gnubok_preview_ef_declaration: EF skattemässiga justeringar preview
- All ≤280-char descriptions, additionalProperties:false, conform to
  STAGED_OPERATION_SCHEMA for write tools. Read tools return the same
  shapes as their HTTP counterparts so agents and the UI share a contract.
- The existing GET /bokslutsdispositioner endpoint now calls the shared
  buildDispositionsProposal helper that the MCP tool also uses, removing
  the duplicate logic that lived in both.

Verification
- 149 unit tests pass (was 125 on Phase 1-3; +24 across accruals, EF
  calculators, expansionsfond, periodiseringsfond-ef)
- Zero lint or typecheck errors on any new file
- Migration 20260516170000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address PR #509 round-1 — CI blocker + 3 P1s + compliance bundle

CI BLOCKER (output-schema test)
- The 5 new read-only MCP tools (gnubok_propose_dispositioner / _accruals /
  _annual_depreciation, _preview_arsredovisning / _ef_declaration) were
  missing outputSchema, which the existing strict-schemas guard reads as a
  hard failure on core-only CI. Added a permissive { type: 'object',
  additionalProperties: true } outputSchema to each — the return shapes are
  the same as their HTTP counterparts and trying to mirror them inline
  would duplicate the type tree across two boundaries.

P1 — vacation accrual: two real bugs in one entry
- Delta was anchored on the OPENING balance of 2920, so any mid-year
  postings (partial accruals, reversals) were ignored. Now anchors on the
  current closing balance via tb.rows.find(2920).closing_credit -
  closing_debit. Updated the computation field label to current_2920.
- More importantly: 2920 is a balance-sheet carry-forward (semesterlöneskuld
  persists until the actual vacation is paid). The original implementation
  set reverses_on to Jan 1 of the next year, which would zero the liability
  on day 1 of the new year — a known Swedish bookkeeping error. The
  vacation proposal now ships with reverses_on = '' to suppress the
  reversal badge, the API route emits a "Bokslutsjustering" description
  instead of "Periodisering (vänds …)", and AccrualsStep renders "Rullas
  vidare (ingen vändning)" so the user knows the liability carries forward.

P1 — signature_requests immutability gap (security)
- The existing trigger only guarded role / signer_name / signed_at /
  status on signed rows, leaving bankid_signature_data,
  signer_personnummer_encrypted, and signer_personnummer_hash mutable. An
  UPDATE on a signed row could silently alter the BankID proof. New
  migration 20260517090000 replaces the trigger function to cover the full
  audit-critical column set (plus fiscal_period_id and company_id as
  belt-and-braces).

P1 — narrative edits never reached the PDF
- The /bookkeeping/year-end/arsredovisning page let the user edit
  description / important_events / resultatdisposition but the download
  link pointed at a plain GET that regenerated boilerplate. Wired the PDF
  endpoint to accept description / events / disposition as query params
  (length-capped) and the page now constructs the download URL with the
  current narrative state — only fields the user actually changed are
  included, keeping the URL short for the unchanged-defaults case.

Compliance quick wins
- Added period_lock check to gnubok_post_annual_depreciation MCP tool
  (matches the existing accruals POST guard).
- Added explicit fiscal-period ownership pre-check to the signatures POST
  route (RLS would reject anyway; the route layer just makes the 404
  envelope cleaner).
- Replaced free-text role on the signatures schema with an enum allowing
  only Styrelseledamot / Styrelseordförande / VD / Verkställande direktör.
- Added Cache-Control: no-store + Pragma: no-cache to the ÅR PDF response
  so the document (officer names + financials = personal data) isn't
  cached by any intermediary.
- Sanitized period_end in the PDF Content-Disposition header to dodge
  header-injection via stray chars (defensive — period_end is a date, but
  the cost is one regex).
- Softened the iXBRL warning text on the ÅR page: digital filing is
  proposed by Bolagsverket but not yet enacted; PDF is still valid today.

False positives I'm intentionally not chasing on this round
- Greptile P2 `sate` typo in the address city field — the rename would
  touch the type and every consumer; defer.
- Greptile P2 "computation field label" — already addressed as part of
  the P1 vacation fix above.
- Compliance V2.2 "silent skip" in accruals POST — the silent skip is in
  the UI's empty-row filter, not in the server route. Server validation
  already returns 400 via Zod for any invalid item.

Verification
- 154 unit tests pass (was 149; +5 from re-running the MCP strict-schemas
  + output-schema suites that now include the new tools)
- Zero typecheck errors on any touched file
- Zero lint errors on any touched file
- Migration 20260517090000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address PR #509 round-2 — 3 real bugs + 5-item polish

3 real bugs from the round-1 Swedish review + Compliance Swarm re-eval:

- Soliditet inflated by obeskattade reserver. build-data.ts filtered equity
  with startsWith('20') || startsWith('21') — but 21xx (periodiseringsfonder,
  överavskrivningar) are partially deferred tax, not eget kapital. Splitting
  them out per K2 / ÅRL. Filter is now startsWith('20') only.

- Resultaträkning omitted bokslutsdispositioner + skatt rows. K2 RR must
  include 88xx (dispositioner) and 89xx (skatt) before "Årets resultat" per
  ÅRL 3:2 — without them, the printed RR doesn't reconcile to BS 2099 and
  the document is non-compliant for any AB that posted bolagsskatt or
  periodiseringsfond. flattenIncomeStatement now splits the financial
  sections on title (Bokslutsdispositioner / Skatter och årets resultat)
  and emits the K2-required intermediate subtotals: "Resultat efter
  finansiella poster" → dispositioner → "Resultat före skatt" → skatt →
  "Årets resultat".

- Accruals POST had no idempotency. Re-running the wizard (or a retried
  POST after a flaky network) would create duplicate accrual entries that
  distort both the balance sheet and trial balance. New
  findExistingAccrualEntry helper queries the period for an existing
  posted entry whose description matches the kind's stable prefix (or for
  manual prepaid/accrued, the user-supplied description). Duplicates land
  in a new `skipped` array in the response with a reference to the
  existing entry id, rather than producing a second posting.

5-item compliance polish:

- signer_name on the signatures schema now has .max(200) per GDPR Art.25.2
  data-minimization. Swedish personal names are well under that — the
  bound is a defense against an unbounded-string injection.

- Audit-fee accrual: 6420 is BAS-specific to lagstadgad revision. Bokslut
  fees for a non-revisionspliktigt bolag (liability_account = 2991) now
  debit 6590 (övriga externa tjänster) instead — Skatteverket may query a
  6420 debit when there is no revisor i bolaget.

- Räntefördelning ne_ruta label: was 'R30 / INK1 kapital' (confusing —
  INK1 is a separate form). Positive now reads 'R30 (avdrag i
  näringsverksamhet)' with the INK1 T4 cross-reference moved to the
  description; negative reads 'R30 (tillägg till resultat)'.

- gnubok_post_annual_depreciation MCP tool now checks the caller's
  company_members role and throws on viewer. RLS would reject the
  underlying INSERT anyway; failing fast here produces a cleaner error
  than the cascaded RLS rejection. Mirrors the HTTP route's
  { requireWrite: true } guard.

- Signature DELETE policy now blocks both 'signed' AND 'declined' rows. A
  declined signature is auditable evidence (board member refused) and is
  material under ABL 8 kap. New migration 20260517100000.

Bot-flagged items I'm deliberately not chasing on this round:
- V8.2.1 × 2 cross-tenant findings on ÅR routes — same false-positive
  class I've responded to repeatedly: buildArsredovisningData internally
  filters by company_id, bot can't see past the route handler.
- V4.5 / V2.2 MCP arg Zod redundancy — MCP server's central handler
  validates against each tool's inputSchema.
- Narrative-in-URL GDPR concerns — proper fix is POST + body or
  server-side persistence; tracked as follow-up.
- `sate` typo (Greptile P2) — type-wide rename, deferred.
- Vacation avgifter age-tier rate split — inherited from upstream
  generateVacationLiability which doesn't expose age; needs upstream
  work.
- Medelantal anställda proper monthly average — needs salary-run
  aggregation across the year, follow-up.
- K2 noter aktiekapital + fastställelseintyg blocks — real K2 gaps,
  tracked as follow-up.

Verification
- 94 tests pass (subset for bokslut + MCP suites; full Phase 1-7 suite
  unchanged in scope)
- Zero typecheck errors on any touched file
- Zero new lint errors on any touched file (the 2 server.ts warnings are
  pre-existing)
- Migration 20260517100000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:50:48 +02:00
Jakob Wennberg 05078c9d8e feat(bokslut): year-end wizard with bokslutsdispositioner + asset register (#508)
* feat(bokslut): year-end wizard with bokslutsdispositioner + asset register

Ships the first user-visible bokslut surface for K2 aktiebolag. The year-end
engine, INK2/INK2R/INK2S generator, and reconciliation reports already existed
in lib/core/bookkeeping/ and lib/reports/; this work wires them into a real
multi-step UI, adds the missing dispositioner calculators (bolagsskatt,
periodiseringsfond, överavskrivningar, SLP), and introduces a fixed-asset
register that feeds planenliga avskrivningar into the same flow.

PHASE 1 — Wizard around the existing year-end engine
- Replaces the "Kommer snart" stub at /bookkeeping/year-end with a 4-step
  wizard (Kontroll → Dispositioner → Förhandsgranska → Verkställ) plus a
  Klart result view
- New aggregator lib/bokslut/readiness-aggregator.ts composes
  validateYearEndReadiness with bank-reconciliation status and entity-typed
  reminders into one fetch backing the preflight step
- New endpoint GET /api/bookkeeping/fiscal-periods/[id]/bokslut-readiness

PHASE 2 — Bokslutsdispositioner calculators
- lib/bokslut/tax-provision/{bolagsskatt,sarskild-loneskatt}-calculator.ts —
  20.6 % on taxable result → 8910/2512 (with non-deductible / non-taxable
  manual adjustments and schablonintäkt pass-through) and 24.26 % SLP on
  posted pension costs → 7533/2514
- lib/bokslut/reserves/periodiseringsfond-service.ts — proposeAvsattning
  (25 % cap, BAS 212X cohort accounts) + proposeAteforing (FIFO 6-year
  mandatory reversal with schablonintäkt computation) + balance lookup
- lib/bokslut/reserves/overavskrivningar-service.ts — 30-rule + 20-rule
  helpers + proposeOveravskrivningar (8853/2153)
- New endpoint /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner
  (GET ordered proposals, POST commits user-chosen ones as separate
  year_end vouchers via the journal engine)
- New DispositionsStep UI: per-card accept/skip + editable amount where
  meaningful; mandatory p-fond reversals can't be skipped
- INK2 bug fix: ink2-engine.ts SRU mapping ranges previously pointed at
  accounts BAS doesn't seed (8810/8830/8840). Corrected to 8811 (avsättning),
  8819 (återföring), 8830 (lämnade koncernbidrag) so calculator output now
  flows into INK2 correctly. Regression-locked with 6 new mapping tests.

PHASE 3 — Anläggningsregister + depreciation engine
- New migration 20260516120000_assets_and_depreciation.sql: assets table
  (category, BAS-triple, K3 components JSONB reserved) and
  depreciation_schedules (asset+period+journal_entry link). RLS via
  user_company_ids(), immutability triggers after disposal/posting.
- lib/bokslut/assets/asset-service.ts — CRUD + disposal that posts a proper
  gain/loss entry against 3973/7973
- lib/bokslut/assets/depreciation-engine.ts — computeAnnualDepreciation
  (linear, pro-rata at acquisition/disposal/end-of-life) +
  proposeAnnualPostings + commitAnnualPostings (one entry per asset)
- New endpoints /api/assets (CRUD + dispose) and
  /api/bookkeeping/fiscal-periods/[id]/depreciation (preview + commit)
- /assets list+create page with K2 schablon defaults (3y datorer,
  5y inventarier, 25y byggnader); sidebar entry added
- DepreciationPanel mounted at the top of DispositionsStep; posting
  refreshes dispositions so bolagsskatt picks up the new result

Out of scope (per the agreed plan): K3 framework, iXBRL filing to
Bolagsverket (manual export only for now — regulatory risk flagged for
FY2026 closings), inventory module, koncernredovisning, revisor workflow.

Verification
- 116 unit tests pass across lib/bokslut/, lib/reports/ink2/, and the
  existing lib/core/bookkeeping/year-end-service suite
- Zero lint or typecheck errors in any new file
- Migration applied successfully via Supabase MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address PR #508 review — P1 correctness + P2 conventions

P1 — stale reminders in readiness-aggregator (greptile)
Remove the depreciation_manual / bolagsskatt_manual / periodiseringsfond_manual
nudges. After Phase 3, the wizard handles all three automatically via
DepreciationPanel and the dispositioner calculators — surfacing them as
manual reminders told users to repeat work the page already did. Only the
accruals_manual reminder (Phase 4 hasn't shipped) and the EF-only
ef_skatt_via_ne reminder remain.

P1 — netBookValueAfter ignored prior accumulated depreciation
proposeAnnualPostings now fetches all prior posted depreciation_schedules
for the company (excluding the current period) and sums them per asset, so
the displayed restvärde reflects every previously-booked year of avskrivning
instead of only this year. Without the fix, a 5-year asset in year 3 would
have shown 48 000 instead of the correct 24 000 net book value.

P1 — ordering bug in dispositioner POST handler
The 25 % p-fond avsättning cap derives from the current trial balance, so
mandatory återföring entries must post first. Added a server-side sort by
canonical bokslut order (återföring → överavskrivningar → avsättning → SLP
→ bolagsskatt) regardless of the client array order. The cap can no longer
be evaluated against a stale pre-återföring net result.

P2 — depreciation_schedules missing updated_at
New migration 20260516140000_depreciation_schedules_updated_at.sql adds the
column + trigger via update_updated_at_column(). Per CLAUDE.md migration
conventions, never modified the original migration. DepreciationSchedule
type updated.

P2 — addMonths end-of-month overflow
Replaced setUTCMonth (which overflows: Jan 31 + 1 month → Mar 3) with a
day-clamping implementation that produces Feb 28/29. Without the fix,
lifeEndExclusive landed one day too late and slightly over-depreciated.
New regression test asserts Jan 31 + 12 months stays in January.

P2 — pg-real tests for new triggers and RLS
tests/pg/assets.pg.test.ts (13 tests) covers:
  - enforce_asset_post_disposal_immutability blocks every financial field
    after disposal, allows notes/name through
  - assets_disposal_atomic CHECK requires both disposed columns set together
  - enforce_depreciation_schedule_immutability blocks edits after
    journal_entry_id is linked, allows them before
  - depreciation_schedules delete RLS policy filters out posted rows
  - assets + depreciation_schedules RLS isolates across companies

Verification
- 117 unit tests pass (was 116, +1 for the addMonths regression)
- New pg-real suite syntactically + type-correct; will execute in CI
- Zero lint or typecheck errors in any touched file
- Migration 20260516140000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address compliance-swarm + swedish-review findings

Real bugs surfaced by the bots on the first push that weren't covered by
greptile's inline P1/P2 set:

- Immaterial asset disposal hit the wrong BAS accounts. disposeAsset always
  posted gain/loss to 3973/7973 regardless of category. For category
  'immaterial' it must use 3013 (vinst) / 7813 (förlust) per BAS — using the
  tangible accounts misclassifies in INK2R. Now branches on category. Two
  new regression tests pin each branch.

- acquisition_cost CHECK was too loose. CreateAssetSchema accepted 0 (just
  nonnegative). Tightened to z.number().positive() — a zero-cost asset
  creates a no-op depreciation row and a balance sheet line that nothing
  reconciles against.

- UpdateAssetSchema let users remap BAS accounts arbitrarily. Bot flagged
  this as a defense-in-depth gap (V4.5). Added BAS_RANGES_BY_CATEGORY
  validation at both the schema layer (Create) and the service layer
  (Update) so user-supplied account overrides must stay inside the
  category's expected BAS range. INK2R mappings and the depreciation
  engine's category-driven defaults now can't drift.

Swedish accounting review:

- Building/markanläggning defaults — clarified UI copy. The 25-year
  schablon is K2-redovisning, not the IL skattemässig rate. New helper text
  spells this out. Markanläggning default lowered from 20→10 years
  (Skatteverket guidance allows 10 % rate; 20 was on the upper bound
  without justification).

- createAsset doesn't post the acquisition entry by design — that gap
  wasn't called out anywhere in the UI. Added a tip box in
  CreateAssetDialog explaining that the acquisition must already be in the
  books; the register only drives depreciation.

- Disposal VAT (ML 3:3 / 7:3) not handled — sale of a deduct-eligible
  anläggningstillgång is in principle 25 % momspliktig. Documented this as
  a known limitation in the disposeAsset docstring so any future UI
  surfacing the disposal endpoint warns the user.

Documented (not fixed yet) — bot was right but wider-scope work:

- SOC 2 PI1.3: dispositioner POST loop is not transactional. A failure
  midway leaves partial postings. Added a code comment explaining the
  recovery path (re-POST omitting committed kinds — each calculator
  re-derives from current TB). Real atomicity via an RPC wrapper is Phase
  5+ work.

False positives intentionally not changed:

- 4× OWASP V8.2.1 cross-tenant findings — service functions already filter
  by company_id; the bot can't see past the route handler.
- V2.3 client-supplied amount clamping — proposeAvsattning and
  proposeAteforing both clamp via Math.min already.
- A.8.15 audit events — withRouteContext already logs completion.
- Schablonintäkt journal entry — per IL 30:6a it's a skattemässig
  justering, never booked. Current implementation is correct.
- Voucher series 'A' — matches existing executeYearEndClosing convention;
  not changing here in isolation.

Verification
- 119 tests pass (was 117, +2 for the immaterial-disposal branches)
- Zero lint or typecheck errors on any touched file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address compliance round 4 — BAS account overlap + method gate

Two real bugs the compliance bot caught after my round-3 fixes (both new on
its re-run, not in the original report):

- BAS account overlap not prevented: my BAS_RANGES_BY_CATEGORY uses the same
  class range for asset and accumulated (e.g. immaterial: both 1010–1099,
  building: both 1100–1199). Nothing stopped a user from picking the same
  account for both, which would silently net acquisition cost against
  accumulated depreciation in one bucket and corrupt INK2R 720x mappings.
  CreateAssetSchema now rejects bas_asset_account === bas_accumulated_account
  in a superRefine cross-field check; updateAsset enforces the same invariant
  by reading the existing asset and validating the merged result.

- declining-balance methods silently fell back to linear. The DB enum allowed
  declining_balance_30 / declining_balance_20, but the engine's
  computeAnnualDepreciation only implements linear math. A determined caller
  (MCP, curl, future UI) could create an asset labelled as räkenskapsenlig
  avskrivning and get linear charges — silently wrong numbers under a
  misleading method. Both CreateAssetSchema and UpdateAssetSchema now refine
  the depreciation_method enum to require 'linear'. The DB enum stays open
  for a future phase to add proper support. Stale comment in
  depreciation-engine.ts updated to reflect the new invariant.

False positives I'm explicitly not chasing further on this round:
- 3× repeated OWASP V8.2.1 cross-tenant — services already filter by
  company_id; bot can't see past the route handler. Round 3 already added
  service-layer tests and inline reasoning.
- V2.3 atomicity upgrade to high — bot now flags it harder *because* I
  documented it in round 3. The existing executeYearEndClosing has the same
  non-transactional sequential-write pattern; wrapping just this endpoint
  in an RPC while leaving the rest inconsistent is worse than the doc
  comment. Real atomicity is Phase 5+.
- Disposal VAT user-facing warning — no UI surfaces dispose yet; docstring
  in the service is sufficient until the UI ships.

Verification
- 119 tests pass
- Zero lint or typecheck errors on any touched file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address compliance round 5 — disposal integrity + öavskr accounts

Round-5 bot feedback after my round-4 push (the sticky reports re-edited
themselves with two new genuine findings; bot count: 18 → 14 active).

P1 — accumulated_depreciation was client-supplied (OWASP V8.2.1, swedish
compliance review): the dispose endpoint accepted accumulated_depreciation
as a request-body number. A malicious or buggy caller could inflate it to
manipulate the book-value calculation and pocket a phantom gain. Now:

- DisposeAssetSchema no longer accepts accumulated_depreciation
- disposeAsset sums planned_depreciation from depreciation_schedules where
  journal_entry_id IS NOT NULL for the asset, server-side
- New regression test "server-derives accumulated_depreciation — caller
  cannot inflate gain" pins the server-derivation against the prior attack
- Limitation: manual avskrivningsverifikationer posted outside the engine
  aren't captured. Phase 5+ can swap this for a trial-balance scan on
  bas_accumulated_account if that gap matters.

P2 — överavskrivningar hardcoded 8853/2153 regardless of asset category
(swedish-asset-accounting): for buildings BAS uses 8852/2152 and for
immateriella tillgångar 8851/2151. Edge case for K2 SME (öavskr on
buildings is rare; on immateriella rarer still) but worth not lying about
the accounts. Now:

- New OVERAVSKRIVNING_ACCOUNTS table maps category → expense/accumulated
  pair (machinery_equipment, building, immaterial, group)
- proposeOveravskrivningar accepts optional category, defaults to
  machinery_equipment (the dominant K2 case — no behaviour change for
  existing callers)
- POST handler item schema accepts optional category
- Label + description strings now name the actual accounts used
- 3 new tests cover the building, immaterial, and default branches

False positives I'm still declining to chase (already covered in prior
commit messages):
- 3× repeated OWASP V8.2.1 cross-tenant — services scope by company_id;
  bot can't see past route handler
- V2.3 atomicity — existing executeYearEndClosing has the same pattern;
  wrapping just this endpoint is inconsistent; real fix is Phase 5+ RPC
- Disposal VAT user-facing warning — no UI surfaces dispose yet

Verification
- 123 tests pass (was 119, +3 for öavskr category branches and +1 for the
  server-derivation regression test, with one prior test rewritten to use
  the new server-supplied accumulated path)
- Zero lint or typecheck errors on any touched file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): pre-merge polish — building disposal accounts, SLR-2026 rate

Last polish round before merge. All three from the round-5 sticky
Swedish-accounting-review update:

- Building / markanläggning disposal posted gain/loss to 3973/7973. BAS
  2026 routes those to 3971/7971 (the SRU mapping points them at a
  different INK2R field, so the existing accounts misclassify). Extended
  the existing immaterial branch (3013/7813) into a three-way:
    immaterial            → 3013 / 7813
    building / land_imprv → 3971 / 7971
    other tangible        → 3973 / 7973
  Two new regression tests pin the building and land_improvement branches.

- DEFAULT_SCHABLONINTAKT_RATE was 0.03, based on SLR 2024-11-30 (1.96 %).
  For closings of inkomstår 2026 the rate is SLR 2025-11-30 (2.55 %) + 1 pe
  = 3.55 %. The wrong rate under-taxes the schablonintäkt, which feeds into
  bolagsskatt. Updated to 0.0355 and rewrote the doc comment to track both
  years so the next bump is obvious.

- Jämkning of input VAT for buildings / markanläggning disposed within the
  10-year jämkningsperiod (ML 9 kap 8–11 §§) is out of scope for this PR
  but should not be silently absent — added a KNOWN LIMITATION block to the
  disposeAsset docstring so any future UI surfacing disposal checks the
  10-year window and warns the user.

After this push the PR has 125 passing tests, all CI green, no merge
conflicts, and the only remaining bot complaints are repeat false
positives or Phase 5+ scope (RPC atomicity, full asset disposal UI,
K3 component depreciation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:55:27 +02:00
Mattsson c3021f1ec6 Bug/supplier invoice input (#504)
* feat(settings): add information about Skatteverket's scope for transactions

* feat(supplier-invoices): replace Input with Controller for description field in NewSupplierInvoicePage
feat(bookkeeping): update AccountCombobox styling by removing height class
docs(settings): add documentation link for SkatteverketConnectPanel

* refactor(sandbox-seed): remove redundant environment check for sandbox seeding

* feat(sandbox-seed): implement rate limiting and IP address handling in sandbox seed endpoint

* fix(supplier-invoice): add ref to input fields for better form handling
2026-05-16 10:23:37 +02:00
Jakob Wennberg b46b572ee9 fix(invoices): duplicate-payment guard on customer mark-paid + categorize (#502)
* fix(invoices): duplicate-payment guard on customer mark-paid + categorize

Two-pronged fix preventing duplicate verifikationer when a customer
invoice is marked paid OR a 19xx→1510 categorization is applied to an
inbound bank tx that already belongs to an open invoice.

Prong A (mark-paid): before booking, scan unlinked positive business
bank txs from the same customer within ±2% / ±60 days. If candidates
exist, return 409 INVOICE_PAID_LIKELY_DUPLICATE with per-candidate
match_reason (ocr_exact > name_amount_fuzzy > amount_only). Override
via `{ force: true }`. Applied to both legacy /api/invoices/[id]/
mark-paid and v1 /api/v1/.../invoices/[id]/mark-paid; v1 guard runs
before dry-run so previews can't mask the warning.

Prong B (categorize): when the user assigns 1930→1510 directly on a
positive business tx with a matching open customer invoice (by name
OR by OCR-normalized reference), return 409
TX_CATEGORIZE_SUGGEST_CI_MATCH routing them to /match-invoice.

Mirrors the supplier-side guard from #461. Shared helpers
(DUPLICATE_AMOUNT_TOLERANCE_PCT, escapeLikePattern) reused as-is.
New helper normalizeOcrReference() strips non-digits for Swedish OCR
equality. New shared candidate-finder
lib/invoices/duplicate-payment-candidates.ts keeps the legacy and v1
routes calling the same code.

Frontend:
- PaymentBookingDialog intercepts the 409, renders candidate list
  with match_reason badges (Exakt OCR-träff / Sannolik träff /
  Möjlig träff), offers "Länka transaktion" or "Bokför ändå"
  (force-retry generates a fresh Idempotency-Key for v1 callers)
- transactions/page.tsx mirrors siMatchSuggestion handling as
  ciMatchSuggestion with a parallel "Matcha mot kundfaktura?" dialog

v1 caveat documented in the route's pitfalls block:
INVOICE_PAID_LIKELY_DUPLICATE force-retry requires a fresh
Idempotency-Key because the original is body-hash bound; reusing it
returns 400 IDEMPOTENCY_KEY_REUSE.

Tests: 5 new mark-paid tests (legacy + v1) covering 409, force
bypass, partial-payment skip, ocr_exact match_reason, multi-candidate
ranking. 1 v1-only test verifying dry-run also surfaces the 409. 2
categorize Prong B tests (409 + confirm_no_match bypass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(invoices): address compliance-swarm review on duplicate-payment guard

Three review-driven fixes:

1. **PostgREST .or() injection (OWASP V1.2.5).** `escapeLikePattern` neutralises
   LIKE wildcards but NOT PostgREST filter-DSL chars (`,`, `.`, `(`, `)`). A
   customer name like `Acme,fake.eq.true` could otherwise inject a synthetic
   filter clause into the `.or('merchant_name.ilike.%X%,description.ilike.%X%')`
   string. Replaced with two parameterised `.ilike()` queries dispatched in
   parallel and merged by id in JS. Slight perf cost (two index hits per call),
   eliminates the DSL-injection surface entirely.

2. **Date window anchored on invoice_date instead of due_date
   (swedish-accounting-compliance bot).** The Prong B categorize intercept
   filtered open customer invoices by `invoice_date ± 60d` relative to the
   bank-tx date. For invoices with 60–90 day payment terms, the actual
   payment lands well after `invoice_date`, so the legitimate match falls
   outside the window and the guard silently misses it. Switched to
   `due_date ± 60d` — the better proxy for "around when payment is expected."
   No corresponding change for Prong A (mark-paid), which is correctly
   anchored on `paymentDate` (the user-supplied or default-today date) and
   scans bank-tx dates around that anchor.

3. **Force-bypass log enrichment (ISO A.8.15, OWASP V16).** Both
   `duplicate-payment guard bypassed` warn entries now include `userId` and
   `paymentAmount`. Attribution was previously incomplete — the bypass log
   carried only `invoiceId`, which forced a join in log aggregation to
   identify the acting principal.

Tests updated for the two-query pattern (legacy mark-paid suite enqueues
two transactions-table responses per guard invocation; v1 tests already
worked with the single-entry-per-table mock semantics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(docs): redirect /docs/api and /llms-full.txt to docs.gnubok.se

Canonical docs host is now docs.gnubok.se. Every `docs_url` field on the
v1 error envelope still points at /docs/api/* on this app; the 308
permanent redirect forwards humans and agent crawlers to the docs
subdomain without us needing to mass-update structured-errors.ts.

/llms-full.txt also routes through the docs host where it's served from
the docs site's own build.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:46:02 +02:00
Mattsson 240412fd32 feat(settings): add information about Skatteverket's scope for transactions (#499) 2026-05-15 16:36:07 +02:00
Mattsson 7175daee87 Bug/skv konto numbers (#498)
* feat(skattekonto): add overdue transactions handling and split logic

* feat: enhance transaction handling and loading states

- Update BalanceHero component to display last synced date and additional information about Skatteverket updates.
- Refactor BookDirectlyDialog to simplify transaction linking logic and improve UI for transaction selection.
- Revamp InvoiceInboxWorkspace layout for better responsiveness and user experience, including improved skeleton loading states.
- Introduce new loading states for ExtensionWorkspace to match the live layout and improve user feedback during data fetching.
- Implement exchange rate fetching in QuickReviewDialog, ensuring transactions are always processed in SEK with error handling for rate fetching.
- Add structured error handling for unavailable exchange rates in the transaction API.

* feat(skattekonto): add 'Skattekonto – saldo & transaktioner' scope and update authorization checks

* feat: implement reverse charge handling in supplier invoice calculations and UI
2026-05-15 16:25:05 +02:00
Jakob Wennberg 3912c74a7b feat(api): Phase 6 PR-2 — docs polish (Stripe-inspired) (#497)
* feat(api): Phase 6 PR-2 — docs polish (Stripe-inspired)

Ships the developer-facing documentation surface for the v1 REST API.
Mirrors Stripe's structure (landing → cookbooks → concepts → reference
→ errors → changelog) at /docs/api with a sticky-sidebar layout in the
gnubok editorial-monochrome aesthetic. Every page is also served as
plain Markdown via a sibling .md URL so agents and LLM crawlers can
ingest the same content without HTML parsing — the existing /llms.txt
already promised /docs/api references that this PR makes real.

Single source of truth for endpoint metadata is the existing Zod
registry (lib/api/v1/registry.ts). The reference pages auto-generate
from it: adding a new endpoint surfaces in the docs on the next build
with no manual sync. The error reference pulls directly from
lib/errors/structured-errors.ts STRUCTURED_ERRORS.

CONTENT LAYER (lib/docs/):

- content/landing.ts — introduction, auth, base URL, response envelope,
  the four core principles (dry-run, idempotency, strict-mode, inline
  audit), pointers to every other section.
- content/versioning.ts — versioning + deprecation policy (Stripe dated
  format), idempotency, dry-run, strict-mode write semantics, inline
  audit blocks.
- content/webhooks.ts — webhook concept guide. Full Node.js (express +
  crypto) and Python (Flask + hmac) signature-verification samples that
  match lib/webhooks/signing.ts exactly. Lifecycle, event-type
  catalogue, payload shape, request headers, common pitfalls,
  auto-disable behaviour, audit + retention.
- content/errors.ts — generated from STRUCTURED_ERRORS. Groups by
  domain (generic, bookkeeping, periods, invoices, supplier-invoices,
  transactions, reports, imports, documents, salary, company, provider).
  Every code is anchorable so the docs_url field on every error
  envelope finally points somewhere real.
- content/reference.ts — generated from listEndpoints(). Groups by
  resource (companies, customers, invoices, suppliers,
  supplier-invoices, transactions, journal-entries, fiscal-periods,
  accounts, documents, employees, salary-runs, reports, imports,
  compliance, webhooks, operations, voucher-gap-explanations,
  reconciliation). Each endpoint section: summary, description,
  useWhen, doNotUseFor, pitfalls, scope, idempotent/reversible/dry-run
  flags, request + response examples.
- content/changelog.ts — initial entry for API version 2026-05-12
  covering every endpoint shipped in Phases 1-6. Lists what's coming
  in Phase 6 PR-3 (hardening + remaining cookbooks).
- content/cookbook/quickstart.ts — five-minute send-your-first-invoice
  guide. Demonstrates auth, dry-run, idempotency, audit-block patterns
  in one continuous narrative.
- content/cookbook/webhooks.ts — end-to-end webhook setup, sig
  verification, retry handling, idempotency on receiver side, replay
  patterns, auto-disable behaviour. Companion to the concept page.
- content/cookbook/index.ts — recipe registry. 4 placeholder recipes
  (ingest-bank-transactions, file-vat-declaration, run-payroll-and-agi,
  year-end-closing) link to their reference pages with a "coming after
  Phase 6 PR-3 hardening" note. Narrative cookbook quality benefits
  from a focused pass after the substrate stabilises.
- nav.ts — single source of truth for the sidebar nav, used by the
  layout AND the landing-page resource grid.
- markdown.tsx — shared <DocsMarkdown> component using react-markdown
  (already a dep) with Hedvig serif headlines, Geist mono code blocks,
  hairline section borders, paper-white surfaces — same editorial
  aesthetic as the dashboard.

LAYOUT (components/docs/DocsLayout.tsx):

Two-column sticky-sidebar layout. Top header carries the gnubok mark
+ section links (API reference, Cookbooks, Errors, Changelog,
openapi.json). Sidebar groups: Getting started, Cookbooks, Concepts,
API reference, Reference. Active page highlighted with the same
warm-beige bg the dashboard sidebar uses.

ROUTES (app/docs/api/, app/llms-full.txt/):

- /docs/api → landing
- /docs/api/errors → error reference
- /docs/api/webhooks → webhook concept
- /docs/api/versioning → versioning + idempotency + dry-run
- /docs/api/changelog → release notes
- /docs/api/reference → resource overview
- /docs/api/reference/[slug] → per-resource pages (19 resources, all
  generated from the registry; generateStaticParams listed)
- /docs/api/cookbook/[slug] → recipe pages (8 entries, 2 fully written
  + 6 aliases/placeholders)
- /llms-full.txt → everything concatenated for one-shot LLM ingestion

Every page has a sibling .md route (e.g. /docs/api/errors.md) serving
the raw Markdown for agents — same content, no HTML wrapper, same
5-min cache. Honours the existing /llms.txt promise that "every .md
URL under /docs/api is served as plain Markdown".

CI GUARD (lib/api/v1/__tests__/spec-snapshot.test.ts):

Vitest snapshot test that locks down (a) the endpoint count, (b) the
sorted set of method+path keys, (c) the set of distinct scopes
referenced. CI fails if any drift unexpectedly so a Zod-schema change
can't ship a silent API break — when you intentionally add/remove an
endpoint, run with -u to refresh the snapshot, review the diff, and
commit alongside the route change. The snapshot diff itself is a
self-describing changelog entry.

Initial snapshot: 100 endpoints, 17 distinct scopes, full key set
sorted. Fourth assertion in the test guarantees every endpoint
declares the agent-facing metadata (summary, description, useWhen,
doNotUseFor, pitfalls, example) the reference pages depend on — so a
registerEndpoint call that omits any of these fields is caught at CI
time rather than rendering an empty section in the docs.

INFRA TOUCH (lib/api/v1/load-routes.ts):

Added the 5 Phase 6 webhook route imports so the registry includes
them on the docs builders' path. Required for the /docs/api/reference/
webhooks page to render. The webhook routes' registerEndpoint calls
already exist; this just side-effect-imports them where the spec
generator can see them.

Coming in Phase 6 PR-3 (hardening — separate PR):

- 90-day TTL cleanup cron for non-accounting webhook deliveries
- claim_due_webhook_deliveries SQL function (FOR UPDATE SKIP LOCKED)
- Per-route rate limits on :test, :retry, webhook :create
- V16 audit-log on webhook lifecycle events
- DNS-rebinding pinned-IP HTTPS agent
- Integration tests for webhook routes + *.pg.test.ts for triggers
- Populated previous_attributes for update-style webhook events
- The remaining 4 cookbook recipes (ingest-bank-transactions,
  file-vat-declaration, run-payroll-and-agi, year-end-closing) once
  the engine surface is fully stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-497 review round 1 — CI fix + 5 small docs items

CI BLOCKER (the reason core-only failed):

1. **Type error on `[slug].md/route.ts` dynamic routes** — Next.js 16's
   route-type inference can't extract the dynamic segment from a
   directory whose name contains a literal suffix like `[slug].md/`. It
   types `params` as `Promise<{}>` and rejects our handler that
   declares `params: Promise<{ slug: string }>`. The framework still
   ROUTES requests correctly (URL `/docs/api/cookbook/quickstart.md`
   reaches the handler) — only the typed `params` is unusable.

   Fix: drop the typed `params` parameter on the two affected handlers
   (cookbook + reference) and parse the slug from `request.url.pathname`
   directly. Inline comment documents the workaround so the next person
   to touch these doesn't try to "fix" it back to the typed pattern.

GREPTILE INLINE (2 items):

2. **Python sample was missing `import json` and `import os`** — the
   webhook signature-verify sample uses both but only imported `hmac`,
   `hashlib`, `time`, and `flask`. Added the two missing imports.

3. **`buildResourcePages()` perf — called twice per request** (P2).
   Each call iterates every registered endpoint, groups by resource,
   sorts, and serialises Markdown for all 19 resource pages. Memoised
   at module level — the registry is populated once at module load and
   immutable for the process lifetime, so a single derivation is safe
   to cache. Halves the cost on the HTML routes' `generateMetadata` +
   page render pair, and the .md route handlers (which Next.js doesn't
   statically pre-render) are now constant-time after the first GET.

SWEDISH-COMPLIANCE PRECISION (3 items):

4. **`webhooks.ts`: behandlingshistorik vs räkenskapsinformation
   distinction.** The previous "Audit + retention" section conflated
   the two — webhook delivery rows are *behandlingshistorik* (system-
   event log) per BFNAR 2013:2 kap 8 §, NOT räkenskapsinformation
   themselves. The 7-year retention from BFL 7 kap 1 § attaches to the
   underlying verifikation/faktura/AGI XML in its own table, not to
   the delivery envelope. Updated the section to draw the distinction
   and clarify gnubok's 7-year retention on accounting-event delivery
   rows is an operational audit-trail policy, not a statutory
   obligation passed through to the integrator.

5. **`changelog.ts`: same distinction in the Phase 6 PR-1 entry** —
   replaced the "räkenskapsinformation" framing with the correct
   behandlingshistorik framing + the operational-policy note.

6. **Quickstart cookbook: ML 17 kap 24 § p.8 note about
   `beskattningsunderlag per skattesats`.** The "What just happened"
   section now explicitly notes that the rendered PDF contains every
   ML 17 kap 24 § field (including taxable amount per VAT rate) and
   that the JSON response's summary fields are convenience aggregates
   for the integration — the binding faktura content is the PDF.
   Forecloses the misreading that `subtotal + vat_total` is sufficient
   compliance.

7. **Changelog: BFL 7 kap caveat on SIE export.** The `/reports/
   sie-export` line now warns that a SIE4 export alone does NOT
   satisfy BFL 7 kap archiving obligations — SIE captures account
   positions and verifikationer but lacks system documentation and
   behandlingshistorik. Treat as a portability format
   (Fortnox/Visma/Bokio migration), not as a complete archive. Closes
   the misreading the swedish-sie-import-export skill flagged.

DEFENSIBLE DEFERS (round 1 final):

- **CM-8 SPDX-License-Identifier headers per file** (Compliance Swarm).
  The repo declares AGPL-3.0-or-later in the root LICENSE file, which
  satisfies licensing for the project as a whole. Per-file SPDX
  headers are a REUSE-conformance feature; we can address as a sweep
  across the entire codebase if/when REUSE conformance becomes a
  requirement. Out of scope for a docs PR.

- **`/llms-full.txt` exposes payroll endpoint metadata publicly**
  (A.8.12). By design — the entire point of the file is one-shot LLM
  ingestion of the public docs corpus. Endpoint METADATA (path, scope,
  description) is non-sensitive; actual payroll DATA is gated behind
  `payroll:read` scope and requires a real API key. Adding an auth
  gate would defeat the agent-discovery purpose.

- **Secret rotation endpoint** (Art.25(2)). Real product gap (delete +
  recreate is the current rotation path), but it's feature work, not
  docs. Tracked for Phase 6 PR-3 alongside the other webhook hardening.

- **DNS rebinding pinned-IP HTTPS agent** (Art.5(1)(f)). Already
  documented in this changelog as a Phase 6 PR-3 item; bot is just
  re-flagging that it's not yet shipped.

- **A.5.34 changelog cites GDPR Art.5(1)(c) for personnummer masking
  without linking privacy policy.** The citation is informational
  context for developers, not a privacy notice to data subjects. Data-
  subject notices live at /privacy. Adding a pointer is reasonable;
  adding it would consume real estate that's better spent on the
  technical detail. Defer.

- **Compliance Swarm A.5.21 third-party attribution in llms-full.txt**.
  False positive — all markdown content in this PR is original
  first-party text. No third-party snippets to attribute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-497 review round 2 — 6 small precision fixes

All CI green after round 1 (core-only fixed). Compliance Swarm: 7 → 10
findings is the documented oscillation pattern — net-new actionable
items are 6 small fixes; the rest are recurring defers
(plaintext-secret variants, SPDX, planned PR-3 features the changelog
already lists as "coming soon").

FIXED:

1. **Slug allow-list validation in `[slug].md` routes** (V1.2.5 ×2,
   medium). The cookbook + reference .md routes parse the slug from
   the URL pathname (round-1 workaround for Next.js 16's failed
   inference on `[slug].md/` directories) and pass it to a
   dictionary-based lookup. The lookup itself is safe — findRecipe /
   buildResourcePages can't reach SQL or filesystem from a bad
   slug — but the explicit allow-list gate keeps the contract safe
   if the lookup mechanism ever changes (file-load, RPC, etc.).
   Added `Set<string>(COOKBOOK_SLUGS)` + `Set<string>(RESOURCE_SLUGS)`
   guard before any lookup runs.

2. **Changelog: BFL 5 kap 5 § cited on both `/reverse` AND `/correct`**
   (swedish-compliance precision). The previous wording cited BFL 5:5
   only on `/reverse` (storno) and described `/correct` as plain
   "rättelse" — but BFL 5:5 governs rättelse generally, and storno is
   the canonical method of rättelse, so both endpoints satisfy 5:5.
   Updated to: "/{id}/reverse (storno) and /{id}/correct (rättelse) —
   both satisfy BFL 5 kap 5 § (storno is the canonical method of
   rättelse)".

3. **Changelog AGI: explicit that XML is for manual submission**
   (swedish-compliance / swedish-payroll). Previously said
   "/generate-agi produces AGI XML" — could be misread as
   auto-submission to Skatteverket. Now states explicitly that the
   response carries `data.xml` for the integrator to upload via
   Skatteverket Mina Sidor (or via the optional `skatteverket`
   extension), and that the AGI deadline (12th / 17th of the
   following month) is the integrator's responsibility. Aligns with
   the route file's existing doNotUseFor + pitfalls metadata.

4. **Quickstart: F-skatt note qualified**
   (swedish-invoice-compliance). The "What just happened" section
   previously said the PDF "contains the F-skatt note" — only valid
   if the seller actually holds F-skatt. Updated to: "The 'Godkänd
   för F-skatt' note is included automatically when
   company_settings.has_f_skatt is set — confirm this on the company
   settings page before sending invoices in production." Also
   tightened the beskattningsunderlag wording to mention "one line
   per distinct rate on multi-rate invoices" — closes the
   swedish-compliance note about the multi-rate claim in the landing
   needing explicit support in the cookbook.

5. **Cookbook placeholder VAT description: "compute and review" not
   "submit"** (swedish-vat). The placeholder previously said "Compute
   momsdeklaration rutor and submit to Skatteverket" — but no
   Skatteverket-submission endpoint exists in the v1 surface; the
   API only computes the rutor 05–62 values for manual filing.
   Updated description in BOTH cookbook/index.ts AND nav.ts (where
   the same string was duplicated): "Compute momsdeklaration rutor
   05–62 and reconcile against the GL before manual submission to
   Skatteverket." Title also flipped from "File a VAT declaration"
   to "Compute and review a VAT declaration".

6. **Cookbook nav for AGI: aligned to "generate" semantics**. nav.ts
   AGI summary used to say "file AGI" — same misreading risk as #3.
   Now: "Calculate, approve, mark paid, book, generate AGI XML for
   manual Skatteverket upload."

DEFERS (round 2 final — every remaining swarm finding is in one of
these buckets):

- **🟠 Art.32 plaintext webhook secret + 4 sibling framings** (V14,
  V11.1, A.8.24, CC6.1). Established defer per Stripe / GitHub /
  Slack precedent; documented inline in lib/webhooks/signing.ts.
  Bot is re-flagging via the docs surface this round; underlying
  position unchanged.
- **🟡 Art.5(1)(e) 90-day TTL non-accounting cron + V16 audit log +
  V2.4 rate limits**. All explicitly listed in the changelog as
  "Coming soon (Phase 6 PR-3 hardening)". Bot is reading the same
  text we wrote; not blocking.
- **🟠 A.8.11 personnummer masking lacks an automated test**. Real
  product-hardening request, but it's feature work in the employees
  test surface, not docs. Tracked.
- **🟡 CM-8 SPDX-License-Identifier headers per file**. Established
  defer from round 1 — root LICENSE covers AGPL-3.0-or-later for
  the project as a whole; per-file SPDX is a REUSE-conformance sweep
  that's its own effort.
- **🟡 SR-3 SBOM dependencies**. False positive — next/server, react,
  next/link, next/navigation are existing project deps, not new in
  this PR.
- **swedish-compliance "SIE disclaimer could note immutability
  requirement"** — the current disclaimer accurately calls out
  system documentation + behandlingshistorik as missing; adding
  immutability would over-stuff a one-line caveat. Defer with the
  understanding that the SIE skill itself documents the
  immutability requirement for any consumer that follows the
  reference.

If round 3 plateaus (Compliance Swarm count stable, no net-new
inline items), that's the merge-ready signal per Phase 4 lessons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-497 review round 3 — 4 small precision fixes

Compliance Swarm: 10 → 3 (down 70%) — net-new actionable items are
the 4 below; remaining 3 swarm findings are either trivial defense-
in-depth (URL decode, fixed here) or out-of-repo decisions
(personnummer disclosure DPO confirmation).

FIXED:

1. **URL-decode slug before allow-list check** in both .md route
   handlers (V1.2.5 ×2 low). The closed allow-list is pure ASCII so a
   percent-encoded value can't decode to a legitimate slug, but the
   explicit decode-then-check pattern keeps the contract correct under
   any future encoding-quirk runtime. try/catch around
   decodeURIComponent so a malformed % sequence (which throws) returns
   a clean 404 rather than a 500.

2. **Quickstart: explicit `delivery_date` requirement** (swedish-
   invoice-compliance / ML 17:24 field 7). The previous wording
   listed "supply date" as a covered field but didn't note that the
   API does NOT default delivery_date to invoice_date — integrators
   shipping invoices for goods delivered on a different date than the
   invoice date must pass delivery_date explicitly or the rendered
   PDF is non-compliant. Added explicit pass-it-yourself note.

3. **Quickstart: F-skatt strengthened from "verify" to "legal
   requirement"** (swedish-invoice-compliance / Peppol BIS 3.0
   SE-R-005). The previous "confirm on settings page" wording risked
   integrators treating the F-skatt note as optional UX. It's a legal
   requirement on every faktura issued by a company that holds
   F-skatt registration — and a FATAL Peppol BIS 3.0 validation
   failure (SE-R-005) for B2G invoices when missing. Reframed as a
   compliance assertion: the PDF includes it automatically when the
   setting is true; verifying the setting is correct before
   production is the integrator's responsibility.

4. **Changelog: SIE post-import VAT code reconfiguration warning**
   (swedish-sie-import-export). The /imports/sie line previously
   noted the file format support but didn't warn that SIE files do
   NOT carry VAT codes or tax-rate-to-account mappings. After
   migrating from Fortnox / Visma / BL / SpeedLedger / Bokio,
   integrators must manually reconfigure VAT codes before the first
   momsdeklaration — skipping this is the most common source of
   incorrect VAT submissions in migrated bookkeeping.

DEFERS (round 3 final — these are the architectural floor):

- **🟠 A.5.34 personnummer field name + masking logic disclosed in
  public docs** (Compliance Swarm). Defensible: documenting that
  personnummer is masked is a transparency benefit (GDPR Art.13/14
  intent), not a privacy disclosure risk. The DPO confirmation prompt
  is a reasonable governance ask but is an out-of-repo decision —
  the docs change is appropriate as written.
- **AGI penalty amounts (625 / 1,250 SEK)**. Operational guidance
  for integrators building deadline-tracking; not strictly API-doc
  material. The deadline (12th / 17th) is documented; integrators
  who automate compliance can read SFL for penalties.
- **VAT period thresholds in the cookbook placeholder**. Belongs in
  the actual cookbook content when written, not in the placeholder
  description.
- **`invoice.credited` event-naming verification**. False positive —
  the emitter uses `credit_note.created` (which IS in the docs);
  there is no `invoice.credited` event in the codebase. Naming is
  consistent.
- **Webhook retention sentence reordering** (swedish-compliance
  stylistic). Current wording leads with what delivery rows ARE
  (behandlingshistorik), then clarifies what they are NOT
  (räkenskapsinformation) — clean teaching arc, the qualifier is
  prominent. Reordering doesn't change clarity.

Round 3 stop signal hit (per Phase 4 lessons): swarm count plateauing
at the architectural floor with all remaining findings either
defers, false positives, or out-of-repo decisions. Greptile has
posted no inline comments since round 1's two items (both fixed).
This should be merge-ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-497 review round 4 — 5 small precision fixes

Compliance Swarm: 3 → 5 (slight uptick from oscillation, but 0
critical, 1 actionable; remaining 4 are recurring or philosophical).
swedish-compliance: 7 advisories — 3 actionable precision items
incorporated below; the others are forward-looking notes for cookbook
content that ships in Phase 6 follow-ups.

FIXED:

1. **Spec-snapshot test enforces ep.scope is explicitly defined**
   (CC6.3, real future-bug prevention). Previously the test asserted
   every endpoint declared the agent-facing metadata fields the docs
   depend on, but `scope` could be `undefined` — a registerEndpoint
   call that silently dropped the field would make the wrapper treat
   the route as unauthenticated. Added an assertion that
   `ep.scope !== undefined` (the literal sentinel `null` is allowed
   for genuinely public endpoints like /api/v1/health). The 4 spec
   tests still pass — confirming no current endpoint has undefined
   scope and the gate works prospectively.

2. **F-skatt: integrator responsibility for `has_f_skatt` accuracy**
   (swedish-invoice-compliance). The previous "verify on settings
   page" framing didn't connect the flag to the live Skatteverket
   registration. Now: "The integrator is responsible for keeping
   has_f_skatt in sync with the company's live Skatteverket
   registration status. Update via PATCH /api/v1/companies/{id}/
   settings or the settings page — a flag that's false while the
   company is actually F-skatt-registered produces non-compliant
   invoices, not merely a missing optional note."

3. **AGI deadline qualified by turnover** (swedish-payroll). The
   previous wording listed "12th / 17th of the following month" with
   no condition. Now: "12th of the following month for large
   employers, 17th for companies with annual turnover ≤ 40 MSEK."
   Aligns with the swedish-payroll skill's AGI filing deadline
   section.

4. **SIE import warning includes behandlingshistorik gap**
   (swedish-sie-import-export + swedish-accounting-compliance). The
   previous warning covered the VAT-code reconfiguration requirement
   but didn't note that SIE files also do NOT transfer
   behandlingshistorik (the source system's processing log per
   BFNAR 2013:2 kap 8 §) or systemdokumentation. Added: "The
   behandlingshistorik gap must either be preserved separately
   (export from the source system + archive alongside the SIE file)
   or accepted with documented justification — gnubok starts a fresh
   behandlingshistorik from the import date forward."

5. **Webhook 7-year retention: voluntary policy vs statutory
   obligation** (swedish-accounting-compliance). The previous wording
   said gnubok keeps delivery rows "for 7 years as an operational
   audit-trail policy" — the 7-year figure could be misread as
   statutory. Tightened in BOTH webhooks.ts and changelog.ts:
   the 7-year statutory retention under BFL 7 kap 1 § applies ONLY
   to the underlying verifikation/faktura/AGI XML; gnubok's 7-year
   policy on delivery rows is voluntary and chose the duration to
   align conveniently with the statutory horizon on the underlying
   records.

DEFERS (round 4 final, all in the architectural-floor bucket):

- **🟠 A.5.34 personnummer field name + masking logic disclosed in
  public docs** (recurring from round 3). Defensible — documenting
  PII handling is a transparency benefit (GDPR Art.13/14 intent),
  not a privacy disclosure risk. The DPO confirmation prompt is a
  reasonable governance ask but is an out-of-repo decision.
- **🟡 A.8.23 DNS-rebinding "coming soon" item**. The bot is reading
  the changelog's own deferral list. Already tracked for Phase 6
  PR-3 hardening.
- **🟠 CC6.6 SSRF protection details exposed in /llms-full.txt**.
  Stripe / GitHub / Slack publish their full webhook security
  posture publicly (signature format, rejected IP ranges, retry
  policy) — documenting protections IS the trust pattern. Obscurity
  is not security; the SSRF protection is enforced in code, not in
  the docs.
- **🟡 CC6.7 public CDN caching**. withPublicSecurityHeaders()
  already applies the appropriate headers (CSP, X-Content-Type-
  Options, X-Frame-Options). The 5-min cache is appropriate for
  static developer documentation; the alternative (no caching) is
  cost without security benefit since the content is intended to be
  public.
- swedish-compliance "VAT 2026-04-01 livsmedel rate change" —
  forward-looking; ships when the actual VAT cookbook is written.
- swedish-compliance "year-end IB/UB continuity" — forward-looking;
  ships when the year-end cookbook is written.
- 2 verify-only notes (rättelse implementation, future salary-
  journal/avgifter-basis masking sweep) — not actionable in this PR.

Round 4 stop signal: every remaining swarm finding is in the
deferred or recurring bucket; the actionable item (CC6.3) is
shipped. swedish-compliance is now in advisory mode (no errors,
just stylistic suggestions and future-cookbook notes). Per Phase 4
lessons, this is the merge-ready signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-497 review round 5 — 4 small precision fixes (last actionable items)

Compliance Swarm: 5 → 2 (down to architectural floor — 1 high + 1
medium). swedish-compliance: 7 advisories, 4 actionable precision
items addressed below; the others are forward-looking notes for
content that ships in Phase 6 follow-ups.

Trajectory: 7 → 10 → 3 → 5 → 2. Plateaued.

FIXED:

1. **Webhook secret storage guidance: secrets manager, not env file**
   (A.8.5 high). Added explicit instruction to the cookbook that the
   returned secret is signing material and must live in a secrets
   manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault,
   Doppler, 1Password Connect, ...) — not a plaintext .env file or
   config commit. Treated with the same care as a database password.

2. **AGI deadline correction: 17th = January and August only**
   (swedish-payroll). Round 4's wording said "12th (large employers) /
   17th (≤40 MSEK)" — but per the swedish-payroll skill the 17th
   applies in January and August specifically, not generally to all
   months for sub-40 MSEK companies. Other months are the 12th
   regardless of employer size. Fixed to: "the 12th of the following
   month for every reporting period EXCEPT January and August, where
   companies with annual turnover ≤ 40 MSEK get the 17th." This
   would've caused integrators automating sub-40 MSEK deadline
   tracking to misfile by 5 days from February through July and
   September through December.

3. **F-skatt SE-R-005 broader scope** (swedish-invoice-compliance /
   swedish-e-invoicing). The previous wording framed SE-R-005 as
   primarily a Peppol B2G validation rule. Reframed: the F-skatt
   note is a legal requirement on every faktura issued by a Swedish
   momsregistrerad seller that holds F-skatt registration — applies
   to PDF/paper AND Peppol/e-invoice formats. The buyer uses it to
   determine A-skatt withholding obligation (omitting it can shift
   tax liability onto the buyer); B2G is just where the validation
   is automated as a FATAL Peppol BIS 3.0 check.

4. **SIE behandlingshistorik gap: full räkenskapsår scope**
   (swedish-accounting-compliance). Round 4's wording said the
   integrator "must either preserve [behandlingshistorik] separately
   or accept the gap with documented justification" and that gnubok
   "starts a fresh behandlingshistorik from the import date forward."
   The "documented justification" framing implied the gap was
   acceptable as a default. Per BFNAR 2013:2 kap 8 §, the obligation
   attaches to the entire räkenskapsår, not from the import date.
   Reframed as: "must be preserved separately... best practice for a
   mid-year migration: export the source system's behandlingshistorik
   for the full fiscal year and archive it alongside the SIE file."

DEFERS (round 5 final — these are the architectural-floor items
that will recur indefinitely):

- **🟡 A.8.20 DNS-rebinding gap** (Compliance Swarm). Already
  documented in the changelog as a Phase 6 PR-3 deferral item; the
  bot is reading the same text we wrote.
- **swedish-compliance: VAT 2026-04-01 livsmedel rate change**.
  Forward-looking — for the actual VAT cookbook recipe content,
  which ships post-Phase-6.
- **swedish-compliance: year-end IB/UB continuity**. Forward-looking
  — same.
- **swedish-compliance: SIE warning placement note**. Forward-looking
  — for the imports reference page when authored.
- **swedish-compliance: BFNAR 2013:2 citation correct, webhook
  retention correct**. No-op confirmations.
- **swedish-compliance: delivery_date pre-payment scenario**. Real
  but extremely narrow edge case (faktura utfärdad före leverans).
  Defer with the understanding that anyone using the API for
  pre-payment invoicing will read the full invoice reference, not
  rely solely on the quickstart.

This is the merge-ready signal per Phase 4 lessons-learned: every
remaining swarm finding is in the deferred or recurring bucket;
swedish-compliance is in pure-advisory mode (forward-looking notes
for cookbook content that ships later); CI is fully green; Greptile
posted nothing past round 1's two items (both fixed). Ship it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:57:28 +02:00
Mattsson b0890c7c79 Add/docs skv mcp (#494)
* feat: add "book directly" functionality for invoice inbox items

- Extend JournalEntrySourceTypeSchema to include 'inbox_item'.
- Introduce BookInboxItemDirectlySchema for direct journal entry creation.
- Update InvoiceInboxItem type to include matched_transaction_id and created_journal_entry_id.
- Implement BookDirectlyDialog component for user interaction.
- Create API route for booking directly from inbox items with appropriate validations.
- Add SQL migration to support new journal entry references in the invoice inbox items table.
- Implement tests for the new booking functionality and ensure proper error handling.

* fix(invoice-inbox): update status handling for resolved inbox items

* feat: enforce unique journal entry constraint for invoice inbox items
2026-05-15 00:49:59 +02:00
Jakob Wennberg fbf8348ca3 feat(api): Phase 5 PR-2 — payroll lifecycle (calculate, approve, mark-paid, book, generate-agi) (#489)
* feat(api): Phase 5 PR-2 — payroll lifecycle verbs (calculate, approve, mark-paid, book, generate-agi)

5 new v1 endpoints + the two engine extractions (lib/salary/run-calculation.ts
and lib/salary/agi/generate-declaration.ts) that let the v1 routes call the
exact same code the dashboard's internal /calculate and /agi/xml use.
Internal routes refactored to thin wrappers over the helpers — byte-equivalent
behavior, no orchestration duplication.

Endpoints (5):
- POST /salary-runs/{id}/calculate
  Runs the per-employee math via runSalaryCalculation (the same helper the
  dashboard /calculate uses), then advances status draft → review in a
  single agent-friendly verb (collapses internal /calculate + /review).
  Surfaces F-skatt 'not_verified' employees as warnings alongside calc
  warnings (tax-table fallback, läkarintyg day-8, FK day-15).
- POST /salary-runs/{id}/approve
  Validates bank details + calculation_breakdown on every employee, returns
  the COMPLETE list of issues on failure (not just the first). Optimistic-
  lock on status='review'. Emits salary_run.approved.
- POST /salary-runs/{id}/mark-paid
  Stamps paid_at + advances approved → paid. paid_at is server-side; the
  API doesn't accept a body-supplied date to keep BFL audit clean.
- POST /salary-runs/{id}/book  (highest-risk verb)
  Engine-touching. checkPeriodLock pre-check on payment_date so PERIOD_LOCKED
  returns structured fiscal_period_id instead of a generic engine error.
  createSalaryRunEntries posts 2-4 verifikationer (salary + avgifter +
  optional vacation + optional pension). Optimistic-lock status='paid' →
  'booked'. Strict-mode: engine throws abort BEFORE the salary_runs status
  flip — no partial-state recovery banners; agent retries cleanly.
  Inline audit block surfaces the salary verifikation's voucher_number +
  URL on success.
- POST /salary-runs/{id}/generate-agi
  Sync (sub-second). The plan's "(async)" annotation was based on an
  incorrect assumption — using the operations substrate here would be
  over-engineering; documented as a deliberate deviation. Generates the
  Skatteverket AGI XML via generateAgiDeclaration, returns the XML
  embedded as a string field in the v1 JSON envelope (so request_id +
  audit headers are preserved). Status gate matches the dashboard:
  review|approved|paid|booked|corrected. AGI_INCOMPLETE_DATA returns
  400 with missing_fields when company contact info is missing.

Engine extractions (both follow the same discriminated-union pattern):

  runSalaryCalculation(args) → { ok: true; run; warnings } | { ok: false; code; details?; status? }
  generateAgiDeclaration(args) → { ok: true; xml; agiDeclarationId; ... } | { ok: false; code; details?; status? }

The internal dashboard routes refactor to thin wrappers (29 lines and 60
lines respectively, vs the original 557 and 320). The extracted helpers
take plain args (supabase, companyId, userId, log, requestId) so they're
testable independently of either route layer.

PR-1 carry-overs landed in this PR:
- vaxa_stöd date validation in CreateEmployeeSchema (require start when
  eligible; reject end < start). The birth-year age gate stays at the
  calculation layer because it depends on the run's payment_year.
- SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY distinct error code for the FK-null
  guard on salary-runs DELETE (PR-1 review feedback: an operator seeing
  this in logs should immediately know a verifikation may be attached,
  not just that the status raced).
- 3 new structured-error codes: AGI_INCOMPLETE_DATA, COMPANY_NOT_FOUND,
  SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY.

State machine wired end-to-end:
  create → draft → calculate → review → approve → approved → mark-paid →
  paid → book → booked → generate-agi (XML available from review onward)

Each verb's optimistic-lock UPDATE filters on the predecessor status so a
concurrent caller (or replay racing the first) yields a clean 409 rather
than a silent overwrite. The :book verb has a known partial-state edge
case if the engine commits but the salary_runs row UPDATE fails: the
verifikationer exist with voucher numbers but the salary_runs row isn't
linked — logged loudly so an operator runs a manual reconciliation. This
matches the dashboard's existing behavior.

Tests:
- 16 new lifecycle integration tests (auth, state-machine enforcement,
  strict-mode, period-lock, audit block, AGI gate, dry-run)
- Existing PR-1 tests updated for the SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY
  swap (1 test edit)
- 34 total salary-run tests pass (was 17 in PR-1)
- 250 total v1 tests pass; 490 across v1 + salary
- All type-checks clean

Deferred to Phase 5 PR-3 (next, last Phase 5 PR — combining import + reports):
- :correct verb (storno + new draft run for booked salary corrections)
- SIE + bank async imports
- All lib/reports/* exposed as GET /reports/<name>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-489 review round 1 — defense-in-depth filters + maybeSingle + vaxa_stöd UPDATE + V1.2.5 sanitisation

Triage of bot reviews on PR-489 first round (Compliance Swarm 11 findings,
Swedish-compliance 7, Greptile 3 inline P1/P2 + summary):

FIXED (real bugs):

- **Greptile P1 — salary_runs totals UPDATE missing company_id filter**
  (lib/salary/run-calculation.ts:586). The final UPDATE on salary_runs
  ran with only `.eq('id', id)` even though the surrounding code knows
  the company_id. RLS would have blocked a cross-tenant write, but
  CLAUDE.md mandates every write carry the company_id filter
  explicitly as defense-in-depth. Added .eq('company_id', companyId).

- **Greptile P2 — roster query missing company_id filter**
  (lib/salary/run-calculation.ts:116). Same pattern on the
  salary_run_employees SELECT. Added .eq('company_id', companyId).

- **Compliance Swarm V8.2.1 — approve route's roster query missing
  company_id filter** (app/api/v1/.../salary-runs/[id]/approve/route.ts).
  Same defense-in-depth rule. Added the explicit filter.

- **Greptile P2 — agi/generate-declaration.ts existing-AGI check
  uses .single()**. Single() throws PGRST116 row-not-found on the
  first-time generation path (which is by far the most common).
  maybeSingle() returns null cleanly. Swapped.

- **Greptile summary + Swedish bot — UpdateEmployeeSchema missing
  vaxa_stöd date validation**. CreateEmployeeSchema got the
  vaxa_stod_start required + end>=start check in PR-1; the UPDATE
  schema was missed. Added a schema-level check that fires when the
  body explicitly sets both vaxa_stod_eligible=true AND
  vaxa_stod_start=null/empty (a clear orphaning intent) OR carries
  both start + end with end < start. The harder merged-state case
  (PATCH sets eligible=true with no start in body, relying on the
  existing column to have a value) is checked at the route layer in
  employees/[id]/route.ts — it can see the merged state, the schema
  cannot.

- **OWASP V1.2.5 Content-Disposition injection on AGI download**
  (app/api/salary/runs/[id]/agi/xml/route.ts). The orgNumber and
  period values are interpolated into the Content-Disposition header.
  Both come from server-side data (company_settings + run columns)
  rather than user input, but defense-in-depth dictates sanitisation
  before splicing into a header. Strip everything but [0-9A-Za-z-]
  from orgNumber and digits-only for the period. Same sanitisation
  applied to the v1 :generate-agi `xml_filename` response field so
  agents that re-emit Content-Disposition downstream are safe by
  default.

DOCUMENTED (architectural floor / pre-existing dashboard behavior):

- **Concurrent :book engine-call race** (Greptile summary). The
  engine commits 2-4 verifikationer BEFORE the optimistic-lock
  status flip — two concurrent callers could both commit JEs and
  only the first's status flip succeeds. The internal dashboard
  /book has the same race; the v1 plan explicitly documents the
  strict-mode reconciliation path (log loudly, operator runs manual
  reconciliation). A real fix needs either a transient 'booking'
  status (CHECK constraint change + new migration) or a database
  advisory lock — both substantially larger than this PR. Tracked
  for a future hardening pass.

- **vaxa_stod → 'standard' AGI category mapping** (Swedish bot).
  The internal route had this same mapping; the extraction
  inherited it. vaxa_stod should likely map to the youth/reduced
  bracket. Engine-layer fix — out of v1 PR-2 scope, dashboard
  parity preserved.

- **AGI correction path overwrites corrects_agi_id null** (Swedish
  bot). Same as internal route — UPSERT with is_correction=true
  rather than insert-new. Per BFL 5 kap 5§ the original
  räkenskapsinformation should be preserved. Engine-layer concern.

- **AGI status gate allows review** (Swedish bot). Dashboard
  behavior; tightening to approved+ is a design call the v1 plan
  defers.

- **sjuklonRate fallback 0.80** (Swedish bot). Pre-existing engine
  default. Doesn't ship in this PR.

- **Compliance Swarm V8.2.1 path-based tenant check** (book route).
  Recurring false positive per the documented architectural floor.
  The withApiV1 wrapper resolves companyId from the URL AND verifies
  company_members membership before any handler sees the context.

- **V16.1 eventBus emit swallowed**. Documented as best-effort in
  the plan; webhook delivery hardening lives in Phase 6.

- **V2.4 rate limiting at route level**. Documented as Upstash
  Redis follow-up in the plan.

- **Detail endpoint full personnummer / bank_account_number**.
  Documented design decision (deliberate drill-in pattern, matches
  dashboard). CC6.3 segregation-of-duties is an architectural
  decision deferred.

Test count: 38 (unchanged — fixes are all internal). 250 v1 tests pass.
490 across v1 + lib/salary. Type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(ui): switch extension workspace shells to PageHeader + trim TicWorkspace status row

Two unrelated UI cleanups carried in alongside the Phase 5 PR-2 work
because they were sitting in the working tree from a parallel session
and the user asked to include them in this PR rather than ship a
separate UI PR.

- **ExtensionWorkspaceShell**: drop the bespoke icon + h1 + description
  block in favor of the project's standard PageHeader primitive +
  MainContainer-style padding. Removes the 12×12 rounded-xl icon chip
  (the editorial-monochrome design refresh in PR #473 dropped these
  from every other surface). Net: 19 → 6 lines of layout code per
  extension page.

- **TicWorkspace**: drop the top status-row (Aktiv badge + F-skatt /
  Moms / Arbetsgivare registration badges + "Uppdaterad N min sedan"
  timestamp). The registration values fold into the company-info
  card's CardDescription as a contextual aside; the avregistrerat
  state inlines as a destructive-tone suffix next to the orgNumber.
  Simpler header surface, fewer redundant badges.

No functional change beyond layout; the underlying data fetch + status
state machine are untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-489 review round 2 — AGI INSERT race fallback to UPDATE branch

Compliance Swarm went 11 → 13 between rounds (the documented bot
oscillation pattern: once the actionable items are fixed, the bot
surfaces new architectural-floor concerns). Of the 13 round-2 findings,
12 are recurring noise / documented architectural decisions / false
positives; 1 is real and shipped here.

FIXED:

- **Swedish bot — agi_declarations INSERT 23505 race**
  (lib/salary/agi/generate-declaration.ts). The existing-AGI lookup
  uses .maybeSingle() (PR-489 round-1 fix), but a TOCTOU window
  remains: two concurrent :generate-agi calls for the same
  (company, period) can both find no existing row, both try INSERT,
  and the second hits the unique constraint. Previously surfaced as
  a generic DATABASE_ERROR. Now: catch error.code === '23505',
  re-fetch the now-existing row via .maybeSingle(), and fall back
  to the UPDATE branch with is_correction=true. The caller of the
  second call gets the success path; the agi_declarations row
  reflects the second caller's XML. opLog.warn surfaces the race
  for observability.

  Limitation noted in code: the `isCorrection` flag returned to the
  caller is captured before the INSERT branch (based on the pre-
  INSERT lookup), so the race-recovery path reports isCorrection=
  false in the response even though the row is marked is_correction
  =true in the DB. Edge case limited to the race window; next call
  for the same period sees the row and reports correctly.

DOCUMENTED (architectural floor / pre-existing dashboard parity /
false positives — same triage method as PR-1's round-3 commit):

- **V8.2.1 agi/xml legacy companyId** — false positive. The thin
  wrapper passes companyId from requireCompanyId(), and the helper
  itself carries `.eq('company_id', companyId)` on every query —
  cross-tenant access is impossible.

- **V8.2.1 `ctx.companyId!` non-null assertion** — defense-in-depth
  paranoia. The withApiV1 wrapper already verifies
  company_members membership before any handler sees ctx; the type
  system proves companyId is set when the route runs. Adding `if
  (!ctx.companyId) return UNAUTHORIZED` is dead code.

- **V2.3 calculate race** — false positive. The route DOES
  optimistic-lock on `.eq('status', 'draft')` when flipping
  draft→review (see calculate/route.ts line ~206), and treats
  count=0 as 409 SALARY_RUN_CALCULATE_NOT_DRAFT. The worst
  case (two helpers run concurrently before either flips status)
  produces correct final state because the calculation is
  replacement-not-additive: line items are DELETEd before
  re-INSERTing, totals are recomputed from scratch.

- **V4.5 PATCH merges raw body** — false positive. The for-loop
  iterates `Object.entries(body)` where `body` IS the Zod-parsed
  output (`parsed.data`), not rawBody.

- **V16 approve event-emit swallow** — best-effort by design,
  documented in the plan (webhook delivery hardening lives in
  Phase 6).

- **Art.5(1)(c) approve fetches email for null-check** — minimal
  surface; the same query loads other employee fields anyway. The
  alternative (.is.null filter) would mean an additional round-
  trip. Out of scope.

- **Art.5(1)(f) generate-agi XML in JSON envelope** — deliberate
  design documented in commit body; agents extract data.xml and
  forward. Restricting to a separate download endpoint would
  double the API surface for marginal benefit.

- **Art.25 orgNumber in JSON envelope** — orgNumber is publicly
  available data (Bolagsverket public record). Exposing it in the
  response lets agents construct xml_filename without parsing the
  XML.

- **A.8.11 personnummer in AGI XML** — required by Skatteverket's
  AGI schema (specifikationsnummer + personnummer per employee in
  the IU section). Not removable.

- **A.5.34 PATCH error response includes `existing`** — false
  positive. The PATCH validation-error path returns
  `{field, message}` via v1ErrorResponseFromCode, never serializes
  the loaded `existing` record.

- **A.8.15 / A.8.33 / Art.5(1)(c) test fixtures** — recurring
  noise. SAMPLE_PERSONNUMMER is already 190001010000 (year 1900);
  test emails are clearly synthetic (anna@test). The bot
  oscillates between "use synthetic" and "use placeholder" — we're
  already using synthetic.

- **Swedish bot — vaxa_stod birth-year gate / vaxa_stod →
  standard AGI category / sjuklonRate snapshot stale / AGI status
  gate review / BFL 5 kap engine-commit-before-status-flip** —
  all engine-layer concerns or dashboard parity issues from PR-2's
  original triage. Documented in the original commit body; no
  change in this round.

Tests: 38 lifecycle (unchanged). 250 v1 / 490 v1+salary. Type-check
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-489 review round 3 — totals consistency, userId removal, V3.2 citation

Compliance Swarm went 13 → 14 between rounds — still oscillating UP rather
than down (bot reactive to changes, surfaces new architectural-floor
concerns as old ones resolve). Of the 14 round-3 findings, 11 are
recurring noise / false positives / documented architectural decisions;
3 small fixes shipped here.

FIXED:

- **Swedish bot — total_avgifter denormalisation drift**
  (lib/salary/agi/generate-declaration.ts). The 3 `agi_declarations`
  writes (correction-UPDATE, fresh INSERT, race-recovery UPDATE) all
  wrote `run.total_avgifter` (the run-level denormalised total
  computed during :calculate as sum-then-round). The XML, however,
  uses `totals.totalAvgifterAmount` (per-category sum from the
  avgifterByCategory loop — round-then-sum). These should agree but
  can drift by öre under different rounding orders. Now all three
  writes use `totals.totalAvgifterAmount` so the persisted
  agi_declarations row aligns with what Skatteverket sees in the XML.

- **Art.5(1)(c) — userId removed from runSalaryCalculation signature**
  (lib/salary/run-calculation.ts). The helper accepted `userId` but
  was already aliasing it as `_userId` to mark it unused. Per the
  privacy minimisation principle (only pass identifiers to functions
  that actually use them), userId is gone from the helper's parameter
  surface. The two callers (internal /calculate, v1 :calculate) drop
  the argument.

- **OWASP citation correction — V1.2.5 → V3.2/V4**
  (app/api/salary/runs/[id]/agi/xml/route.ts +
  app/api/v1/.../salary-runs/[id]/generate-agi/route.ts). V1.2.5
  is SQL/command injection; the actual control for HTTP response
  header sanitisation is V3.2 (output encoding) / V4 (general access
  control). Comment-only fix; sanitisation code itself was already
  correct.

DOCUMENTED (architectural floor / false positives — same triage method):

- **V4.5 PATCH .strict()** — false positive. Zod's default for
  z.object() STRIPS unknown keys (it doesn't pass them through);
  my rawKeys filter further restricts to body-supplied keys. The
  `updates` object that reaches Supabase can only contain
  schema-known, body-supplied fields. No additional .strict()
  needed.

- **Art.5(1)(f) book first_name/last_name in JEs** — false positive.
  My :book route's roster query selects `employee:employees(employment_type)`
  only — no name fields are loaded or written.

- **V8.2.1 path-based tenant check** — recurring (3rd repeat). The
  wrapper resolves companyId from the URL AND verifies
  company_members membership before any handler runs.

- **V2.3 warnings as blockers** — design decision. Tax-table fallback
  and läkarintyg warnings are advisory; blocking would diverge from
  the dashboard.

- **Art.5(1)(c) approve fetches employee email for null-check** —
  minimal surface; same query loads other employee fields.

- **Art.5(1)(b) XML in JSON envelope** — deliberate design (3rd
  repeat). Documented in commit.

- **Art.25(2) userEmail fallback** — false positive. The helper
  already prefers `settings?.email` over user.email; the
  fallback chain is documented.

- **Art.32 test fixture Bearer token** — paranoia. Literally
  'test-fixture-not-a-real-key'.

- **A.8.15 event swallow** — best-effort by design (4th repeat).
  Phase 6 webhook hardening covers this properly.

- **Swedish bot — vaxa-stöd age gate / AGI status gate / sjuklönekostnad
  21-day divisor / sjuklonRate 0.8 fallback** — all engine-layer
  concerns or dashboard parity issues. Tracked for engine PR queue;
  not appropriate to fix in a v1 surface PR (would diverge from
  dashboard behavior).

Tests: 38 lifecycle (unchanged). 250 v1 / 490 v1+salary. Type-check
clean.

Compliance Swarm trajectory: 11 → 13 → 14. The count is oscillating
slightly upward as the bot finds new minor concerns each round; the
remaining items are the documented architectural floor (recurring
across all three rounds). Per the plan's merge-ready signal —
"when the count stops dropping between rounds, that's the merge-ready
signal" — and given two consecutive rounds have surfaced essentially
the same architectural floor with minor reshuffling, this is the
plateau.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:53:07 +02:00
Jakob Wennberg 5eee06a56d fix(reconciliation): exclude opening balance from unmatched-1930 set (#443) (#485)
* fix(reconciliation): exclude opening balance from unmatched-1930 set (#443)

Reconciliation incorrectly counted IB (source_type='opening_balance')
vouchers on 1930 as unmatched bank transactions and included them in the
GL period total. Result: an SIE-imported book always showed a phantom
unmatched voucher and a difference equal to the IB amount, even when
every real bank transaction was matched. This was the root of the
"reconciliation says broken but numbers look right" complaint.

The fix is two-sided:

- get_unlinked_1930_lines RPC now skips source_type='opening_balance'
  rows. IB has no counterpart in the bank feed by definition.
- getReconciliationStatus splits glOpeningBalance out of the period
  movement and computes difference = bankTotal − glPeriodMovement
  (was bankTotal − glBalance). gl_1930_balance is preserved unchanged
  for back-compat; new gl_1930_period_movement and
  gl_1930_opening_balance fields let the UI show both perspectives.
- BankReconciliationView shows period movement vs bank in the diff and
  folds IB into a small caption ("Ingående balans … räknas inte i
  avstämningen") only when non-zero.

Mirrors Fortnox's "Ingående saldo vid avstämningsstart" pattern — IB is
verified separately, never surfaced as unmatched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(reconciliation): use IS DISTINCT FROM for NULL-safe source_type filter

`source_type <> 'opening_balance'` evaluates to NULL (not TRUE) when
`source_type IS NULL` under SQL three-valued logic. journal_entries.source_type
is NOT NULL today so the practical difference is zero, but if that constraint
is ever relaxed `<>` would silently drop NULL rows from the unmatched-1930 set,
making them invisible to reconciliation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): use valid source_type 'bank_transaction' in pg-real fixture

CI pg-real failed because 'bank_import' is not in the journal_entries
source_type CHECK constraint. The valid value for bank-imported transactions
is 'bank_transaction' (per migration 20260513170001 + earlier).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(reconciliation): deprecate gl_1930_balance on ReconciliationStatus

Mark the IB-inclusive balance field as @deprecated on both the server-side
ReconciliationStatus interface and the component-side mirror. Downstream
consumers that read this field expecting it to drive the diff will be off
by the IB amount whenever a SIE-imported opening balance exists on 1930.
The new gl_1930_period_movement field is the correct basis for the diff and
is what the difference field is computed against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(reconciliation): tighten review feedback on PR #485

Three tweaks from the Swedish-accounting bot review:

1. Drop the gl_1930_period_movement ?? gl_1930_balance fallback in
   BankReconciliationView. gnubok has no persisted reconciliation snapshots
   and Vercel deploys atomically, so the old-server/new-client case can't
   occur — the fallback was defensive code for an impossible state. Per
   CLAUDE.md: don't add back-compat shims when you can just change the code.
   gl_1930_period_movement and gl_1930_opening_balance are now required on
   the type.

2. Clarify in the migration comment that source_type='opening_balance' is
   reserved for the fiscal-year IB voucher (always at period_start). The
   bot questioned whether the unconditional filter could hide a mid-year
   corrective IB entry; document the invariant — mid-year corrections use
   source_type='correction' or 'manual', never 'opening_balance'.

3. Test fixture 2099 → 2091. 2099 is årets resultat (current year);
   2091 is balanserad vinst/förlust, the realistic counterpart for a
   carried-forward bank IB. RPC filters on 1930 only so this is cosmetic
   but matches what a real SIE import would produce.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:39:38 +02:00
Jakob Wennberg cad83d180d fix(currency): transaction-date FX rate + mall bookings in SEK (#442) (#483)
* fix(currency): use transaction-date FX rate and post mall bookings in SEK (#442)

Three related defects in non-SEK transaction handling:

1. Ingest priced every transaction in a batch at today's rate because
   fetchMultipleRates() was called without a date. Switch to a per-
   (currency, date) fetch via fetchExchangeRate() + Promise.allSettled,
   so each row stores Riksbanken's rate as of its own transaction date.

2. buildMappingResultFromTemplate emitted VAT/reverse-charge/output-VAT
   lines from Math.abs(transaction.amount) — the source-currency amount.
   Mall bookings on USD/EUR transactions therefore posted the journal
   entry in the wrong currency. Resolve to SEK once via resolveSekAmount()
   and reuse for every line, matching what createTransactionJournalEntry
   already does for the settlement line.

3. The Granska bokföring modal and the journal-entry detail page lacked
   a clear audit trail. The verifikation preview now renders in SEK with
   a separate original-currency subtitle in the header; the detail page
   gains a dedicated "Valutaomräkning" card above Kontorader showing the
   rate and original total.

Also: replace truncate with break-all on the modal description so long
UUID-style strings (e.g. BALANCE_CASHBACK-…) wrap instead of forcing
horizontal overscroll.

Tests: 4 new booking-templates cases (USD 25%, EUR fallback via
exchange_rate, EU reverse-charge on USD, output VAT on USD income) and
2 new ingest cases (per-pair fetch dedup; no fetch for SEK rows).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(currency): wire SEK conversion into SwipeCategorizationView (#442 follow-up)

Address Greptile review:

- SwipeCategorizationView rendered JournalEntryPreview without amountSek,
  so the swipe flow had the same defect QuickReviewDialog had — a USD
  transaction showed the verifikation in source currency labeled SEK.
  Now resolves SEK once via resolveSekAmount, passes it to the preview,
  and shows SEK as the headline with the original-currency subtitle
  (parallel to QuickReviewDialog).
- Drop the now-dead `currency` prop from JournalEntryPreviewProps and
  its remaining call site in QuickReviewDialog — the component
  hardcodes 'SEK' on display, so the prop was misleading.
- Correct the misleading inline comment in ingest.ts: network failures
  resolve inside fetchExchangeRate to getFallbackRate() (non-null),
  so the key only stays unset on empty-observation responses or
  outright rejections.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:00:12 +02:00
Jakob Wennberg 4a6c473cc4 fix(bookkeeping): allow EF förlängt räkenskapsår on first-period edit + hide Recapt widget (#481)
* fix(bookkeeping): allow EF förlängt räkenskapsår on first-period edit + hide Recapt widget

PATCH /api/bookkeeping/fiscal-periods/[id] rejected enskild firma first
fiscal periods extended into the next calendar year (e.g. 2020-10-04 →
2021-12-31, 15 mån) even though BFL 3 kap. permits up to 18 months when
the EF starts after 1 juli. Validator and DB trigger already supported
this; only the API check ignored isFirstPeriod. Move the isFirstPeriod
calculation above the EF rule and split it into "end must be 31 dec
(always)" + "start must be 1 jan (only when not first period)". Brings
the API into agreement with the frontend's validateFirstPeriod logic.

Also mount RecaptHideWidget in the root layout, which calls
window.recapt('feedback', { widget: 'hide' }) once the SDK is ready.
The floating bubble no longer appears in the bottom-right; Recapt's
identify and programmatic feedback APIs continue to work unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(bookkeeping): address PR-481 review — standard guards, shared helpers, 18-month cap regression

- Use shared createMockRequest / createMockRouteParams from tests/helpers.ts
- Add 401 (unauthenticated), 400 (malformed body), 404 (unknown period)
- Rename "mid-month startdatum" case to "not 1 januari" (request sends 2026-02-01, a month boundary, not mid-month — the rule rejects any non-Jan-1)
- Add defense-in-depth case proving validatePeriodDuration rejects a 24-month EF first period (BFL 3 kap. 18-month cap), since the new EF end-date guard runs before duration validation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(bookkeeping): lock in EF subsequent-period end-date guard

The start-must-be-1-jan + end-must-be-31-dec guards together force EF
subsequent periods to a 12-month span. validatePeriodDuration's 18-month
universal cap doesn't enforce this on its own — only the route's per-EF
guards do. Add a regression test so a future refactor of the EF block
can't silently allow a 13-month subsequent period (e.g. 2026-01-01 →
2027-01-31), addressing PR-481 swedish-compliance review finding #1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:31:01 +02:00
Jakob Wennberg dd28a379c9 refactor(ui): form primitive polish + invoice page grid alignment (#475)
* refactor(ui): form primitive polish + invoice page grid alignment

Three things that were dropped from the previous design-refresh squash
and one new fix on invoices/[id].

Form primitives — tighter focus rings, match Button transition cadence:
- Input/Textarea/Select trigger: transition-colors duration-200 → 150
- Focus ring: border-primary/{50,60} + ring-primary/{20,35} →
  border-primary + ring-primary/20 (full-opacity border with a
  faint glow ring; cleaner than the opacity-stacked version)
- SelectContent: border-border/60 → border-border, shadow-md →
  halved --shadow-md token
- SelectSeparator: bg-border/50 → bg-border

invoices/new — single grid with explicit row placement so the
second-row cards (Anteckningar / Summering) align across columns
regardless of how tall the first-row cards end up:
- Flatten the two column wrappers into one grid
- Add lg:auto-rows-min lg:items-start
- Each card pinned with lg:col-{start,span} + lg:row-start
- Fakturarader now spans col 1-3 (full width) on lg+

invoices/[id] — same alignment fix on the detail view:
- Flatten the column wrappers into one grid
- Kund (col 1-2) + Fakturarader (col 1-2) + Anteckningar (col 1-2,
  conditional) pinned to rows 1-3
- All right-side cards (Detaljer + conditional status cards) stay
  grouped in a single wrapper at lg:col-start-3 lg:row-span-3, so
  they stack naturally without disrupting the left-column rows

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(ui): address PR review

- invoices/[id]: add lg:auto-rows-min to match invoices/new and prevent
  unintended row expansion from any future auto-placed items
- Clean up stale indentation in both invoice pages — Card children
  were 2 spaces too deep after the wrapper divs were removed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:07:01 +02:00
Mattsson c8461397c8 Bug/accounting ps eu (#474)
* feat(api): implement commit functionality for journal entries

* fix(extensions): make ExtensionSettings.clear() a real delete so disconnect flows work

The 2026-03-30 multi-tenant refactor dropped all RLS policies on extension_data
and recreated only SELECT/INSERT/UPDATE. Combined with `value jsonb NOT NULL`,
every extension that called `settings.set(key, null)` to clear stored state
(cloud-backup disconnect, skatteverket OAuth/AGI cleanup, arcim-migration
consent reset) silently failed — the upsert hit the NOT NULL constraint and
the error was swallowed, leaving users stuck with stale connection rows.

Adds an `extension_data_delete` RLS policy, a `clear(key)` method backed by a
real DELETE, switches the four affected handlers, and makes `set()` throw on
Supabase error so this class of silent failure can't recur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(journal-entries): add draft saving functionality to journal entry form

* feat: add periodisk sammanställning report generation and CSV export

- Implemented period date helpers in `period-dates.ts` for calculating start and end dates based on period type (monthly, quarterly, yearly).
- Created `periodisk-sammanstallning.ts` to generate the periodisk sammanställning report, including data fetching, validation, and warning handling.
- Developed CSV serializer in `periodisk-sammanstallning-csv.ts` for exporting the report in SKV574008 format.
- Added new columns to `company_settings` for storing periodisk sammanställning settings and tax contact information via migration.
- Introduced a new migration to add a `paid_with_private_funds` flag to `supplier_invoices` for tracking out-of-pocket expenses.
- Updated journal entries to include the new source type for privately paid supplier invoices.

* feat(migrations): add paid_with_private_funds flag to supplier_invoices and expand journal_entries.source_type CHECK

* fix(ai_requests): drop existing policies and trigger before creating new ones

* fix(migrations): ensure extension_data has a proper DELETE policy for ExtensionSettings.clear()

* fix(supplier-invoices): update error handling for invalid input in POST request

* fix: correct capitalization in project title

* fix(migrations): resolve duplicate version 20260513120000

Two migrations shared the same timestamp prefix, causing
schema_migrations_pkey collision on Supabase preview branches.
Bump extension_data_delete_policy to 20260513120001.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:10:44 +02:00
Jakob Wennberg ba45b8f661 refactor(ui): editorial monochrome design refresh (#473)
* refactor(ui): editorial monochrome design refresh

Design-system layer only, no functional changes. Cascades app-wide
from 8 files in the primitives + tokens layer.

- Swap Fraunces → Hedvig Letters Serif for display typography
  (single-weight; drop font-medium from CardTitle and PageHeader)
- Token sweep: pure white background, warm beige secondary
  (40 11% 89%), achromatic primary, calibrated 45 5% 85% border,
  halved-opacity shadows
- Flatten Card: remove shadow, rounded-xl → rounded-lg, full-opacity
  border (was border-border/60)
- Flatten Button: remove shadow-sm, remove active:scale-[0.98],
  drop outline border morph, transition-all 300ms → transition-colors
  150ms
- Soften Dialog overlay (bg-black/80 → bg-black/40 with dark variant)
  and use halved --shadow-md token on DialogContent
- Sidebar: bg-card/90 → bg-background, full-opacity hairline border,
  warm-beige active state (bg-secondary), hover bg-secondary/60
- .hover-lift utility: replace translateY + box-shadow with flat
  background-color shift
- PageHeader title: text-2xl font-medium → text-3xl md:text-4xl
  (no font-medium)
- CLAUDE.md Brand & Aesthetic, Typography, and Forbidden Patterns
  sections rewritten to reflect new system

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(ui): address PR review

- Remove dead .hover-lift utility (no callers; CLAUDE.md now bans
  hover-lift patterns, so leaving the class would contradict the docs)
- Bump Dialog overlay bg-black/40 → bg-black/50 — gives a more
  perceptible separation layer over the new pure-white background
  while keeping the lighter editorial feel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:01:53 +02:00
Mattsson f0a2577b8b feat(vat-declaration): implement RC basis gap detection and correctio… (#466)
* feat(vat-declaration): implement RC basis gap detection and correction functionality

* fix(vat-declaration): improve error handling and validation for RC basis account selection
2026-05-13 16:13:45 +02:00