Commit Graph

98 Commits

Author SHA1 Message Date
Mattsson 98d0c7f2d0 Add/stripe skv (#1004)
* fix(salary): align pain.001 salary file with the Swedish domestic bank dialect

Verified against the Swedish Common Interpretation of ISO 20022
(Bankforeningen, Common Payment Types in Sweden, Appendix 1 Example 4:
Salaries) and Nordea Corporate Access pain.001 examples v2.6 (2026-06-22),
and XSD-validated against the official pain.001.001.03 schema:

- drop SvcLvl SEPA (SEPA credit transfers are EUR-only; omitting SvcLvl
  gets the domestic NURG default)
- drop RmtInf (not allowed for SALA salary payments; the beneficiary
  statement text comes from the Dataclearing LON code)
- address employees domestically: clearing as CdtrAgt ClrSysMmbId SESBA,
  account WITHOUT clearing as CdtrAcct Othr with SchmeNm BBAN
- share the clearing/account split (Swedbank 5-digit shift, Nordea
  personkonto prefix dedup) between the LB and pain.001 generators via
  splitDomesticBankAccount, fixing pain.001 duplicating the personkonto
  clearing
- clamp MsgId/PmtInfId/InstrId/EndToEndId to Max35Text with the per-tx
  counter surviving truncation; carry the org number on Dbtr
- return 400 from the pain001 route on an invalid clearing instead of
  emitting a broken file

Also includes two unrelated decision-log lines from the parallel
revisor-review session (DECISIONS.md is a shared append-only log).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(nav): surface the year-end chain in the sidebar

Add Periodiseringar, Arsredovisning (aktiebolag only) and
Inkomstdeklaration (INK2 for AB, NE-bilaga for EF) to the Skatt &
bokslut group, in workflow order. Entity gating via a new entityOnly
flag on NavItem; isActive carve-outs extended so exactly one row
lights up for the new routes. Driven by an external revisor review
that concluded these features did not exist because none of them
were reachable from the nav.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(stripe): Stripe Connect integration behind config gate

Connect OAuth per company (only the acct_ id is stored), automatic
single-use Payment Links on invoice send, deterministic payment
settlement against 1686 (BAS moved acquirer receivables 1580 -> 1686),
payout booking with reverse-charge fees (6570 + 4535/4598 + 2645/2614),
and a 15-minute sync cron. Non-deterministic events land as
needs_review, never guessed at.

Fully dark without STRIPE_CONNECT_CLIENT_ID: connect returns 503, the
send hook and cron no-op, and the settings page shows 'Kommer snart'
(hosted) until the Connect platform is verified. Self-hosted keeps the
honest not-configured message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(deadlines): add shared completeTaxDeadline and fix dead AGI deadline auto-complete

generate-declaration.ts has updated non-existent columns (type/period/
status) since inception, so the arbetsgivardeklaration deadline was
never auto-completed. Replace with a shared helper targeting the real
schema (tax_deadline_type/tax_period/is_completed), also used by the
kvittens crons and moms handlers in the follow-up commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(rot-rut): import Skatteverket beslutsfil and record decisions on payout requests

Parse the beslutsfil JSON from Skatteverkets rot/rut e-tjanst and record
godkant belopp on the matching begaran: matched by stored
skv_referensnummer first, then exact name among active undecided
requests; arenden by fakturanummer then personnummer, exactly-one or the
beslut errors (all-or-nothing). Never auto-settles: recording the beslut
and booking the payout are separate acts. Exposed as an API route and
the gnubok_import_rot_rut_beslut MCP tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skatteverket): system auth for background reads, one-click VAT submit, kvittens notifications

Hybrid auth program: system CCG (org certificate) for background reads
while personal BankID stays for interactive submissions, since SKV
per-flow refresh tokens live 65 min and crons structurally cannot run
on them. All system-auth code sits behind SKATTEVERKET_SYSTEM_AUTH_MODE
(default off) with a stub transport until the Expisoft cert and CCG
avtal land; auth resolution is centralized in resolve-auth.ts.

Also in this change:
- One-click VAT submit chaining kontrollera -> utkast -> las
  server-side with a stage discriminator; step-by-step buttons demoted
  to the overflow menu.
- Kvittens crons (AGI + new VAT schedule) with email-only
  notifications, deduped in notification_log under the new
  skv_kvittens type.
- Ombud grant probe + verification UI in the connect panel, and a
  dashboard promo card for unconnected companies.
- skatteverket_company_connections table with pg-real coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(salary): auto-settle AGI tax payment from skattekonto and surface SKV reconnect on the tax card

The "Skatt att betala" card only cleared via the manual mark-paid button
on the run detail page; the promised automatic flip from the Skattekonto
sync was never implemented, so paid periods stayed red.

- settleAgiTaxPayments: during every skattekonto sync, a booked
  "Arbetsgivardeklaration YYYYMM" debit row settles the matching
  agi_declarations.tax_paid_at, but only when the amount equals the
  declared total to the ore and the account is not in deficit
  (deterministic; drift or deficit falls back to manual).
- Salary overview card: reconnect hint when the SKV token needs
  re-consent (link to /settings/tax, silent when the extension is off),
  plus an inline "Markera som betald" button reusing the existing
  endpoint and salary_payments strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add cloud backup scheduling and alerting features

- Implement unit tests for scheduling logic in `schedule.test.ts`, covering various scenarios for determining if a backup schedule is due.
- Create a new module `backup-alert.ts` to handle failure alerts for cloud backup auto-sync, including email notifications for reauthentication and repeated failures.
- Introduce `schedule.ts` to manage scheduling logic, including handling local time zones and converting between local and UTC hours.
- Add CSV report generation functions in `archive-csv.ts` for trial balance, income statement, balance sheet, and general ledger, ensuring compatibility with Swedish Excel formats.
- Create a README generator for the archive structure in `archive-readme.ts`, providing clear documentation for users accessing backup files.
- Implement tests for CSV report generation in `archive-csv.test.ts`, ensuring correct formatting and content.
- Establish a full-archive coverage contract test in `full-archive-coverage.pg.test.ts` to ensure all company-scoped tables are properly classified for backup.

* fix(stripe): correct invoice clearing reference and improve type safety in sync logic

* fix(invoices): narrow accountingMethod before resolveInvoicePaymentSourceType

settleInvoicePayment takes accountingMethod as a raw settings string, but
resolveInvoicePaymentSourceType requires the 'accrual' | 'cash' union.
Normalize at the call site (anything but 'cash' books as accrual), matching
the existing useCashEntry semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address CodeRabbit review findings and nitpicks on PR #1004

Review findings:
- backup settings redirect: always force view=export over incoming params
- AGI/VAT kvittens crons: isolate best-effort post-submit calls, check the
  signed-state persist error, guard recovery calls in catch blocks so one
  company cannot abort the rest; surface grant_revoked in the run summary
- kvittens notifications: atomic claim-first dedup with a partial unique
  index; map non-uuid reference keys to deterministic uuids
- grant probe: record the actual 2xx status; mTLS transport: handle
  response-stream errors
- stripe: amount-aware idempotency keys for payment links; emit
  stripe.disconnected on upstream revocations
- ROT/RUT beslut import: mutate in-memory request state after apply, move
  item + header writes into an atomic apply_rot_rut_beslut RPC, add
  rot_rut_payout to JournalEntrySourceTypeSchema
- migrations: use NOT VALID + VALIDATE CONSTRAINT for CHECK constraints on
  journal_entries, notification_log and rot_rut_payout_requests
- cloud backup: hour_utc-only schedule updates clear stale hour_local

Nitpicks:
- stripe sync: enforce the cron time budget inside per-connection event
  processing with idempotent cursor progress; maybeSingle for settings;
  honest partial-customer DTO shared with the settlement boundary
- shared applyPaymentLinkToInvoice helper for both invoice send routes,
  v1 docblock documents step 6b and PAYMENT_LINK_FAILED
- settings panel: drop redundant decodeURIComponent
- cloud backup: document worst-case archive memory headroom

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:14:12 +02:00
Jakob Wennberg 650c7be5e1 fix(bookkeeping): revive counterparty template learning (dead since the multi-tenant refactor) (#989)
* fix(bookkeeping): revive counterparty template learning, dead since the multi-tenant refactor (#865)

The learning half of counterparty templates has written nothing since
2026-03-30 (prod: 750 SIE imports, zero new templates). Two stacked bugs:

- The multi-tenant refactor re-scoped categorization_templates to
  company_id and the lib stopped writing user_id, but user_id kept its
  NOT NULL: every insert failed with a null violation that supabase-js
  returns rather than throws, so nothing was ever logged. Migration
  20260711100000 drops the NOT NULL and the dead user_id indexes.
- Four of six learning call sites (both categorize routes,
  categorize-core, the MCP server) passed the auth user id as companyId,
  so even with the column fixed the writes would fail FK/RLS and
  corrections could never find the template they were correcting.

Hardening while in here:

- insertOrUpdateTemplate now checks every write result, logs failures,
  and returns whether a row was written; populateTemplatesFromSieVouchers
  reports only templates actually persisted.
- Sign-mismatched matches (an incoming refund matching an expense-learned
  template) previously booked backwards: debit expense / credit bank for
  money coming IN. They are now mirrored into the correct refund shape
  (VAT leg reversed for deductible input VAT), flagged requires_review,
  and excluded from template/rule learning so a refund can never flip a
  learned template.
- Template amounts are computed from the SEK-resolved amount, so
  foreign-currency transactions no longer produce unbalanced multi-line
  entries (or VAT computed on foreign units).
- SIE extraction no longer hardcodes 25% for 2641 (rate-agnostic in BAS):
  the rate is inferred from voucher amounts and snapped to 25/12/6%, and
  reverse-charge counterparties learn vat_treatment='reverse_charge'
  instead of losing the RC legs (which also no longer poison the ratio
  base).
- New pg-real test locks the exact insert column set against the real
  schema, so a schema/code drift like this can't ship green again.

Closes #865

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): mirror fiktiv-moms legs on RC credit notes, exclude import VAT accounts from ratio base

Compliance-review follow-ups on #989:

- REVERSE_CHARGE_VAT_ACCOUNTS gains the import output-VAT accounts
  (2615/2625/2635), which pair with 2645 in import vouchers exactly like
  the RC pairs and must not shrink the business ratio base.
- A sign-mismatched match against a reverse_charge template (an RC
  supplier's credit note) now mirrors both fiktiv legs (credit 2645 /
  debit 2614) instead of booking gross, so Ruta 30/48 net back to zero.
  The income line-builder nets VAT credits against debit legs to keep
  the mirrored pair balance-neutral (identical result for all existing
  credit-only output-VAT paths).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(types): CategorizationTemplate.user_id is nullable since 20260711100000 (CodeRabbit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): use roundOre for the VAT netting, keep the ore-round ratchet at baseline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): review-gate stale 12% templates across the livsmedel transition, pattern-aware direction guard

Compliance-review round 2 on #989:

- Livsmedel VAT dropped 12% -> 6% on 2026-04-01 (Prop. 2025/26:55) while
  restaurang/hotell stay at 12%. A reduced_12 template whose
  last_seen_date predates the transition can no longer be trusted
  unreviewed: its match is flagged requires_review until a
  post-transition approval refreshes it (re-approval keeps 12%, a
  correction relearns 6%). Actively-confirmed 12% counterparties flow
  without friction.
- The opposite-direction correction guard now falls back to the line
  pattern's business sides when the legacy fields are both
  settlement-ish and cannot classify a multi-line template.
- Documented the accepted import-RC mirroring limitation (2614 vs 2615
  ruta attribution) and the netted-vatCredit precondition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:13:50 +02:00
Jakob Wennberg aec81cb7ad fix(db): lock down exchange_rates writes, drop duplicate JEL index, receipts anon read (#969)
Three Supabase-advisor findings from the 2026-07-09 production log triage:

1. exchange_rates (rls_policy_always_true): the exchange_rates_insert
   policy was WITH CHECK (true) for authenticated, letting any signed-in
   user poison the shared FX cache that feeds money math (amount_sek on
   ingested transactions, invoice SEK conversion). Migration
   20260710100000 drops the policy and revokes INSERT from
   anon/authenticated; only the service role writes the cache now (the
   05:00 enable-banking sync cron and the v1 API-key paths both use the
   service client). writeCachedRate() in lib/currency/riksbanken.ts was
   already fail-soft and never inspects the upsert result, so
   user-client paths (bank file import, refresh-exchange-rate) keep
   returning the fetched rate unchanged when the cache write is
   rejected; documented and covered by a new unit test.

2. journal_entry_lines (duplicate_index): idx_journal_entry_lines_entry
   and idx_journal_entry_lines_entry_id are byte-identical btree indexes
   on (journal_entry_id), verified via pg_indexes on prod. Migration
   20260710101000 drops idx_journal_entry_lines_entry (created outside
   the migration history); the repo-defined _entry_id stays.

3. receipts bucket (public_bucket_allows_listing): receipts_public_read
   gave anon SELECT over every object in the bucket, enabling anonymous
   listing. The bucket is unused: no code references it, public.receipts
   has 0 rows in prod, 2 orphan objects from 2026-02-26. Migration
   20260710102000 drops the anon policy; authenticated own-folder
   policies stay untouched.

New tests/pg/db-advisor-lockdowns.pg.test.ts covers all three
(authenticated INSERT rejected, SELECT still works, privilege revoked,
duplicate index gone, anon cannot list receipts).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:05:40 +02:00
Jakob Wennberg 2e7931b36b feat(customers): let users set a customer number shown on the invoice (#957)
Implements #914 (kundnummer on customers, printed on the invoice PDF).

- Migration: nullable text column customers.customer_number, no unique
  constraint in v1 so existing rows and imports keep working.
- API: CreateCustomerSchema/UpdateCustomerSchema accept an optional
  customer_number (trimmed, max 32 chars, nullable-then-optional so the
  OpenAPI registry sees it as not required); create/update routes
  persist it and normalize empty string to null so it can be cleared.
- v1 public API: customers create/detail/update round-trip the field
  (insert and update field lists, response projections, response
  schemas), and the invoices :send route fetches customer_number in its
  explicit customer join so the emailed PDF matches the downloaded one
  (the pdf route already selects customers(*)).
- UI: optional Kundnummer field in CustomerForm (next-intl keys in both
  sv and en), wired into the edit dialog's initialData; read-only
  Kundnummer row on the customer detail page's business-details card.
- Invoice PDF: renders "Kundnr:" / "Customer no.:" in the customer box
  when set; the PDF reads the live customers join, so no snapshot
  column is needed.
- Tests: route tests cover 400 validation, trimming, clearing with
  null/empty, and omit-leaves-untouched on POST and PATCH; v1 tests
  cover the create/update round-trip (insert/update payload + response
  projection) and the :send customer-join projection.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:27:03 +02:00
Mattsson bacc5914af Fix/dependabot cus feedback (#946)
* feat(bookkeeping): per-account default VAT, oresavrundning momsfri

Add a per-account "Standard moms" setting to the chart of accounts and use
it to auto-fill the moms on a leverantorsfaktura-rad when that konto is
picked. Oresavrundning (3740) ships as "Ingen moms", so a rounding line no
longer inherits the 25 % rad-default and skews the moms.

- chart_of_accounts.default_vat_rate (0/0.06/0.12/0.25, CHECK-constrained)
- BEFORE INSERT trigger ships 3740 momsfri on every insert path; backfills
  existing 3740 rows
- kontoplan editor: dead free-text momskod replaced with a Standard moms select
- supplier-invoice rad auto-fills the rate from the konto default

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

* feat(supplier-invoices): configurable start number for the ankomstnummer series

Add a company_settings.next_arrival_number start floor so a company can continue its leverantorsfaktura numbering from a previous system (e.g. Fortnox) instead of restarting the ankomstnummer at 1. get_next_arrival_number now floors the series via GREATEST(MAX(arrival_number)+1, next_arrival_number), so the floor can never move the series backwards or collide with the (company_id, arrival_number) unique index.

The RPC is hardened while rewritten: SET search_path to empty, schema-qualified refs, and an auth.uid() membership check matching generate_invoice_number.

Includes the settings UI field, sv/en strings, migration, and pg-real coverage. The CompanySettings type and Zod schema field for this feature landed earlier in 1bf3b641 (swept into the per-account VAT commit).

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

* fix(dependabot): reduce open pull requests limit and group updates for better management

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:19:57 +02:00
Mattsson 8dde46ad96 fix(db): reconcile prod-orphaned migrations blocking Supabase branching (#942)
* fix(db): reconcile prod-orphaned migrations blocking Supabase branching

Prod's schema_migrations carries three versions with no committed file on
main, leaving the default Supabase branch in MIGRATIONS_FAILED and stopping
preview branches from being created:

  20260707113729  add_transactions_enrichment    (adopted from #927)
  20260708120000  ledger_stats_committed_at_lag  (adopted from #935)
  20260708130000  ledger_deep_context            (adopted from #935)

Adopt the byte-identical SQL under the exact apply-time versions, plus the
matching pg-tests and fixtures for the two RPCs so pg-real stays green:
20260708120000 switches get_ledger_usage_stats' median_booking_lag_days to
committed_at, so the existing test now asserts the new behavior. Idempotent
(ADD COLUMN IF NOT EXISTS / CREATE OR REPLACE FUNCTION): no-op on prod,
clean on fresh replays, no-op on #927/#935's next rebase. The knowledge-page
UI/lib/i18n stay in #935.

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

* fix(deps): pin @anthropic-ai/bedrock-sdk to 0.29.1

0.32.0 (grouped dependabot bump #884) broke Bedrock streaming in prod: empty stream / "request ended without sending any chunks", taking down the in-app AI assistant and invoice OCR. Local dev ran the stale 0.29.1 in node_modules, so it only failed on deploys built fresh from the lockfile. Revert to the six-week-stable 0.29.1; creds/region were never the cause (proven AKIA key + eu-west-1).

Guard against an accidental re-bump three ways: exact pin (no caret), a dependabot ignore, and a pinned-dep check in scripts/checks/no-new-antipatterns.mjs (check:guards). Unpin only once 0.32.x streaming is verified against Bedrock. See DECISIONS.md.
2026-07-08 23:54:49 +02:00
Jakob Wennberg fddc58f624 fix(mcp): exclude VAT contra accounts from ledger-context dominant pick (#932)
Found by the switch-on check (calling gnubok_get_agent_briefing on real
prod data): counterparty patterns for reverse-charge foreign SaaS
(Google/ngrok/Supabase) reported dominant_account 2614 (reverse-charge
output VAT) instead of 5420 (software expense). The dominant_account CTE in
get_ledger_usage_stats excluded only 19xx, so on a reverse-charge booking
(expense + 2645 + 2614 + 1930) the three non-bank accounts tie at equal
counts and the account_number ascending tiebreak picks the low VAT number.

Migration 20260708110000 CREATE OR REPLACEs the function to also exclude
26xx (always moms in BAS, never characterizes a counterparty). Loan/tax
counterparties booking to 23xx/24xx/25xx/27xx stay eligible. supplier_patterns
is unaffected (it aggregates supplier_invoice_items.account_number, expense
only). Regression pg test asserts 5420 over 2614 and was confirmed to fail on
the old function.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:04:25 +02:00
Jakob Wennberg a3c6566caf feat(mcp): ledger-context resource with per-company booking patterns (#928)
* feat(mcp): ledger-context resource with per-company booking patterns

Adds Accounted://ledger/context: derived account usage, counterparty
booking patterns with explicit confidence share (0.7 floor), explicit
mapping rules kept separate as authoritative, observed VAT profile, and
conventions. Backed by a SECURITY INVOKER get_ledger_usage_stats RPC so
group-bys run SQL-side, and surfaced as a top-5 digest stanza on
gnubok_get_agent_briefing so one call still bootstraps a session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mcp): fold source-quality prereqs into the ledger-context RPC

Merchant-name normalization at the aggregation path (the splinter fix):
new normalize_counterparty_key() SQL function mirroring
normalizeCounterpartyName() so KORTKÖP/SWISH/date-suffixed labels merge
into one counterparty key, which also makes the categorization_templates
join exact. New supplier_patterns section (per-supplier dominant expense
account + VAT treatment from supplier invoices; credit notes and reversed
invoices excluded). account_usage excludes storno lines (they re-inflate
the account a correction moved away from); the counterparty CTE keeps
corrections because the transaction relink self-heals. Pattern confidence
is now count-grounded evidence {seen_12m, agree, share, last_booked}
instead of a bare ratio, and the digest frames it as historical frequency,
never auto-book permission.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent-context): use roundOre for the share ratio (antipattern ratchet)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent-context): defensive storno filter on counterparty CTE, fail-loud secondary reads

Review follow-ups: the counterparty CTE now excludes source_type='storno'
defensively (no live code path links a transaction to a storno, but legacy
rows may predate reverseEntry's unlink; a linked storno would count the
reversed category as precedent). Corrections stay included: they are the
live booking after relink. Secondary reads (rules, templates, settings)
now throw instead of silently reading as empty data: an agent must never
be told 'no rules' when the truth is 'read failed'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 13:48:04 +02:00
Jakob Wennberg 8e7e7201d3 fix(db): drop delete_user_account RPC that bypassed BFL retention (#901)
* fix(db): drop delete_user_account RPC that bypassed BFL retention

delete_user_account disabled the retention/immutability/audit triggers,
deleted audit_log rows, and cascaded auth.users, destroying 7 years of
legally retained rakenskapsinformation (BFL 7 kap 2 paragraf). It was
SECURITY DEFINER with only a self-only guard and no REVOKE, so any
authenticated user could call it via PostgREST.

The product path already uses anonymize_user_account, which so far
existed only on production (drift). This migration drops the dangerous
RPC, commits the prod definition of anonymize_user_account verbatim,
adds the profiles tombstone columns it writes (also drift), and locks
grants down to authenticated only.

Closes #342

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log profiles tombstone-column drift-capture decision

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:20:34 +02:00
Mattsson 2c2743eb79 Check/salary bankid api (#892)
* fix(bankid): harden login/signup flow — polling, signup rollback, metadata merge, enrichment lookup

- middleware: read BankID enrichment from the bankid_enrichment table (the
  extension_data path has been dead since the multi-tenant refactor), so
  company-less BankID users land on /select-company instead of the manual wizard
- BankIdAuth: hard 6-min poll deadline; every failed poll counts toward the
  give-up limit; guard overlapping ticks so completion runs exactly once
  (a double /complete regenerated the magic link and invalidated the first,
  failing logins intermittently); retry clicks wait out the start cooldown
  instead of silently no-oping; Swedish messages for 429/unknown start errors
- bankid/complete: all-or-nothing signup — delete the created user when the
  identity insert, app_metadata update, or magic-link generation fails, so a
  retry starts clean instead of hitting account_exists with an unusable account
- bankid/unlink: read-merge-write app_metadata so has_password survives unlink
  (BankID-only users could otherwise strand themselves with no login method)
- login: BankID "create account" CTA now links to /register instead of
  dismissing the notice; sv.json: fix missing å/ä/ö in settings_bankid strings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: move secondary guides into docs/, delete dead root files

Move DOCKER.md, SELF-HOSTING.md, WHITELABEL.md and extensions.md
(renamed EXTENSIONS.md) into a new docs/ folder and update all path
references (README, setup.sh, .dockerignore image rules, docker-publish
workflow comment, _example-branding, lib/branding/service.ts).

Delete two dead root files: customer.json (stray API-test payload) and
findings.md (point-in-time swarm audit export, criticals already filed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(api): security & correctness hardening + withRouteContext MFA migration across API routes

Audit of ~100 app/api routes. Highlights:

Security
- agent/conversations: list leaked colleagues' titles + message previews
  (company-scoped RLS, no user filter) -> user-scoped
- calendar/feed PUT: raw body into .update() allowed feed_token fixation on a
  public unauthenticated URL -> strict schema, content toggles only
- bokslutsdispositioner: unbounded schablonintaktRate could inflate the
  IL 30 kap 25% periodiseringsfond cap base -> bounded
- agent profile/composer/onboarding: viewers could rewrite the agent profile
  while sibling /verify blocked them -> role-gated

Correctness
- account-totals / listAssets: unbounded queries silently truncated at 1000
  rows (under-counted money; skipped assets at year-end depreciation) ->
  fetchAllRows with stable order (+3 more pagination fixes)
- voucher-gaps: swallowed detect_voucher_gaps RPC errors (BFNAR gap view could
  show "no gaps" when the check never ran) -> surfaced
- 5 phantom-success writes (OK on zero matched rows) fixed
- assets K3 component-sum validated against stale acquisition_cost -> fixed
- invite silent email-send failure -> response carries email_sent;
  deadlines/calendar cast-then-check JSON crashes -> Zod

Convention
- ~44 legacy routes converted to withRouteContext (MFA); added Zod validation,
  corrected status codes, console.* -> lib/logger

Response shapes preserved for existing callers. ~110 new tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): save a booking as a reusable template from Bokför direkt

Add a "Spara som mall" action to the manual booking dialog so users can
capture a kontering they just worked out as a booking template — right
where they figured out how something should be booked.

- derive amount-parameterised template lines from the concrete booking
  (settlement = the non-VAT leg nearest the total, 26xx = a VAT line with
  its rate snapped to the nearest standard rate, the rest = business
  ratios; line labels come from the loaded BAS chart)
- extract the shared TemplateForm out of BookingTemplatesPanel so the
  booking dialog reuses the same editor, live preview and convertibility
  hints instead of duplicating them
- save via the existing POST /api/settings/booking-templates endpoint

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

* fix(bokslut): render arsredovisning RR/BR at ÅRL post level — no kontonummer

Bolagsverket rejected a user's filed årsredovisning with "Balansräkning
och resultaträkning ska inte innehålla kontonummer": the PDF built every
statement row as per-account "1930 Företagskonto" lines while the iXBRL
filing path already aggregated to statutory posts, so the two artifacts
diverged.

The PDF statements now derive from the same K2 risbs mapping the iXBRL
document uses (mapTrialBalancesToK2), via a new statement-rows.ts that
emits post-level rows in uppställningsform order for both the K2 and K3
templates. Also fixed along the way:

- Jämförelseår column (ÅRL 3:5 §) — previous-year trial balances now load
  and render; the old PDF had no comparatives at all.
- mapping.warnings (unmapped accounts, RR ≠ 2099, obalans, reclass
  nudges) flow into ArsredovisningData.warnings so the wizard flags a
  non-fileable document before download.
- Flerårsöversikt current/previous year overridden with the mapper's
  strict-3000–3799 Nettoomsattning, mirroring build-input's
  duplicate-fact rule, so the FB table ties to the RR.
- FB eget kapital-table is post-level and drops obeskattade reserver
  (never eget kapital); K3 equity-changes statement uses real prior-year
  opening balances with derived utdelning/nyemission residuals that tie
  the roll-forward exactly to booked UB.
- build-input dedupes warnings now that the PDF path runs the same
  mapping.

Regression test asserts no RR/BR label ever contains a four-digit
account number again.

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

* fix(reports): diagnose untransferred prior-year results behind balance-sheet differens

Prod incident (97 kr): a multi-year SIE migration lacked one year's
omforing av arets resultat; the residual corrupted every later derived
opening balance and Balansrakningen showed a bare "Differens: 97 kr"
with no explanation. Continuity checking cannot catch this failure mode
(prior-year UB and derived IB match per-account by construction) - the
invariant that actually breaks is per-year P&L = 0 for all non-latest
years.

- lib/reports/imbalance-diagnosis.ts: shared detector
  (findUntransferredResults + buildImbalanceDiagnosis)
- Balansrakning/Balansrapport attach imbalance_diagnosis when unbalanced,
  naming the exact culprit years; rendered in web views + PDF; MCP
  gnubok_get_balance_sheet inherits the field via spread
- SIE import: parse-time warning when a completed year's vouchers leave
  a P&L residual, plus a post-import DB walk surfacing culprits as
  warnings and structured details.untransferredResults; the Arcim
  migration workspace previously dropped result.warnings entirely and
  now renders them
- opening-balance/correct: pre-flight the company lock date and return
  409 OB_COMPANY_LOCK_DATE (retryable: false, lock date interpolated in
  the client message) instead of the retryable 500 that invited blind
  retries; catch-path maps a raced trigger rejection to the same code

Diagnosis runs only on unbalanced paths (zero cost when healthy) and
never fails the report or the import. No migration, nothing persisted.

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

* fix: production error remediation — FX rates, deadlines, log levels, correction relink

Batch of fixes for recurring Vercel runtime errors:

- Riksbanken FX rates: persistent read-through cache (exchange_rates
  table), one retry honoring Retry-After on 429/5xx, bounded ingest
  concurrency, and an honest fallback — most recent cached observation
  or null, never a hardcoded rate silently booked into amount_sek.
  Unrated transactions stay repairable via refresh-exchange-rate.
- Tax deadline regeneration inserts replacement rows before deleting
  the superseded set, so a failed insert no longer wipes a company's
  deadlines (the 23502 user_id regression did exactly that). Migration
  makes deadlines.user_id nullable for system-generated rows.
- Route wrappers + errorResponse log 4xx outcomes at warn so only
  genuine 5xx reach Vercel's runtime-error clustering; client-supplied
  /api/log telemetry demoted to warn as well.
- application/json documents (raw PSD2 responses archived per BFL)
  validate as parseable JSON with object/array root instead of always
  failing the magic-byte check.
- correctEntry surfaces document-relink failures to callers, and the
  BFL document-immutability trigger now allows relinking underlag from
  a reversed entry to its correction (migration + pg test).
- Middleware clears stale session cookies on /api requests too, using
  scope 'local' so cleanup doesn't re-trigger the failed token refresh.

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

* fix(skatteverket): persist token health and stop retrying dead consents

Terminal auth errors (SESSION_EXPIRED, REFRESH_EXHAUSTED, MISSING_SCOPE,
TOKEN_CORRUPTED) mark the token row needs_reconsent with the error code
and timestamp — SKV per-flow refresh tokens live 65 minutes, so once
expired nothing recovers without a fresh BankID consent. The AGI
kvittens and skattekonto sync crons skip flagged connections instead of
failing every night, and the settings panel prompts for re-consent
proactively. A successful reconnect resets the row to active.

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

* fix(banking): allocate distinct BAS ledger slots for PSD2 mirror accounts

A bank returning N same-currency accounts used to map them all onto the
currency default (1930/1932/1933/1934), tripping the UNIQUE
(company_id, ledger_account) constraint per-account — swallowed errors
left accounts silently unmirrored. allocatePsd2LedgerAccount now hands
out the currency default first, then free 1931–1959 sub-account slots,
skipping slots held by any existing row.

- Callback persists allocations to accounts_data so the picker pre-fills
  reality; reconnect reuses previously mirrored ledgers instead of
  re-deriving (a user remap to 1935 survives).
- Selection save resolves effective ledgers up front and rejects
  duplicates or cross-connection conflicts with a 400 instead of
  silently skipping the mirror.
- Bank error codes + psu_type are forwarded to the settings page for
  every OAuth error, keying the Handelsbanken corporate fullmakt
  guidance.

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

* fix(agent): stage exact journal lines on categorization previews

Categorization previews only carried debit/credit accounts, the GROSS
amount, and separate VAT rows — read together that looks like an
unbalanced 'gross on cost account + VAT debit' entry, and it misled
both users and agents into rejecting correct proposals. The MCP
preview and the pending-operation PATCH now materialize the exact
lines the commit executor will post (net cost line, VAT line, gross
bank line, SEK) via buildTransactionEntryLines, and PATCH re-derives
them from the new mapping instead of spreading stale staged lines.
ApprovalCard and /pending render the verifikat lines, falling back to
the legacy summary only for operations staged before this fix.

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

* feat(bookkeeping): prune unused imported accounts from the chart

SIE imports routinely bring in hundreds of accounts that were never
used and clutter the kontoplan. New account_usage_counts RPC (one
grouped query instead of a count per account) backs GET
/api/bookkeeping/accounts/usage, and POST /api/bookkeeping/accounts/prune
deletes zero-usage accounts — dry-run first, then an explicit account
list capped at 2000. Accounts with journal lines are skipped, never
deleted. The chart manager shows a usage column and a prune dialog
grouping custom accounts vs unused BAS-seeded ones.

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

* feat(api): carry dimensions through v1 invoice and supplier-invoice surfaces

Credit-note creation now copies default_dimensions and per-line
dimensions from the original, so the reversing journal entry nets
against the same dimension cells instead of dropping them. List/detail
responses expose the dimension fields, and the OpenAPI spec snapshot
follows.

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

* perf: batch serial Supabase round-trips on hot dashboard paths

Every dashboard render pays the layout's query chain, so serialized
awaits are direct wall-clock: the layout, chat conversation, invoice
detail, supplier detail, select-company, and agent-onboarding pages now
run their independent lookups in parallel batches, and
getCompanyCapabilities folds its disabled-config read into the same
round-trip. JournalEntryList hydrates the saved fiscal-year scope
optimistically instead of serializing the first entries fetch behind
the fiscal-periods request. The supplier detail page filters invoices
server-side via a new supplier_id query param instead of fetching the
whole company ledger, and the invoice editor (with its framer-motion
dependency) lazy-loads so it stops shipping with the invoice list
bundle.

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

* feat(salary): one-click runs, payslip delivery, payments settings, run cockpit

Salary P1 batch, driving the 20-click flow toward 3 clicks:

- One-click 'Starta lönekörning': POST /api/salary/runs accepts an
  empty body and resolves defaults server-side — period follows the
  latest non-corrected run, payment date from the new
  salary_pay_day setting, series from the per-source-type map. The
  separate /salary/runs/new page is gone.
- Run detail page rebuilt as a step-railed cockpit (progress rail,
  KPI cards, employee ledger, journal preview) on a deliberately
  wider canvas; components extracted to components/salary/run/.
- Payslip delivery: tokenized public payslip pages (/payslip/[token],
  backed by salary_payslip_links) plus per-employee email send with
  PDF — employees need no account, and the middleware exempts the
  route from auth redirects.
- Payments settings: salary pay day, default bank, and pain.001 vs
  Bankgirot Lön format with per-bank upload instructions and an LB
  sunset warning (banks retire LB during 2026).
- AGI panel: full submission status flows (stale drafts, signing
  links, kvittens polling, error reports); tax payment panel with
  skattekonto shortcut and mark-as-paid.
- Salary calendar bulk editing, employee benefits/tax-card polish,
  municipality tax-table lookup improvements.

messages/sv+en also carry the strings for the account-prune,
skatteverket-reconsent, and banking surfaces committed just before
this.

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

* chore: adopt Next 16 proxy.ts convention + repo housekeeping

- Rename middleware.ts to proxy.ts with the proxy() export (Next 16
  renamed the middleware convention; behavior unchanged).
- Exclude dev_docs/ from tsconfig so stray snippets in planning docs
  don't break the build type-check.
- Ratchet antipatterns-baseline down (raw-route-auth 165 → 119) to
  lock in the withRouteContext migration from 5cfd2b76.
- template-library uses roundOre() instead of inline rounding.
- database.md: drop account_balances from the key-tables list.

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

* fix(bookkeeping): robust service-role detection in correction document relink

relink_documents_to_correction() keyed its service-role branch on auth.role(),
which reads the singular request.jwt.claim.role GUC that PostgREST v10+ and the
pg-real harness no longer populate. Genuine service-role callers (pending-ops
executor / MCP approve) landed in the auth gate and could not relink underlag.
Read the role from the request.jwt.claims JSON directly, mirroring the canonical
link_voucher_rpcs_tenant_guard convention. Validated on staging.

Also: harden the salary run page's error paths (res.json().catch) against
non-JSON error bodies, and roll back the pg-real service-role case in finally so
an aborted transaction cannot poison a pooled connection for the next test.

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

* fix(documents): restore journal_entry_line_id link durability (BFL 7 kap)

Migration 20260704103000 rewrote enforce_document_journal_entry_immutability to
guard journal_entry_id but left journal_entry_line_id to the metadata trigger,
which exempts draft-linked docs -- and the entry-level trigger only fired on
UPDATE OF journal_entry_id, so a line-id-only UPDATE never invoked it at all.
That let a set journal_entry_line_id be cleared to NULL, breaking the "link
durable from first set" invariant (document-immutability.pg regression).

Widen the trigger to fire on journal_entry_line_id too and guard it with the
same uuid-durability rule as journal_entry_id (setting NULL -> uuid stays
allowed; clearing/re-pointing a set value is blocked, status-independent). The
correction-relink GUC path, which legitimately clears line_id when moving
underlag to the posted correction, stays exempt. Validated on staging.

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

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:05:09 +02:00
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00
Jakob Wennberg 764348e99c feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement (#886)
* feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement

The final rung of the dimensions ladder
(dev_docs/dimensions_implementation_plan.md §7 row 10):

- custom dimensions: POST /api/dimensions creates registry dims (next free
  SIE number >= 20 when omitted; explicit numbers allowed — SIE import
  already mints reserved ones); register gets a 'Ny dimension' dialog with
  a quiet Avancerat disclosure for the #UNDERDIM parent; GET now carries
  parent_sie_dim_no (the column + SIE round-trip existed since PR1/PR5 —
  this exposes it)
- account_dimension_rules (migration 20260703120000): one rule per
  (account, dimension) — required / default / fixed, per-rule is_active,
  company-scoped RLS, composite FK to the registry, value-presence CHECK
- enforcement, opt-in BY CONSTRUCTION (zero rules = engine byte-identical;
  deliberately NO settings toggle — a rule that exists but is ignored is
  worse than either extreme): default/fixed apply onto line bags at draft
  creation (fixed overwrites, default fills); required asserts at
  commitEntry with a Swedish MANDATORY_DIMENSION_MISSING naming every
  account + dimension; the bulk-book route runs the same policy before its
  RPC; storno/correction paths never pass through commitEntry so history
  always reverses regardless of policy; rule fetches fail open incl.
  thrown exceptions
- chart of accounts: per-account Dimensionsregler section in
  EditAccountDialog (Krävs/Förval/Låst, value picker, pause switch),
  gated on the existing dimensions toggle, quiet when empty
- pickers: LineDimensionFields is registry-driven (one combobox per active
  dimension, cached fetch, hardcoded 1/6 fallback) — every existing mount
  lights up custom dims with zero changes
- agent briefing: per-dimension required_on_accounts/default_on_accounts
  so agents self-correct instead of bouncing off the policy error
- rules CRUD API with existence/active/company validation and qualified
  DTO ids; firm_id FK deferred until the firms table lands (per plan)

39 new tests (pure-fn rules, engine enforcement, both new API surfaces,
pg-real RLS/CHECK/cascade suite); full suite 6,791 green; migration
replayed on a fresh container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: renumber migration to 20260703200000 — version collision with prod

The concurrent session shipped pending_operations_add_link_document_to_voucher
as 20260703120000 today; the Supabase preview branch (cloned from prod)
rejected the duplicate version key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: review round — auto-pick retry on collision, fail-open warnings, query schema

- POST /api/dimensions retries once past a concurrent number claim when the
  number was auto-picked (explicit choices still 409)
- every fail-open skip of the dimension-rules policy now logs a structured
  warning (engine draft/commit paths + bulk-book) — deliberate fail-open,
  but observable
- GET /api/dimensions/rules validates its query through
  ListDimensionRulesQuerySchema instead of an inline regex

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:50:28 +02:00
Mattsson 237b77a366 feat: custom inbound mail domains, rot/rut payout file, invoice email texts, security hardening (#878)
* fix(security): guard MCP test keys, RLS role gate + voucher RPC guards, /api MFA gate, deps

- MCP: force dry-run / block writes for test-mode API keys in tools/call (extensions/general/mcp-server)
- DB: current_user_can_write role gate on write policies (40 tables) + tenant guards, SET search_path, REVOKE anon on commit_journal_entry / next_voucher_number / detect_voucher_gaps (migration 20260702093000)
- Middleware: MFA (AAL2) gate on cookie-authenticated /api routes via apiPathSkipsMfaGate
- Deps: npm audit fix clears mailparser/linkify-it/nodemailer/svix/uuid highs; xlsx -> SheetJS 0.20.3

Adds unit + pg-real tests. Does not touch in-progress ROT/RUT or invoice-email-texts work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): rot/rut begäran om utbetalning — HUS XML (V6), payout tracking + settlement, MCP tool

Generates Skatteverkets begäran-om-utbetalning file (schema V6) from paid
ROT/RUT invoices — no submission API exists, the file is uploaded manually
at skatteverket.se. Headless by design for now: API routes + MCP tool
(gnubok_generate_rot_rut_file), no UI surfaces.

- lib/invoices/rot-rut-file.ts: pure XML generator with deterministic
  per-invoice blockers (hours, work type, personnummer, property info,
  mixed rot+rut, XSD limits) + 31 January deadline warnings
- rot_rut_payout_requests(+items) tables: one active begäran per invoice
  (DB triggers incl. reactivation guard), RLS, audit, pg-real tests
- Settlement: POST /settle books debit 1930 / credit 1513 via the engine
  (source_type rot_rut_payout); partial payouts → partially_paid
- Work-type lists corrected against Begaran.xsd: IT-tjänster is rut-only,
  snöskottning/tillsyn/tvätt added (schablontjänster utfört-only)
- Fix: invoice-level fastighetsbeteckning was validated but never
  persisted — now stamped onto rot lines in build-invoice-write; API
  accepts bostadsrätt pair (lägenhetsnr + BRF orgnr, editor UI deferred)
- invoice_items.brf_org_number migration + MCP scope invoices:write

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): per-company editable invoice email texts

Add an "E-posttexter" section under Settings -> Fakturering where the
subject, greeting, body and sign-off of the standard invoice email can
be customized per company in Swedish and English. Fields pre-fill with
the standard texts and only diffs from the standard are stored
(company_settings.invoice_email_texts JSONB), so future improvements to
the stock wording still reach companies that have not customized. Each
field has a reset-to-standard button; cleared fields snap back.

Texts support a fixed placeholder set (invoice number, customer name,
first name, company, due date, amount) substituted at send time in a
single pass; unknown placeholders stay literal. Custom texts are
HTML-escaped after substitution, newlines become <br> in the HTML
variant, and subject lines are flattened to a single header line.
Overrides apply to standard invoices only - credit notes, proforma and
delivery notes keep the stock texts. All send paths (UI, v1 API, MCP
approval, recurring) pick the texts up via the existing settings row.

The Zod schema half of this change (InvoiceEmailTextsSchema in
lib/api/schemas.ts) was inadvertently included in 8291f745.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(documents): accept PDFs with preamble before %PDF- header, surface content rejections as 400

detectFileMagic required the %PDF- signature at byte 0 (BOM aside),
rejecting genuine PDFs that carry a leading newline or junk bytes —
files every ISO 32000 reader opens fine. Now scan the first 1024 bytes
for the signature, matching real-reader behavior. Image types stay
strict at offset 0 to keep the anti-placeholder defense tight.

Magic-byte rejections were also mislabeled as DOC_UPLOAD_STORAGE_FAILED
(500 'Filen kunde inte sparas'), blaming storage for a client-side file
problem. Both upload routes now map them to a new
DOC_UPLOAD_INVALID_CONTENT (400) with an accurate message.

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

* feat(bookkeeping): full keyboard flow for manual journal entry

Enter now drives the whole verifikat flow: verifikationstext drops into
the first row missing an account, konto commits advance to debet, Enter
on an empty debet hops to kredit, and an entered amount jumps to the
next row. Once the voucher balances, Enter opens the review (unchanged
gate) and the auto-focused confirm posts it — including through the
no-underlag warning dialog. Escape in the inline review goes back to
the form.

Also fixes an Enter footgun in AccountCombobox: a bare Enter on a
freshly focused field no longer selects the first account in the list —
selection now requires typing or arrow navigation; otherwise Enter
re-commits the current value or bubbles to the form-level handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: add custom inbound domains management for companies

- Implemented functionality to allow companies to claim and manage their own inbound email domains via Resend's API.
- Created a new table `company_inbound_domains` to store domain information, including status and DNS records.
- Added necessary RLS policies to restrict access based on user roles (owner/admin).
- Developed functions for domain normalization, validation, claiming, verification, and removal.
- Implemented webhook handling for domain status updates from Resend.
- Added comprehensive tests for RLS, constraints, and triggers related to the new domain management feature.

* fix: address PR #878 review findings and CI failures

- migrations: drop the ai_usage_tracking policy block from the role-gate
  migration — the table was removed by 20260504120000_remove_ai_subsystem
  and only lingers on staging as drift; a from-scratch chain (pg-real,
  Supabase preview) failed on it
- invoice-inbox: never flip a custom domain to verified off a domain.updated
  webhook alone — confirm the receiving capability with Resend first
  (fail-closed); normalize both sides of the orphan-adoption domain match
- rot/rut: block files where begärt belopp exceeds what the buyer paid
  (DEDUCTION_EXCEEDS_PAYMENT); tighten brf_org_number validation to real
  orgnr shapes; parameterize the settlement bank account (19xx, default 1930)
- rot/rut routes: log acting user on financial mutations, stop swallowing
  item mirror errors, narrow response projections (no customer ids through
  the invoice join); document the deliberate inline-XML decision
- documents: stop echoing raw storage-layer error messages to clients

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: round-2 CI + compliance findings on PR #878

- migrations: the role-gate migration targeted automation_webhooks, which
  20260515170000_webhooks_v2 renamed to webhooks on the canonical chain
  (staging kept the old name — drift); gate public.webhooks instead,
  dropping legacy schema-sync policy names defensively. Restore the
  20260623130000 owner fallback in next_voucher_number that the stale
  copied-verbatim body silently reverted (caught by engine.pg locally).
  Full migration chain verified from scratch against supabase/postgres:15.
- mcp: bump the tools/list payload ceiling 44K -> 45K — main's #877
  qualified-identifier schemas plus this branch's rot/rut tool crossed the
  ceiling only in combination; documented in the test's history log.
- rot/rut: refuse partial settlement before Skatteverkets beslut is
  recorded (would bypass the PATCH lifecycle and strand the request);
  block zero-kronor ärenden (ZERO_DEDUCTION); require sekelsiffra 16 on
  12-digit brf orgnr in both schema validation and normalizeBrfOrgNr

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: rename branch migrations off main's colliding versions

After the merge with main, two versions were shared by two files each
(20260702100000: rot_rut_payout_requests vs company_settings_dimensions_
enabled; 20260702130000: invoice_email_texts vs pending_operations_add_
create_dimension_value). psql-based CI applies by filename and doesn't
care, but Supabase branching records migrations by version (PK) — the
second file with the same version breaks the preview with a
schema_migrations_pkey duplicate. Neither branch migration is version-
recorded on staging or prod, so renaming to fresh 20260703 versions is
safe; nothing between the old and new positions depends on these objects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): scope the /api MFA-gate bypass to real Bearer-auth surfaces

Any Authorization header — attacker-controlled — used to skip the AAL2
gate for every /api route, so a stolen-password AAL1 cookie session could
reach cookie-authenticated routes (which ignore the header) by attaching
`Authorization: x`. The skip is now scoped to the surfaces whose auth
contract IS the header (/api/v1 API keys, the MCP endpoint's OAuth
tokens); pure Bearer callers elsewhere (cron secret, signed webhooks)
carry no cookie session and were never touched by the gate, which only
fires for cookie users. Superagent P2 on PR #878.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: normalize path separators in dimension statutory guard scan

The route scan compared walked file paths against a POSIX-path allowlist,
so the suite failed on Windows (backslash separators) while passing on
Linux CI. Normalize the scanned paths to forward slashes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:57:59 +02:00
Jakob Wennberg b27f6cdb04 fix(db): NULL-safe tenant guards via caller_is_company_member + mechanical sweep (#881)
The house guard 'p_company_id NOT IN (SELECT public.user_company_ids())'
skips the deny branch on UNKNOWN (NULL on either side). Not exploitable
today (company_members.company_id is NOT NULL) but a NULL p_company_id
passes the guard, and the shape fails silently under change. 9 live
functions carried it (link_*_to_voucher, reserve/release_voucher_range,
mark_entry_as_opening_balance, retag_line_dimensions,
ensure_company_dimensions, company_has_capability, rotate_company_inbox).

- caller_is_company_member(uuid): NULL-safe membership predicate
  (NULL -> false, always).
- Mechanical rewrite: every public function carrying the raw pattern is
  re-created via pg_get_functiondef with the guard swapped — deliberate
  over hand-copying 9 bodies (the stale-copy hazard behind the 07-03
  constraint clobber). Probe-validated locally: pattern swapped, NULL
  denied.
- pg-real ratchet: after full replay no public function may contain the
  raw pattern (also blocks future reintroduction); detector self-test;
  helper semantics (member/foreigner/NULL).

Existing tenant-guard suites re-assert deny semantics on the rewritten
functions in CI.

Part of dev_docs/mcp_optimization_plan.md (P2-3 follow-up, PR #872 review).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:48:09 +02:00
Jakob Wennberg 21512db81a feat(mcp): unify missing-document surfaces on one predicate (#876)
* feat(mcp): unify missing-document surfaces on one predicate (P1-3)

The two MCP surfaces told different truths: the transactions tool keyed
'has underlag' on transactions.document_id while the verifikat tool
keyed on document_attachments — and neither respected the source-type
semantics, version chains, or journal_entry_no_doc_required waivers
that lib/worklist's canonical count applies. Measured on prod: 22,046
waived verifikat still listed to agents, 2,370 doc-exempt source types
listed, ~87 docs attached to transactions but never propagated to the
verifikat, 1,100 transactions flagged missing-receipt although their
verifikat HAS the underlag.

One predicate now lives in SQL — posted, needs-doc source type
(mirrors NEEDS_DOC_SOURCE_TYPES), no current-version doc, no waiver:

- verifikat_without_documents RPC v2 adopts the canonical predicate.
- New transactions_without_documents RPC: the bank-driven subset of the
  same predicate, joined through transactions.journal_entry_id — a
  strict subset of the verifikat surface by construction. Rows expose
  qualified transaction_id (P1-2 forward-compat); bare id deprecated.
- Both tools become thin RPC wrappers; descriptions state the actual
  set relationship.
- lib/worklist countVerifikatMissingDocument delegates to the RPC
  (previously three full-table pulls set-differenced client-side) —
  badge count and agent surfaces can no longer drift.
- Backfill: propagate transaction-attached docs to their verifikat
  where the attachment was never linked (open periods only; never
  steals a doc linked to another verifikat).

pg-real: fixture matrix (no-doc/with-doc/waived/stale-version/
doc-exempt-source/import), strict-subset assertion, per-source-type pin
of the SQL list against the TS constant, tenant guard.

Part of dev_docs/mcp_optimization_plan.md (P1-3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(mcp): explicit grants restated + count-call comment (#876 review)

- Restate REVOKE/GRANT on verifikat_without_documents so the migration
  is self-contained (CREATE OR REPLACE preserves the 20260703130000
  grants — verified on prod: authenticated + service_role only).
- Comment on the p_limit:1 count call: total_count is computed over the
  full filtered set, independent of page size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:56:14 +02:00
Jakob Wennberg 45438d4d64 fix(mcp): SQL-side filtering and pagination for list_verifikat_without_documents (#872)
The tool applied min_amount in memory after the PostgREST .range() page:
total_count ignored the filter, and next_offset advanced by the filtered
row count while the DB page consumed 'limit' rows — consecutive pages
overlapped and full backlog coverage could not be proven (reported via
agent.feedback).

gross_amount is an aggregate over journal_entry_lines that PostgREST
cannot filter on, so filtering/counting/pagination move into a new
verifikat_without_documents RPC (SECURITY DEFINER with the PR #625
tenant-guard pattern; id tiebreak for total ordering; filter-respecting
total_count). Also indexes document_attachments.journal_entry_id, which
the anti-join and the link tools both hit.

pg-real invariants: disjoint + complete pages under min_amount,
filter-respecting totals, since filter, doc/draft exclusion, tenant
guard both ways.

Part of dev_docs/mcp_optimization_plan.md (P0-2).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:42:02 +02:00
Jakob Wennberg 4b5fe9ee57 fix(mcp): add link_document_to_voucher to pending_operations CHECK constraint (#871)
The gnubok_link_document_to_voucher tool shipped with its executor and
risk tier but its operation type was never added to the
pending_operations_operation_type_check constraint. Every real staging
INSERT failed with check_violation while dry_run previews were clean
(the INSERT is skipped). Reported 3 times by 2 companies via
agent.feedback; blocked Bokio attachment migration.

Adds a pg-real audit test that extracts every op type staged in
server.ts plus all OPERATION_RISK_TIERS keys and asserts each is
accepted by the constraint, so a staging tool can never again ship
without its constraint expansion (and a stale expand-types migration
can no longer silently drop a type).

Part of dev_docs/mcp_optimization_plan.md (P0-1).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:41:31 +02:00
Jakob Wennberg fb3f0a9cee feat(dimensions): PR9 cutover — cost_center/project become GENERATED columns, dual-write removed (#870)
The dual-write window ends (dev_docs/dimensions_implementation_plan.md PR9):
journal_entry_lines.cost_center/project are now GENERATED ALWAYS AS
(NULLIF(dimensions->>'1'/'6','')) STORED — divergence from the bag is
impossible by construction instead of by convention.

- migration 20260702230000: drift pre-flight (refuses cutover on
  inconsistent data; prod verified 0 drift across 593k rows), column swap
  (DROP metadata-only + one-rewrite ADD pair), and atomic redefinition of
  the two SQL writers — retag_line_dimensions (SET dimensions only) and
  bulk_book_transactions (INSERT names the bag only)
- TS writers stripped of the mirror spread: engine buildLineInserts
  (covers create/update/reversal), storno-service (reversal + correction),
  SIE import bulk insert, sandbox seed
- lineDimensionColumns() removed from dimension-resolver — nothing derives
  mirrors in TypeScript anymore; normalizeLineDimensions + the deprecated
  cost_center/project INPUT aliases stay (API contract, they normalize
  into the bag); JournalEntryLine ROW type keeps the fields (generated
  columns still SELECT)
- immutability carve-out unchanged BY DESIGN: its whole-row diff already
  subtracts dimensions/cost_center/project on both sides, which is exactly
  what makes it correct with generated columns (BEFORE-trigger NEW carries
  not-yet-recomputed mirror values)
- audited every reader (v1 journal-entries, MCP query_journal filters +
  group_by, rc-basis-gaps) — reads are untouched; no index, view, or
  constraint referenced the TEXT columns, so DROP COLUMN cascades nothing
- new pg suite: generated derivation, explicit-mirror-write rejection,
  draft-update recompute; existing retag/substrate/bulk-book suites
  updated to bag-only writes (their mirror assertions now exercise the
  generation expression)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:28:42 +02:00
Jakob Wennberg 755e0f7e47 feat(dimensions): PR7 producers — auto-tagged documents (invoices, supplier invoices, bulk-book, templates, MCP) (#868)
* feat(dimensions): PR7 producers — invoices/supplier invoices carry dims, generators propagate, BulkBook + templates + MCP bags

Source documents now carry dimension tags and every entry generator
propagates them onto journal lines (dev_docs/dimensions_implementation_plan.md PR7):

- invoices/supplier_invoices.default_dimensions + per-item dimensions
  (migration 20260702200000; jsonb DEFAULT '{}' + object CHECK)
- invoice-entries: issuance/payment/cash/credit propagate — item bags merge
  over the invoice default per revenue line (account+bag aggregation
  identity), payment vouchers re-propagate the linked invoice's bag onto
  every leg incl. FX result lines; ROT/RUT 1513 carries the item bag
- supplier-invoice-entries: registration/payment/cash/privately-paid/credit
  propagate with the same merge rules (expense buckets keyed account+bag)
- bulk_book_transactions RPC persists per-line bags + derives
  cost_center/project mirrors in SQL (migration 20260702201000; malformed
  bags rejected with BULK_BOOK_INVALID_DIMENSIONS); route merges the header
  default into template/manual lines
- counterparty templates: LinePatternEntry.dimensions learned from SIE
  voucher history (kept only when every occurrence agrees), applied to
  business lines on booking; QuickReviewDialog shows a dims badge
- categorize: staged dimensions bag tags business lines only (bank/VAT
  untagged); credit/convert/inbox copy paths carry bags forward
- propose-payment/send-lines stamp the invoice default so the editable
  payment grid books what the preview shows; mark-paid override lines
  accept dimensions
- UI: InvoiceEditor + NewSupplierInvoiceForm header KS/Projekt pair with
  per-row override; BulkBookDialog header default pair (both tabs)
- MCP: default_dimensions/items[].dimensions on create_invoice +
  create_supplier_invoice_from_inbox, dimensions on categorize_transaction,
  per-line bags on bulk_book_transactions — resolve-don't-select via the
  shared registry helpers, resolutions echoed

32 new propagation unit tests + 4 pg-real tests for the RPC migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: use roundOre in new dims rounding assertions (ratchet)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: copy dimension bag per payment line, document dimensionsBagKey normalization contract (review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:21:01 +02:00
Jakob Wennberg 816b1769c8 feat(dimensions): PR6 retro-tagging — audited retag carve-out, BulkTagWorkbench, staged MCP tool (#867)
* feat(dimensions): PR6 retro-tagging — audited retag carve-out, workbench, staged MCP tool

Tier-2 retro-tagging (founder decision №1, approved 2026-07-02): posted
entries in OPEN periods can have their dimension tags changed through ONE
audited path — everything about the verifikat itself stays immutable.

Carve-out (migration 20260702170000): the line-immutability trigger gains a
single narrow branch — while the transaction-local GUC set by the RPC is
active, an UPDATE of a posted line is admitted iff every non-dimension
column is unchanged, enforced by a whole-row to_jsonb diff (any future
column is protected by construction; mirrors cost_center/project are in the
changeable set because they are derived views of dimensions['1']/['6']).
Precedent: mark_entry_as_opening_balance (20260613120000).

retag_line_dimensions RPC: tenant guard (20260619130100 pattern), writer
gate (viewers rejected), posted-only, open period + company lock date
enforced, every code validated against the ACTIVE registry, immutable
dimension_retag_log row (before/after/actor/reason, INSERT-only via its own
trigger, no FKs so the trail survives hard-deletes) written BEFORE the
carve-out UPDATE. Idempotent no-op without a log row. Untag ({}) supported.
Legal position per the plan: dimensions are internredovisning metadata, not
BFL 5 kap 7§ verifikat content — this is strictly more conservative than
Fortnox/Visma (dimension-only diffs, open periods only, immutable log,
storno past locks — Tier 3 has no exceptions).

Mandatory pg suite (11 tests): GUC-less updates still blocked; amounts/
description can never change even under the GUC (transaction-local);
closed/locked/lock-date, role, registry, draft and cross-tenant rejections;
log immutability; gnubok.allow_delete bulk path unaffected.

UX (all writes through the ONE RPC): pencil on posted-voucher lines in
bookkeeping/[id] ("Påverkar endast internredovisningen, inte verifikatet")
+ retag-history card; BulkTagWorkbench at /dimensions/tagging (filters,
shift-select, merge vs "Ersätt tagg" replace mode, reversal-pair warning
with "Inkludera motverifikat" auto-selection, per-line failure display).

MCP: gnubok_tag_journal_lines (bookkeeping:write) — filter block resolved
via resolve-don't-select, ≤500 lines, staged via pending_operations (new
op type migration 20260702171000, medium risk tier, shared Zod validation
boundary between staging and commit; executor loops the RPC per line with
partial-success aggregation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): address #867 review — SQLSTATE classification, blocking storno confirm, documented divergence

- Retag route classifies RPC errors by SQLSTATE instead of message-regex:
  P0001 (every rule violation in the RPC) → 409 verbatim, 42501 (tenant
  guard) → 403, anything else → logged 500 with a generic message. No more
  substring sniffing.
- The workbench's storno-pair warning escalates to a BLOCKING confirmation
  naming the unselected counter-vouchers before apply (Srf U 14 gross
  reporting — one-legged retags silently skew project P&L; the banner alone
  was advisory).
- The empty-bag divergence is now documented on both schemas as intentional:
  the direct dialog/workbench path allows {} (human untags phantom codes,
  logged with reason), the MCP staged path rejects it (agents never
  bulk-clear history).

Triage notes: the log's missing FKs are the point (behandlingshistorik must
survive undo_sie_import hard-deletes — a cascade would erase the trail);
SIE exports are generated fresh on demand, never cached, so post-retag
exports carry the new object lists automatically; date-scoped registry
values are deliberately not enforced at retag because entry creation does
not enforce them either — enforcing in one path only would be incoherent
(both belong to the PR10 rules engine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:02:34 +02:00
Jakob Wennberg 8bb49c07a2 feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry (#858)
* feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry

Phase 2 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false (default) see zero change.

API:
- Dashboard CRUD: GET /api/dimensions (lazy-seeds system dims 1/6 via the
  ensure_company_dimensions RPC), PATCH /api/dimensions/[id] (is_system
  rename blocked), POST/PATCH/DELETE values (code immutable after creation;
  strict Fortnox code format ^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$ at the API layer;
  retention-trigger deletes surface the Swedish "arkivera istället" message
  as 409 DIMENSION_VALUE_REFERENCED).
- POST /api/dimensions/import-existing — scans journal_entry_lines.dimensions
  for unregistered codes and mints inactive placeholder registry rows.
- v1 public API: GET dimensions + POST values (Idempotency-Key, dry-run),
  registered in the OpenAPI spec (102→104 endpoints).
- dimensions_enabled boolean on company_settings (new migration,
  UI-visibility only, never correctness-bearing) exposed through the
  existing settings read/update path.

SIE export (lib/reports/sie-export.ts):
- Reads the new dimensions/dimension_values registry; legacy
  cost_centers/projects tables now have zero readers (drop migration next).
- Fixes the latent Visma-rejection bug: #OBJEKT now declared for INACTIVE
  values referenced by lines.
- Generic-N: #DIM/#UNDERDIM loop sorted by sie_dim_no; #TRANS object lists
  serialize from the line JSONB map (sorted, '01'→'1' collapse); orphan
  codes/dims synthesize declarations from the SIE reserved-number seed —
  every referenced (dim, code) pair is guaranteed declared.

UI:
- /dimensions register (Register-recipe): tabs per dimension, search,
  sortable table, value dialog (code immutable on edit, projekt dates on
  dim 6), archive-not-delete affordances.
- DimensionCombobox shipped (mounts in the tagging PR).
- Settings toggle "Aktivera kostnadsställen & projekt" — toggle-on runs the
  import-existing scan and links to the register.
- Nav row in redovisning, rendered only when dimensions_enabled (same
  mechanism as pays_salaries).
- dimensions.* i18n namespace (51 keys, sv/en parity).
- Sandbox seed: demo dims + values, revenue line tagged {"1":"BUTIK","6":"P001"}.

Verified: 6328/6328 unit tests, guard + coverage gate green, tsc parity with
main (210=210), production build passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): PR2 review round — atomic archived-create, UNDERDIM ordering, import robustness, date semantics

- POST values accepts is_active so "create as archived" is atomic; the UI's
  fragile create-then-PATCH fallback is deleted (PR Agent finding 1).
- DimensionCombobox blur revert reads the committed value/values through refs
  so a selection landing inside the 150ms window always wins (finding 2).
- import-existing sanitizes candidate codes like the PR1 backfill and upserts
  with ignoreDuplicates — one bad/duplicate code can no longer abort the
  batch; created counted from returned rows (finding 3).
- SIE export emits all root #DIM before any #UNDERDIM so a parent always
  precedes a lower-numbered child (SIE4 declaration order — Swedish review);
  synthesized placeholder declarations now log one structured warning
  (BFNAR 2013:2 behandlingshistorik) + defence-in-depth comment.
- Value dates rejected (400 DIMENSION_VALUE_DATES_NOT_ALLOWED) when the
  parent dimension is flow-period (resets_annually=true); explicit null
  still clears (Swedish review).
- Sandbox seed logs seeded dimension codes; GET /api/dimensions documents
  the deliberate absence of dimensions_enabled gating (UI-visibility flag,
  not a security boundary — compliance-swarm V8.2.1 rejected by design).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:26:42 +02:00
Jakob Wennberg 8cc2efb083 feat(dimensions): PR1 substrate — SIE-native registry + dimensions JSONB on journal lines (#857)
* feat(dimensions): substrate — SIE-native registry + dimensions JSONB on journal lines (PR1)

Implements phase 1 of dev_docs/dimensions_implementation_plan.md:

- New company-native registry tables: dimensions (= SIE #DIM/#UNDERDIM,
  seeded is_system 1=Kostnadsställe / 6=Projekt via ensure_company_dimensions,
  nullable bare firm_id) and dimension_values (= #OBJEKT), full RLS incl.
  DELETE, audit + updated_at triggers, guard triggers (system dims undeletable,
  sie_dim_no immutable, values referenced by posted lines archive-not-delete).
- journal_entry_lines.dimensions jsonb NOT NULL DEFAULT '{}' as the single
  source of truth ({sie_dim_no: object_code}), CHECK object-typed, GIN
  (jsonb_path_ops) + partial expression indexes on dims 1/6. Inherits posted-
  line immutability from the existing trigger with zero new triggers.
- Backfill: representation copy of legacy cost_center/project text into the
  JSONB map (trigger-disabled, schema_sync precedent); legacy cost_centers/
  projects registry rows copied into dimension_values; inactive placeholder
  values for orphaned free-text codes.
- Dual-write: engine buildLineInserts + storno/correction/date-move now derive
  cost_center/project mirrors from the map via lib/bookkeeping/dimension-resolver.ts
  (normalizeLineDimensions / lineDimensionColumns); reversal copies dims.
- CreateJournalEntryLineInput + shared Zod line schema gain a dimensions bag
  (cost_center/project stay as deprecated aliases); pending-ops voucher lines
  coerce it.
- CI ratchet: direct-jel-insert check in no-new-antipatterns.mjs — inserts into
  journal_entry_lines outside sanctioned writers fail CI.
- pg-real suite: registry RLS/guards/retention, ensure_company_dimensions
  tenant guard, dims frozen on posted lines, CHECK enforcement (13 tests).

Non-breaking: companies without dimensions see zero change; no UI yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): address review findings — canonical keys, boundary-validated staged bags, migration guidance

- normalizeLineDimensions canonicalizes numeric keys ('01' -> '1') so
  leading-zero keys can't split values or miss the cost_center/project mirrors
  (PR Agent finding).
- New coerceDimensionsBag() in dimension-resolver is the single boundary
  validator for untyped staged payloads, enforcing the same constraints as the
  Zod line schema (string-only values, 1-40 chars, no SIE-framing chars,
  canonical keys). pending-operations normalizeVoucherLines now uses it —
  staged payloads can no longer bypass API-layer validation via numeric
  coercion (compliance-swarm V2.2/V1.2.5/PI1.1, Swedish review finding 4).
- Migration backfill comment now spells out the exact conditions under which
  the trigger-disable pattern is defensible (BFL 5:5 / BFNAR 2013:2) and what
  a future reviewer must verify before reusing it (Swedish review finding 2).
- 10 new resolver tests incl. reversal-parity (empty bag + aliases ==
  alias-only) proving the reverseEntry and storno paths normalize identically
  (PR Agent finding 1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): round-2 review — shared Zod schema, transactional backfill, empty-string guard

- DimensionsBagSchema now lives in dimension-resolver as the single source of
  truth; CreateJournalEntryLineSchema and coerceDimensionsBag both delegate to
  it, so the API layer and the staged pending-operations path provably cannot
  drift (compliance-swarm V2.2). coerceDimensionsBag switches to whole-bag
  semantics: any invalid entry rejects the bag, exactly like the API schema.
- Migration backfill now runs DISABLE TRIGGER / UPDATE / ENABLE TRIGGER inside
  one transaction — the ACCESS EXCLUSIVE lock from ALTER TABLE holds until
  COMMIT, so no concurrent writer can slip an unguarded line write into the
  window during a live apply (compliance-swarm V1.2, Swedish review finding 1).
- NULLIF guard: empty-string legacy mirrors can no longer mint {"n":""}
  entries the resolver would interpret as "cleared" (PR Agent round-2 edge).
- COMMENT ON dimensions.resets_annually documenting the SIE4 #IB/#OIB
  semantics the PR2+ export path must honour (Swedish review finding 2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:27:07 +02:00
Mattsson f63d3e3100 Bug/open banking flow (#854)
* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects

We never sent auth_method to Enable Banking, so it fell back to the ASPSP's
visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate*
PSUs the redirect flow does not support Mobile BankID, so authorization failed
right after the user approved in the BankID app. Mobile BankID at Handelsbanken
is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses
when requested explicitly.

Resolve the bank's preferred auth method before /auth: query the ASPSP's
auth_methods and pick the DECOUPLED (Mobile BankID) method when present,
otherwise leave auth_method unset so banks that already work are untouched.
The method name is read dynamically per psu_type, so it is robust across
sandbox/production naming.

- api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods
  field name (was available_auth_methods, never populated), add
  getPreferredAuthMethod(), thread optional authMethod through startAuthorization
- index: resolve authMethod in /connect and pass it on both fresh + reconnect
- tests: cover method selection and request-body shaping

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

* refactor(invoice-inbox): clean up bulk-selection toolbar UI

Redesign the selection toolbar shown when inbox items are checked:
one solid primary "Bokför valda" button with outlined secondary
actions ("Fråga assistenten", "Ta bort") and a plain selection
count. Removes the redundant "Avmarkera" button (users uncheck the
still-visible box), fixes label clipping, and gives the toolbar more
breathing room.

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

* chore(entitlements): bypass paywall in local development

Add isPaywallBypassed() so all gated capabilities are testable locally
without a subscription. Fires only on NODE_ENV=development (npm run dev)
or an explicit DISABLE_PAYWALL=true escape hatch — production builds run
under NODE_ENV=production and the entitlement suite runs under 'test',
so both keep exercising the real gate.

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

* fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer

TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand.

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

* feat(transactions): implement categorize core for bank transaction categorization

- Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations.
- Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing.
- Implemented fiscal period validation and duplicate booking detection.
- Enhanced logging and error handling for transaction categorization.

feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata

- Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken.
- Outputs metadata for business and personal PSU types, including default authentication methods.

fix(migrations): increase statement timeout for SIE bulk delete operations

- Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports.

feat(migrations): add bulk book inbox items to pending operations

- Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`.
- Supports bulk booking of matched inbox items against bank transactions.

test(pg): add tests for replace_period_opening_balance_link RPC

- Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow.
- Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries.

* fix(sie-export): update journal entries and lines handling in SIE export tests

* fix(migrations): resolve version collision on 20260629160000

The SIE bulk-delete statement_timeout migration shared version
20260629160000 with journal_entries_list_series_filter (merged from
main via #798/#823), causing a schema_migrations_pkey duplicate key
error on apply. Rename the branch's migration to 20260629160100.

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

* fix(compliance): resolve compliance-swarm + review findings

- opening-balance/correct: compensating rollback for the non-atomic
  storno+rebook so a mid-sequence failure never leaves two posted OB
  entries (ASVS V2.3); durable audit event on every failure path
  (V16); reference the original verifikationsnummer in the corrected
  entry per BFL 5 kap 5§; document that requireWrite already enforces
  write-role + membership (V8.2.1 was a false positive)
- reports sources routes: validate the cursor date component as ISO
  (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2),
  applied to both the VAT-declaration and trial-balance routes
- AgentSessionList: await the rename PATCH, revert the optimistic
  title and toast on failure (ASVS V4.5)
- bank booking: exclude same-batch siblings from the booking-time
  duplicate guard so bulk-booking distinct same-(date,amount)
  transactions no longer false-positives; pre-existing duplicate
  detection is preserved
- BulkBookInboxDialog: drop the unsafe currency-based reverse_charge
  default, add an omvänd skattskyldighet advisory, and type VAT
  options to the backend VatTreatment union
- OpeningBalanceRowEditor: hold onChange in a ref (synced in effect,
  not during render) so an unstable callback can't cause a render loop

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:13:00 +02:00
Mattsson 241959513b Fix/mcp and req (#753)
* feat(api): test-mode API keys force dry-run on the v1 REST API

A key created with mode='test' (prefix gnubok_sk_test_) binds to the real
company, but the v1 wrapper forces dry_run on every write so nothing is
persisted or sent. Mutations on endpoints that can't be simulated
(dryRunSupported=false or unregistered) are refused with 403
TEST_KEY_WRITE_BLOCKED — fail-closed. Reads pass through unchanged and every
test-key response carries X-Gnubok-Mode: test. Live keys are unaffected
(mode defaults to 'live').

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

* feat(invoices): company default "Vår referens" + per-line sales-account override

Add company_settings.default_our_reference (settings form, schema, type); the
invoice editor pre-fills our_reference from it on new invoices only, never
overwriting an edited draft. Separately, add an optional per-line
försäljningskonto (class-3) override in the editor — left blank, the engine
still derives the revenue account from the VAT rate, and reverse-charge/export
lines ignore the override.

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

* feat(invoices): render a Swish payment QR on invoice PDFs

Build the Swish "Type C" QR payload offline (no Swish API call) and embed it as
a PNG in the invoice PDF payment box when Swish display is enabled, the invoice
is in SEK, and the amount is positive. Also surface the invoice number in the
payment box. Wired through every PDF render path: send, mark-sent and pdf
routes (both legacy and v1), the recurring-schedule sender, and the staged-send
commit.

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

* feat(bookkeeping): draft exclusion + correction-chain collapse on verifikationslista

Extend list_fiscal_period_entries_with_related with two opt-in params:
p_exclude_draft (keep drafts off the committed list — they get their own
surface) and p_collapse_corrections (render a correction group as the single
live correction, hiding the mechanical storno and the reversed original).
Both default false; nothing is deleted, every voucher keeps its number, and a
"show all" toggle exposes the full chain.

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

* fix(reports): link multi-year SIE periods so resultatrapport shows the prior year

SIE import now sets fiscal_periods.previous_period_id in both directions when
creating a period, so multi-year files chain correctly regardless of #RAR order.
A backfill migration repairs periods imported before this (idempotent; only
touches NULL links on first-of-month periods). generateResultatrapport falls
back to the date-adjacent prior period when the chain is still null, so the
comparison column works for legacy data too.

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

* fix(articles): hide the VAT field for non-momsregistrerade companies

The article form reads company_settings.vat_registered and, when false, hides
the moms field and forces vat_rate to 0 on submit — mirroring the invoice
editor so a non-VAT-registered company never sets a rate it can't charge.

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

* feat(import): allow file-based imports in the sandbox

Bank-file, CSV/Excel and SIE imports run entirely on uploaded data with no
external service, so they're now reachable in the sandbox. Only the API-backed
options that need live third-party credentials (PSD2 bank connection, provider
migration) stay disabled. Updates the sandbox notice copy to match.

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

* feat(bookkeeping): add edit draft functionality for journal entries

* feat(database): add default "Vår referens" column to company_settings for invoicing

* fix(tests): set SHOW_SWISH_ON_INVOICE to false in PDF template mocks

* @
fix(payments): use roundOre for Swish amount formatting

Replace naive Math.round(x*100)/100 with roundOre from @/lib/money to
satisfy the antipattern guard.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:49:33 +02:00
Mattsson 8322830f46 Add/issue in absurdum (#739)
* feat(assets): allow editing fixed asset fields before depreciation

The fixed asset register only offered a "Dispose" action, so correcting a
mis-entered acquisition date/cost/category meant running the disposal flow —
which posts a real divestment voucher plus a Ch. 8a VAT adjustment.
Disproportionate and wrong for a data-entry fix.

Add an Edit action that allows correcting those fields directly, gated for
correctness:

- service: extend updateAsset() with category/acquisition_date/
  acquisition_cost; block the change once the asset is disposed or has posted
  depreciation (AssetCorrectionBlockedError) where it would desync posted
  vouchers from the register; realign the BAS triple on category change.
  Name, useful life, and method stay editable.
- api: extend the PATCH schema; annotate GET /api/assets with
  has_posted_depreciation so the UI can lock basis fields proactively.
- ui: EditAssetDialog + pencil action; disables date/cost/category when
  depreciation has been booked, with an inline explanation.
- errors: register ASSET_CORRECTION_BLOCKED (409).
- tests: unit tests for the guard; pg test for pre-disposal editability.

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

* feat(assets): also block basis edits when depreciation was hand-posted

The correction guard only consulted depreciation_schedules, so an
avskrivning booked as a manual journal entry (no schedule row) slipped
through and a basis correction was wrongly allowed.

Add a ledger scan: any posted credit to the asset's ackumulerade-
avskrivningar account (12x9) counts as depreciation. Entries that
depreciation_schedules attributes to a *different* asset are excluded, so
a sibling's engine avskrivning on a shared 12x9 account doesn't produce a
false block. What remains is depreciation tied to this asset (engine or
manual); a basis correction is blocked there and must go through storno.

Adds two unit tests: blocks on a hand-posted credit, allows when the only
12x9 credit belongs to a sibling's engine entry.

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

* fix(invoices): allow negative unit prices for discount lines

The invoice creation form rejected negative unit prices via a frontend
superRefine check, blocking valid discount lines (e.g. "Rabatt -100").
The unit_price error was never rendered inline, so submission failed
silently. The backend schema already allows negative unit prices (see
CreateInvoiceItemSchema test), so the form was simply out of sync.

Remove the non-negative constraint; empty/NaN prices are still rejected
by the base z.number() type. Drop the now-unused validation_price_positive
translation key from both locale files.

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

* feat(invoices): allow editing draft invoices

Drafts could be saved but not edited — the only way to change a draft's
lines, customer, dates or amounts was to delete and recreate it. Add a
"Redigera" action on draft invoices that opens the invoice editor
pre-filled with the draft and saves changes in place.

A verifikat is only created when an invoice is sent (or paid, under
kontantmetoden), so every status=draft invoice is uncommitted and safe to
edit; sent/paid invoices stay immutable and still require a credit note.

- Extract buildInvoiceWriteData() with the shared validation + computation
  (VAT rules, ROT/RUT, accruals, totals, currency, item rows); POST now
  uses it too, behaviour unchanged.
- Add UpdateInvoiceSchema and PATCH /api/invoices/[id], guarded to drafts
  (status=draft, no journal entry, not self-billed); number and status are
  preserved and no invoice.created is emitted.
- Extract the invoice creator into a shared InvoiceEditor with create /
  edit modes; /invoices/new is now a thin wrapper and /invoices/[id]/edit
  is the new edit page.
- Add a "Redigera" button on draft invoice detail pages + sv/en strings.
- Tests for the builder, UpdateInvoiceSchema and the PATCH route.

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

* feat(reports): make Huvudbok findable via account/saldo search terms

Searching the command palette for natural phrases like 'saldo per konto', 'kontoutdrag', 'kontoanalys' or 'transaktioner per konto' returned nothing, so users couldn't find the general ledger. Enrich the Huvudbok entry's keywords with those synonyms, and let Saldobalans and Balansrapport match 'saldo per konto' too since they are genuinely per-account balance views.

Companion change — the clearer Huvudbok report description ('Saldo och alla transaktioner per konto') — already landed in d5f474cb.

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

* feat(settings): let users edit their personal name

Add an editable Namn field to /settings/account that updates profiles.full_name and best-effort syncs auth user_metadata. Previously the personal name was only ever set from BankID's legal name at signup with no way to correct it, so users whose tilltalsnamn isn't their first given name were greeted by the wrong name (and email/password users had no name at all).

New POST /api/user/profile route (requireAuth, RLS-scoped update) mirrors /api/user/locale. sv/en strings added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(invoices): per-invoice öresavrundning override

Add a display-only öresavrundning flag per invoice that wins over the
company-wide setting. Resolution order in getDisplayTotal: per-invoice
override -> company setting -> default-on. The stored total and the booked
verifikat keep the exact öre; only the rendered total changes.

Supplier invoices gain the same flag but resolve a null to off (they never
had rounding historically), exposed via a toggle on the new-invoice form
and a rounding row on the detail page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(transactions): warn on possible duplicate before booking

Before committing a transaction (via book or categorize), detect an
already-booked sibling with the same date and amount and return a 409
TRANSACTION_BOOK_POSSIBLE_DUPLICATE instead of silently double-booking.

The user can override with force=true, which must be bound to the reviewed
sibling via expected_duplicate_transaction_id; the candidate is re-detected
server-side, so a stale or guessed id is rejected with
TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH. Detection is fail-open on the
non-force path and fail-closed under force.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(transactions): shadow-mode scope-drift dedup counter in bank ingest

Count rows that an enforcing same-feed scope-drift rule WOULD treat as
re-imports (the IBAN-drift re-imports the external_id check misses) and
surface it as IngestResult.shadow_scope_drift_candidates. Nothing is
blocked yet -- the counter only measures how often the rule would fire so
it can be validated against real data before enforcement.

Also gitignore scripts/delete-duplicate-transactions.ts: a destructive,
hand-run cleanup tool kept out of the repo so it can't run in CI/cron or be
mistaken for a supported feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(bokslut): base bolagsskatt on post-disposition result

Bokslutsdispositioner are booked as source_type='year_end', which the
income statement excludes, so net_result alone overstates resultat före
skatt and the booked tax ignored the periodiseringsfond avsättning (too-high
tax, ÅR/INK2 mismatch).

calculateBolagsskatt now accepts resultBeforeTaxOverride. The preview builder
mirrors each proposal's P&L effect (+återföring, -avsättning, -SLP) onto the
pre-disposition result; the commit path sums the already-posted dispositions
via the new sumPostedYearEndDispositions (class 88 + 7533) since bolagsskatt
is committed last.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(settings): fiscal years manager

Add a FiscalYearsManager to the bookkeeping settings that lists fiscal
periods with their status (closed > locked > open) and creates the next
year via CreatePeriodDialog, seeded to chain forward from the latest
period end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(api): return 400 when locking a period with unbooked transactions

lockPeriod() refuses to lock a period that still has uncategorized business
transactions. Detect that message in the lock route and surface it as a
clear PERIOD_HAS_UNBOOKED_TRANSACTIONS (400) instead of a generic 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(invoices): implement isEditableInvoiceDraft utility and apply it across invoice edit routes
feat(transactions): log duplicate dismissal events in behandlingshistorik
test(invoices): add tests for isEditableInvoiceDraft function
test(transactions): enhance tests to verify behandlingshistorik logging
refactor(bokslut): update tax calculation test descriptions for clarity

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:42:37 +02:00
Mattsson db8983ba9e Add/bokslut (#718)
* feat(arcim-migration): Briox provider with SIE-over-API import

- Briox auth via account ID + application token (no app-level
  credentials); both tokens rotate on refresh and are persisted
- New sie-fetcher pulls the general ledger as SIE through the
  provider API for Fortnox, Briox and Bjorn Lunden
- Wizard stops on a failed SIE import and surfaces the real errors
  instead of proceeding to the misleading migrate-guard message
- PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED;
  new PROVIDER_TOKEN_INVALID for rejected provider credentials

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices

Defer revenue/costs per invoice line to 29xx/17xx interim accounts with
automatic monthly dissolution (nightly cron + catch-up at registration),
schedule cancellation on credit, year-end auto-detect exclusion for
already-scheduled invoices, invoice-inbox service-period extraction for
prefill, and an MCP tool to list schedules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing

Generate the annual report as iXBRL from a generated taxonomy registry
(K2 element lists, taxonomy:generate/check scripts + CI guard), expose it
via the fiscal-period API, and add the bolagsverket extension for digital
submission to eget utrymme with webhook-driven status tracking
(submissions table + pg tests, lifecycle events, year-end wizard UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): raise origin-guard test timeout to 20s

The dynamic import pulls in the full server module; the parse alone
flirts with the 5s default under full-suite parallel load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add new scripts and documentation for K2 AB taxonomy generation and validation

- Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models.
- Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle.
- Included new documentation files:
  - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx`
  - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx`
  - `taxonomi-paket-2024-09-12_rev20250312.zip`

* Add tests for bookkeeping accruals dissolution and supplier invoices

- Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios.
- Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions.
- Introduce tests for the Arcim migration provider client, ensuring token handling and error classification.
- Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings.
- Add Zod schemas for Bolagsverket response payloads to ensure proper validation.
- Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping.
- Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly.
- Introduce typed domain errors for accrual schedules to improve error handling in the service.
- Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling.

* fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments

* fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated

* feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id

* feat(bokslut): enhance compliance and financial processing features with new submission details and security measures

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:35:30 +02:00
Jakob Wennberg 5078b4e02d fix(mcp): grace window + idempotent refresh-token replay for OAuth (#710) (#714)
* fix(mcp): grace window + idempotent refresh-token replay for OAuth (#710)

OAuth refresh rotated BOTH the refresh token and the access key in one
zero-grace CAS. Claude Code's MCP OAuth client fails to persist the rotated
refresh token (or fires concurrent refreshes), re-presents the stale one, the
CAS matches 0 rows, and the grant dies with invalid_grant — forcing a full
re-authorization roughly every 60s in a loop. Regression from #392.

Keep rotation (RFC 9700 §4.14.2 requires it for public clients) but add a
bounded grace window with idempotent replay, atomic in one SECURITY DEFINER RPC:

- Migration adds previous_key_hash / previous_refresh_token_hash (+ *_expires_at)
  shadow columns. validate_and_increment_api_key accepts the current OR an
  unexpired previous key_hash, with the rate-limit increment keyed off the
  resolved row id.
- New rotate_mcp_refresh_token RPC: rotated | replayed | reuse_revoked |
  revoked | invalid. In-grace replay re-issues a fresh pair and slides the
  window so an actively-refreshing client that cannot persist the rotated token
  keeps working; reuse after the window revokes the grant family (RFC 9700
  4.14.2 reuse detection preserved).
- The refresh grant now calls the one RPC, closing the old SELECT-then-CAS
  TOCTOU gap.

All previous_* columns default NULL, so existing keys are unaffected and the
RPC return shape is unchanged (callers untouched).

Tests: rewired the token-route unit tests to the RPC and replaced the test that
codified the bug with a #710 regression (in-grace replay returns 200, not 400);
added tests/pg/mcp-oauth-rotation-grace.pg.test.ts (grace accept/expire,
revoke-never-graced, rotate->demote, idempotent replay, reuse-after-grace->revoke).

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

* ci: retrigger checks for #714

No code change — re-running CI. The Supabase Preview check fails on a
pre-existing main-branch migration-history drift ("Remote migration versions
not found in local migrations directory"), not this PR; pg-real (full migration
replay) passes.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:05:10 +02:00
Jakob Wennberg b7f60b23f5 fix(invoices): v1 mark-paid booking-state routing + journal_entry_id backfill (#713)
invoices.journal_entry_id means "the registration verifikat that booked
this invoice at issuance" — payment flows route on it (set → clear 1510,
NULL → kontantmetoden cash entry). Two bugs in v1 mark-paid broke that:

- The pre-flight select omitted journal_entry_id, so invoiceAlreadyBooked
  always read false — a kontantmetoden company paying an already-registered
  invoice would re-recognise revenue + VAT (double-booking) and orphan the
  1510 receivable. Fixed by fetching the column for routing only; the
  response contract and invoice.paid event payload are unchanged.
- The update wrote the just-created PAYMENT/cash entry id into the column
  (wrong semantic) — once routing reads the column, a cash partial payment
  #1 would make payment #2 clear a 1510 that was never debited. Removed;
  the payment entry id still returns in the response body.

New backfill migration links the earliest posted invoice_created entry to
historical invoices (353 registered-but-unlinked rows in hosted prod),
repairs any payment-type links, and links credit_note reversal entries to
credit-note rows. Idempotent; rows with no registration entry stay NULL
(correct for kontantmetoden/unsent invoices).

Tests: 3 new unit tests lock the select projection, the already-booked→
clearing routing, and the no-write-back semantics (the supabase mock now
records call args). New pg-real suite (11 tests) runs the actual migration
SQL: earliest-wins, reversed/draft exclusion, no-overwrite, cash stays
NULL, payment-link repair, credit notes, cross-company isolation,
idempotency. insertDraftJournalEntry fixture gains optional sourceType/
sourceId/createdAt (defaults unchanged).

Hosted prod requires manual migration apply after merge (Supabase MCP).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:51:31 +02:00
Jakob Wennberg c0b006fcc1 feat(invoicing): artikelregister (product/article catalog) with per-article revenue account (#703)
* feat(invoicing): artikelregister (product/article catalog) with per-article revenue account

Add a lean, non-inventory article catalog (artikelregister) so users can define
reusable invoice-line presets (name, unit, price excl VAT, VAT rate) with an
optional per-article BAS class-3 revenue-account override.

- DB: articles table (RLS via user_company_ids(), audit + updated_at triggers,
  unique-per-company article_number), generate_article_number RPC (atomic +
  idempotent), company_settings counter, nullable invoice_items.revenue_account
  + article_id, pending_operations CHECK expansion.
- Engine: generatePerRateLines groups revenue by (vat_rate, account) —
  byte-identical with no override, balance-safe when split (last account absorbs
  the rounding remainder), reverse_charge/export still force 3308/3305.
- API: /api/articles CRUD (soft-deactivate); override validated against
  chart_of_accounts (active class-3) and frozen onto invoice lines at create.
- Propagation: override carried through send/mark-sent/credit/convert/cash and
  the staged commit paths (recurring deferred — documented inline).
- MCP: gnubok_list/create/update_article (staged, scoped, risk-tiered).
- UI: articles register (list/detail/form) + nav + bilingual i18n + invoice-line
  article picker & "Spara som artikel" quick-create.
- Tests: engine regression, route, and pg-real (RPC/RLS/triggers).

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

* fix(mcp): strip ILIKE _ wildcard from gnubok_list_articles search

Underscore is a single-character ILIKE wildcard; stripping it (alongside the
existing %,()\* set) keeps a stray char in the article search from matching
every row. Read-only + RLS-scoped, so no security impact — addresses PR #703
reviewer + compliance-swarm CC6.3 notes.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:05:37 +02:00
Jakob Wennberg 679b154ad2 feat(skatteverket): MCP wrappers for momsdeklaration + AGI filing (P0-5) (#692)
* feat(skatteverket): MCP wrappers for momsdeklaration + AGI filing (P0-5)

Expose the complete Skatteverket extension as five MCP tools so VAT
(momsdeklaration) and employer (AGI/arbetsgivardeklaration) filing can be
driven from Claude. Commit = "send for BankID signing" (returns a signing
link), never "file" — the user's signature in the browser is the irreversible
act, kept outside the tooling.

Tools (extensions/general/mcp-server/server.ts):
- gnubok_vat_declaration_validate  (compliance:read) — live POST /kontrollera
- gnubok_vat_declaration_submit    (skatteverket:write) — stages submit_vat_declaration
- gnubok_vat_declaration_status    (compliance:read) — GET /inlamnat + /beslutat
- gnubok_agi_submit                (skatteverket:write) — stages submit_agi
- gnubok_agi_status                (compliance:read) — local state + live kvittenser

Architecture:
- Core (lib/pending-operations/commit.ts) cannot import @/extensions (CI guard),
  so the two submit ops dispatch into the extension via the new
  Extension.services channel (first use): registry-resolved
  commitSubmitVatDeclaration / commitSubmitAgi run the SKV chain and return a
  shared SkvSubmitResult (lib/pending-operations/skatteverket-commit.ts).
- Recoverable failures (extension disabled, no connection, rate-limited, still
  processing) release the op back to 'pending' via SkatteverketRecoverableError
  — same contract as AccountsNotInChartError — so the user reconnects and
  re-approves the SAME op. SKV business rejections reject the op.
- No-drift: parseDeclarationRequest / loadAGIXml extracted to
  lib/declaration-prep.ts (buildMomsuppgift / buildAgiUnderlag / resolveRedovisare)
  so route, preview, and commit file identical figures. writeSkatteverketAudit
  hoisted to lib/audit.ts; read tools + executors write BFL audit rows too.
- New scope skatteverket:write (opt-in, in STAGING_SCOPES so SoD ack fires),
  4 structured error codes, sv/en strings, ApiKeysPanel row.
- Migration 20260620120000 adds submit_vat_declaration / submit_agi to the
  pending_operations.operation_type CHECK (must apply to prod post-merge).

Tests: 42 new across executors, MCP tools, declaration-prep, error-map, and the
VAT commit chain. Full suite green (5287), build clean, lint-ratchet at baseline.

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

* ci: add PR-Agent AI review (SHA-pinned, dedicated Bedrock key)

Greptile went silent after #682 (app/account-side, not repo config). Add the
open-source PR-Agent GitHub Action as a replacement, hardened for supply chain:

- Pinned to the v0.36.0 commit SHA (ffe1f89), not the movable tag — the repo
  was recently transferred to a new, unverified org (The-PR-Agent), though it's
  the genuine original pr-agent (repo id 662766482, 11.5k stars).
- Runs on a DEDICATED, minimal IAM key (bedrock:InvokeModel only) via
  PR_AGENT_AWS_* secrets — never the app's general AWS credentials.
- Only /review runs automatically; /describe and /improve are disabled so PR
  descriptions are never overwritten.

Requires three new secrets before it functions: PR_AGENT_AWS_ACCESS_KEY_ID,
PR_AGENT_AWS_SECRET_ACCESS_KEY, PR_AGENT_AWS_REGION (EU region).

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

* ci(pr-agent): handle push events + restrict push to /review

PR-Agent skips synchronize (push) events by default, so the bot ran green but
posted nothing. Enable handle_push_trigger and scope push_commands to /review.

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

* ci(pr-agent): fix pr_actions (event list, not commands) + add synchronize

pr_actions is the list of PR event actions to handle, not slash-commands.
Setting it to ["/review"] removed every real event from the allowlist, so the
bot skipped everything. Restore the default events + synchronize; command
selection stays on the auto_review/describe/improve booleans (review-only).

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

* ci(pr-agent): raise max_model_tokens to 64k for fuller diff coverage

Default ~32k input window truncated large PRs. Sonnet 4.6 has 200k context.

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

* ci(pr-agent): use Claude Opus 4.8 (Sonnet 4.6 fallback)

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

* fix(skatteverket): scope AGI status flips by salary_run_id

Bot review (swedish-compliance) caught that commitSubmitAgi flipped
agi_declarations status by (company_id, period) only. A correction run sharing
the period would have its still-valid declaration co-flipped to rejected/
pending_signature. Scope both updates by salary_run_id (in scope from params) —
more precise than the period-only route handler, which has no run id.

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

* test(pg): fix gen_random_bytes assertion for modern pgcrypto

OpenSSL-backed pgcrypto (CI Postgres image) rejects gen_random_bytes(0) with
'Length not in range' rather than returning empty bytea, so the pre-existing
'returns empty bytea' assertion fails on every pg-real run (repo-wide, not
specific to this PR). Assert the real contract — exactly n bytes for a positive
n — instead of the version-dependent 0-byte edge case.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:43:15 +02:00
Joachim Hesthammer 5aa449ad3c Lock down OAuth used codes table (#641)
* Lock down OAuth used codes table

* test(db): lock oauth_used_codes lockdown contract + reload PostgREST cache

Add a pg-real test asserting anon/authenticated are denied SELECT/INSERT on
public.oauth_used_codes while the privileged (service-role) connection can
still read it, per the project's requirement that RLS changes ship a
*.pg.test.ts. Also append NOTIFY pgrst, 'reload schema' so PostgREST picks up
the privilege change immediately (migration rule 8).

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

---------

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:47:33 +02:00
Filip 3d3f242d11 fix(db): migrations failing on a fresh supabase instance (#645)
* fix(db): add extension function wrappers (avoid shadowing gen_random_uuid)

Signed-off-by: filip <filip.harald@gmail.com>

* test: added test for extension function wrappers (uuid_generate_v4 and gen_random_bytes) in public schema, covering existence, delegation, correctness, properties, and idempotency.

Signed-off-by: filip <filip.harald@gmail.com>

---------

Signed-off-by: filip <filip.harald@gmail.com>
2026-06-08 09:41:36 +02:00
Jakob Wennberg 0bc81d4c88 feat(auth): SoD acknowledge on stage+approve keys + agent:write scope for memory tools (P0-3) (#681)
* feat(auth): SoD acknowledge on stage+approve keys + agent:write scope for memory tools

Segregation of duties on API keys is now warn + explicit acknowledgement
(not block): minting a key with any staging write scope AND
pending_operations:approve returns 409 API_KEY_SOD_CONFLICT unless the
caller re-POSTs with acknowledge_sod: true. The acknowledgement is recorded
(sod_acknowledged_at / sod_acknowledged_by) for an auditable risk acceptance
(ISO 27001:2022 A.5.3 / BFNAR 2013:2). The create UI surfaces an inline
warning and an explicit confirm dialog before submitting the ack — the
default "all scopes ticked" create routes through that path.

Also introduces the agent:write scope and maps the previously-UNMAPPED memory
tools gnubok_remember_fact / gnubok_forget_fact to it. Because unmapped tools
were callable by any key, the migration grandfathers agent:write onto every
existing non-revoked key with an explicit scope list so nothing regresses;
new keys must opt in. agent:write is deliberately excluded from the default
grants and is NOT a staging scope (no SoD conflict with approve).

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

* fix(db): enforce both-or-neither on the SoD acknowledgement pair

Review finding (Greptile P2): sod_acknowledged_at/sod_acknowledged_by were
independently nullable, so a partial write could silently pass and undermine
the auditable risk acceptance (ISO 27001 A.5.3 / SOC 2 CC6.1). Adds a
paired-NULL CHECK constraint + pg-real coverage for both partial-write
directions.

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

* docs(auth)+feat(auth): compliance-review round — self-attestation documented, ack logged, SoD boundary assumption captured

- Migration header now states explicitly that the SoD acknowledgement is a
  SELF-attestation by deliberate design (enskild firma has no second person;
  the claude.ai approval flow needs stage+approve on one credential) — the
  control objective is informed consent + audit record, not dual control.
- The acknowledge_sod=true path now emits a structured log.warn
  (api_key.sod_acknowledged with key id/prefix, conflicting scope, scopes,
  acknowledger, company) so the acceptance lands in the logging pipeline in
  addition to the sod_acknowledged_* columns (ASVS V16.1.1).
- STAGING_SCOPES carries the documented system control (BFNAR 2013:2
  systemdokumentation) for why agent:write is not a staging scope: memory
  tools write advisory agent context and cannot stage räkenskapsinformation.

Dismissed as by-design/verified: hard-block and second-approver remediations
(user decision: warn + acknowledge); scope-update gap (the [id] route only
supports DELETE — scopes are immutable post-creation); session-auth concern
(withRouteContext is cookie+MFA only; API-key auth exists only on /api/v1
and MCP).

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

* chore: re-trigger CI (Supabase Preview 502 infra hiccup)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:13:35 +02:00
Jakob Wennberg 305f469fc3 harden(db): tenant backstop — payment company-consistency triggers + write-RPC guards (P0-2) (#680)
* harden(security): payment-row company-consistency triggers (tenant backstop)

invoice_payments and supplier_invoice_payments are the only two child tables
carrying BOTH a parent FK and their own company_id. A row whose company_id
disagrees with its parent's company_id is a tenant-isolation defect that would
surface a foreign tenant's payment in this company's AR/AP ledger. RLS scopes
by company_id but never cross-checks the parent, so nothing at the DB layer
guaranteed the invariant.

- Pre-flight DO block: fail the migration loudly (listing offending ids) if any
  existing row already violates child.company_id = parent.company_id, rather
  than arm a trigger over dirty data that can never be updated again.
- enforce_payment_company_consistency(): one INVOKER trigger function
  parameterized on TG_TABLE_NAME, wired BEFORE INSERT OR UPDATE OF
  (company_id, parent_fk) on both payment tables; raises on mismatch. Matches
  the SECURITY posture of the sibling enforcement triggers in migration 017.
- pg-real coverage in tests/pg/payment-company-consistency.pg.test.ts: matching
  pair inserts ok; cross-tenant insert + cross-tenant UPDATE raise; both the
  customer and supplier side.

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

* harden(security): tenant guards on six SECURITY DEFINER write RPCs (backstop)

bulk_book_transactions, match_batch_allocate, mark_entry_as_opening_balance,
reserve_voucher_range, release_voucher_range and rotate_company_inbox are all
SECURITY DEFINER and EXECUTE-able by `authenticated`, so an authenticated user
could call them via PostgREST with ANOTHER company's p_company_id. Three already
carried an auth.uid()-based membership check and rotate_company_inbox an
owner/admin gate, but the two voucher-range RPCs had NO tenant check at all.

Adds the canonical claims-based guard (mirrors
20260615120000_link_voucher_rpcs_tenant_guard.sql lines 54-69) at the top of
each body: for anon/authenticated callers, membership of p_company_id
(public.user_company_ids()) is required else RAISE 42501; service_role and
no-claims callers (migrations, pg-harness, MCP / API-key paths whose company
scoping happens in TS) bypass BY DESIGN. Each function body is otherwise copied
verbatim from its latest definition; existing GRANTs re-applied.

pg-real coverage in tests/pg/securitydefiner_write_rpc_tenant_guards.pg.test.ts:
per RPC — userA session targeting companyB raises 42501; targeting own company
passes the guard (succeeds or yields a non-42501 domain outcome, documented
inline); a no-claims bare-pool cross-tenant call bypasses the new guard,
proving the service-role / MCP paths are unaffected.

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

* chore(db): renumber tenant-backstop migrations to 20260619130000/130100

PR1 (agent attribution) claimed the 20260619120000 version slot in the same
batch; Supabase migration versions must be unique across the repo, so the
tenant-backstop pair moves to 130000/130100. Filename-only change plus the
matching doc-comment references in the two pg tests.

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

* docs(db): restore source comments dropped in copied RPC bodies

The guarded redefinitions of bulk_book_transactions and match_batch_allocate
must be byte-verbatim copies of their latest sources (modulo the inserted
tenant-guard block) so the next CREATE OR REPLACE copy keeps full provenance.
Restores the Round-2/Round-3 compliance-fix annotations that were lost in the
copy. Verified mechanically: zero residual diff vs sources after stripping the
guard block, for all six functions.

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

* fix(db): drop raise-guards from bulk_book/match_batch — they break the jsonb error contract

Local full-migration replay + pg-real run surfaced that prepending the
42501 raise-guard to bulk_book_transactions and match_batch_allocate
changes their error contract for authenticated cross-tenant callers: both
already enforce membership in-function and return structured domain errors
(BULK_BOOK_UNAUTHORIZED / BATCH_UNAUTHORIZED) that routes, MCP tools, and
their existing pg tests branch on. The guard added no isolation (they were
tenant-safe) but broke that contract. The migration now guards only the
four RPCs where it is sound: mark_entry_as_opening_balance (P0001→42501,
still an exception), rotate_company_inbox (already 42501), and the two
genuinely unguarded voucher-range RPCs.

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

* fix(db): compliance-review round — log hygiene, explicit INVOKER, anon revoke, UPDATE-path test

Addresses the compliance-swarm findings on this PR:
- Pre-flight dirty-data check now raises with COUNTS only; the row ids move
  to RAISE NOTICE so error pipelines do not ingest identifier dumps
  (ASVS V8.2.1 / SOC 2 CC6.1).
- enforce_payment_company_consistency() declares SECURITY INVOKER explicitly
  — the default was already INVOKER; this makes the security model
  self-documenting.
- REVOKE ... FROM PUBLIC, anon on reserve/release_voucher_range and
  rotate_company_inbox, matching the mark_entry_as_opening_balance pattern.
- Adds the missing supplier_invoice_payments UPDATE-path trigger probe
  (SOC 2 PI1.3).

Dismissed as by-design: the JWT-claim trust boundary (set_config requires
direct SQL access, which already bypasses by design — same model as
20260615120000).

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

* feat(db): voucher-range compliance guards + FK-rerouting trigger probes

Review round 2 on this PR:

Swedish compliance review (both pre-existing function behaviour, hardened
while the PR owns these bodies):
- reserve/release_voucher_range now refuse closed/locked fiscal periods
  (BFL 5 kap 5§ — the sequence of a locked period is räkenskapsinformation;
  mirrors mark_entry_as_opening_balance).
- release_voucher_range asserts no verifikat exist in the released range
  before rolling last_number back (BFL 5 kap 6-7§ — never re-issue or orphan
  posted verifikationsnummer). Neither guard can fire in the legit SIE-import
  flow, which only releases numbers above its highest inserted verifikat into
  an open period — and the import caller treats a failed release as non-fatal.

Greptile P2: the UPDATE OF <parent_fk> trigger leg was never probed — added
cross-tenant FK-rerouting rejection tests for both payment tables (the
supplier company_id UPDATE probe landed in the previous commit).

Verified: full migration replay on fresh supabase/postgres + 333/333 pg-real
green on an origin/main merge (incl. merged #678).

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

* fix(test): release-succeeds probe must persist — callBare rolls back

The legit-path release test asserted last_number after calling the RPC via
callBare, whose BEGIN...ROLLBACK wrapper undoes the UPDATE before the
assertion reads it (caught in CI; the local pre-push replay had validated the
branch's committed state, not the then-uncommitted test). Call the RPC
directly on the pool, like the engine pg tests do for persisting calls.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:42:41 +02:00
Jakob Wennberg bc61862e76 feat(agent): telemetry + CI-gate quick wins from the "AI systems that ship" audit (#677)
* feat(agent): telemetry completeness + durability, CI gates, commit_method provenance

Quick wins from the "Building AI systems that ship" audit:

- mcp.tool_called gains errorMessage (message_sv, truncated 500 chars) on
  all failure exits; new mcp.skill_loaded event on every gnubok_load_skill
  (all tiers) so atom usage is finally measurable
- event_log: (event_type, created_at) index; cleanup cron keeps
  mcp.*/agent.* telemetry 180 days (delivery events stay 30)
- CI: lint ratchet (npm run check:lint — 60 legacy errors baselined,
  fails only on NEW errors) and a pg-real coverage gate (migrations
  touching trigger/RPC/RLS/DEFERRABLE require a *.pg.test.ts change;
  escape hatch: -- pg-test: covered-by/skip)
- journal_entries.commit_method CHECK widened with 'api_key'/'agent';
  the MCP approve path records 'api_key' truthfully instead of
  'user_accept' (agent_first_vision §8 P0-1). 'agent' is reserved — ALL
  MCP traffic (incl. claude.ai OAuth, whose access_token is a minted
  API key) authenticates as api_key today

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

* feat(import): derive opening balances from prior-year #UB when SIE lacks #IB (#675)

SIE files exported without #IB 0 rows (only #UB -1) previously imported
with zero opening balances. getEffectiveOpeningBalances() now derives IB
from prior-year UB for balance-sheet accounts when explicit #IB is
absent, surfaces the derivation as an info issue in the import preview,
and excludes share-capital vouchers from opening-balance detection.
Detection regexes are shared between parser and importer so the two
checks cannot drift. 507 lib/import tests pass.

(Authored in a parallel session in this checkout; included per request.)

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

* fix(review): address PR #677 bot findings — RoPA entry, execFileSync, gate scope note

Triage of the compliance-swarm + Greptile findings:

Applied:
- .compliance/ropa.yaml: new mcp.telemetry processing activity declaring
  the 180-day mcp.*/agent.* retention, lawful basis, data categories, and
  the no-args/no-results minimisation (ISO A.8.10, GDPR Art.5(1)(c) —
  the retention split is now formally documented, referenced from the cron)
- check-pg-test-coverage.mjs: execFileSync with argv array — no shell, so
  a hostile base-ref can't inject (ASVS V13.2.1); verified an injection
  attempt exits 2 without executing
- check-pg-test-coverage.mjs: documented the PR-level (not per-migration)
  scope of the gate so reviewers know to check coverage per migration when
  a PR carries several risky migrations (Greptile P2)

Acknowledged, no change:
- errorMessage PII risk: messages are domain-mapped strings; event_log
  already persists far richer delivery payloads under the same RLS; now
  declared in ropa.yaml
- cron error envelope: errorResponse maps to the canonical safe envelope
  and the endpoint is CRON_SECRET-gated
- two-pass delete "partial state": TTL deletes are idempotent — the next
  daily run sweeps whatever a failed pass left behind
- skill_loaded actorLabel/sessionId: mirrors the pre-existing
  mcp.tool_called payload; sessionId is the join key the analytics exist for

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:47:13 +02:00
Jakob Wennberg f538401988 Invoice correctness bundle: voucher-link race, agent send guards, payment-reversal restore (audit C2/C17, F-2026080) (#666)
* fix(invoices): atomic link_invoice_to_voucher RPC — close the customer voucher-link race (audit C2)

linkInvoiceToVoucher() did UPDATE-then-INSERT with a manual rollback restoring a STALE pre-link snapshot: under concurrent linking on the same invoice, A's failed insert could overwrite B's successful link while B's payment row remained — corrupting paid_amount/AR. Mirrors the supplier-side link_supplier_invoice_to_voucher fix (PR #602).

- New SECURITY DEFINER RPC locks the invoice FOR UPDATE, re-validates (status, posted voucher, 151x AR credit, currency, overshoot, already-linked) and applies UPDATE + INSERT in one PG transaction. Inherits the supplier RPC's remaining-amount fix (trust stored remaining_amount even at 0 — the TS '> 0' guard let rounding drift slip past FULLY_PAID). Hardened per audit A5: REVOKE from PUBLIC/anon, GRANT to authenticated + service_role.
- linkInvoiceToVoucher() now delegates to the RPC — same signature, same LINK_VOUCHER_* codes, so all callers (route, pending-op executor, MCP) are unchanged. Keeps the invoice.paid event (now emitted with the post-link row, mirroring the supplier wrapper) and the best-effort bank auto-reconcile.
- pg-real tests: full/partial link, overshoot leaves the invoice untouched, ALREADY_LINKED, and the race regression (two concurrent full links -> exactly one wins, paid_amount never exceeds total, exactly one payment row). Verified locally against supabase/postgres:15.8.1.060 with all 334 migrations replayed: 10/10 pass. Two unrelated pg tests fail locally with AND without this change (pre-existing env sensitivity; green in CI).
- Unit tests re-mocked to the RPC-wrapper contract.

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

* fix(invoices): agent send path — block cancelled invoices + preflight PDF render (audit C17)

commitSendInvoice (the agent/MCP path) was missing two guards the send route has:

- No cancelled guard: a cancelled invoice passed the already-sent check, got re-rendered and EMAILED (a 'MAKULERAD' PDF delivered as if live), and the unguarded status flip silently re-activated it to 'sent'. Now rejected with the registry's INVOICE_SEND_CANCELLED message (400), mirroring the route.
- No preflight render: the executor assigned the F-series number BEFORE rendering, so a render failure left a numbered-but-never-issued invoice (an F-series gap if the draft is abandoned). Now mirrors the route: on fresh allocation, render with an 'F-PREVIEW' placeholder first and reject with INVOICE_SEND_PDF_RENDER_FAILED before any number is consumed; retries with an existing number skip the preflight.

Items/credit-note lookup moved above the preflight (it needs them); the real render and everything downstream are unchanged.

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

* fix(bookkeeping): payment reversal restores invoice state and releases bank line (F-2026080)

Reversing a payment voucher left the customer invoice deadlocked: status
stayed 'paid' while remaining_amount stayed stale (= total), and the bank
transaction kept pointing at the reversed JE so the line could neither be
re-matched nor deleted.

- Customer branch now recomputes remaining_amount from total (the supplier
  branch already did) and clamps paid_amount at 0.
- Both branches delete the payment row(s) tied to the reversed voucher so a
  re-match doesn't double-count or trip the unique indexes.
- New releaseLinkedTransactions() detaches bank transactions from the
  reversed JE (by journal_entry_id and by captured payment transaction ids),
  clearing the link/categorization columns so the line returns to the inbox.

Covers every standalone storno path (reverse route, MCP reverse tool,
delete-last-voucher); the match-invoice route already handled its own case.

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

* fix(transactions): match-invoice preview double-subtracted VAT on per-item path (F-2026080)

InvoiceItem.line_total is the NET line amount (it sums to invoice.subtotal,
each line's vat_amount = line_total * rate), but the preview's per-item rate
aggregation computed sub = line_total - vat_amount, double-subtracting VAT
and producing an unbalanced previewed verifikat (revenue credit too low
against the 1930 debit). The commit path (generatePerRateLines) was already
correct; only the preview disagreed.

Regression test mirrors the F-2026080 invoice: multi-item 25% SEK cash entry
must balance, with 3001 = subtotal and 2611 = vat_amount.

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

* fix(bookkeeping): address PR #666 review — supplier cash reversal, RPC tenant guard, CI fixes

Review feedback fixes:

- Supplier cash-payment reversal (Greptile): the supplier branch required a
  payment row before restoring status/amounts, so reversing a
  supplier_invoice_cash_payment (which books no payment row) left the invoice
  deadlocked at paid/remaining=0 — the same bug the customer branch fixed.
  Mirror the customer fallback (revert full paid_amount when no row exists).

- Payment-row lookups now filter by invoice id + company_id: a batch voucher
  (match_batch_allocate) carries one payment row per invoice under the same
  journal_entry_id, so the unfiltered .single() errored out and silently
  yielded null.

- Tenant guard on the voucher-link write RPCs (compliance V8.2.1, audit A5):
  link_invoice_to_voucher and link_supplier_invoice_to_voucher are SECURITY
  DEFINER + authenticated-executable, so any signed-in user could mutate
  another tenant's invoices via PostgREST. New migration applies the PR #625
  claims-based membership guard to both, caps p_notes at the Zod layer's
  2000 chars, and gives the supplier RPC the explicit REVOKE/GRANT it never
  had (was default PUBLIC execute). Covered by a new pg-real test.

- releaseLinkedTransactions now logs Supabase errors (compliance V16.1) —
  a failed release leaves a bank line stuck on a reversed JE and must be
  observable.

CI fixes:

- naive-ore-round ratchet (core-only): payment-sync.ts converted to
  roundOre() from @/lib/money (-4 occurrences vs baseline).
- match-batch-allocate.pg.test.ts flake (pg-real): Date.now()+random arrival
  numbers collided in CI; now time-component + monotonic counter.

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

* fix(bookkeeping): address PR #666 review round 2 — payment attribution, batch-scoped deletes, send guard

- RPC payment attribution (GDPR Art.32): user-session callers can no longer
  attribute invoice_payments / supplier_invoice_payments rows to an arbitrary
  user via p_user_id — the JWT sub is authoritative when role is
  anon/authenticated. service_role / direct callers keep p_user_id verbatim
  (their scoping happens in TS). pg-real test asserts the spoofed id is
  ignored.

- Payment-row deletes scoped to the source invoice (SOC 2 CC6.3): a batch
  voucher carries sibling payment rows for other invoices whose status this
  sync doesn't restore; deleting them desynced paid_amount from the rows.

- releaseLinkedTransactions success audit log: transactions has no
  write_audit_log trigger, so clearing the link/categorization columns now
  logs the affected transaction ids for incident reconstruction.

- commitSendInvoice guard extended with partially_paid/credited (ASVS V2.3):
  both imply the invoice was already issued; the status flip would have
  regressed them to 'sent'.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:32:39 +02:00
Mattsson 3e42fc6f32 Feat/voucher docs (#664)
* feat: implement inbox document picker and linking functionality

* feat: implement self-billing invoice functionality

- Added support for registering self-billed invoices received from customers.
- Updated the invoice schema to include fields for self-billing metadata such as `is_self_billed`, `external_invoice_number`, `self_billing_agreement_ref`, and `received_date`.
- Created API route for handling self-billed invoice submissions, including validation and error handling.
- Implemented database migrations to add necessary columns and constraints for self-billing invoices.
- Developed tests to ensure correct behavior of self-billing invoice creation and validation rules.
- Updated Swedish localization files to include new terms related to self-billing.

* feat: enforce SIE import requirement for non-Fortnox providers in migration process

* feat: streamline invoice processing and enhance error logging across APIs
2026-06-04 13:14:57 +02:00
Jakob Wennberg c74b19df1b Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)
* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

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

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:01 +02:00
Jakob Wennberg 358c25094d fix(security): tenant guard on the GL-line read RPCs (#625)
* fix(security): tenant guard on the GL-line read RPCs (PR #624 follow-up)

get_unlinked_gl_lines and get_account_gl_lines_for_matching are SECURITY DEFINER
and EXECUTE-able by anon/authenticated, so any authenticated (or anonymous)
caller could invoke them directly over /rest/v1/rpc with another company's id and
read its general-ledger lines — a cross-tenant read that bypasses the API routes'
requireCompanyId() guard. Confirmed against the DB: anon and authenticated both
hold EXECUTE, and SECURITY DEFINER sidesteps RLS.

Add an in-function guard constraining anon/authenticated callers to their own
companies (the same boundary user_company_ids()/RLS enforces). Trusted callers
are untouched — service_role (the enable-banking reconciliation cron) and direct
/ superuser access (migrations, the pg-real harness) are not anon/authenticated,
so the predicate is a no-op and behaviour is unchanged. A foreign company id now
yields zero rows, not data.

Scope: hardens the two READ RPCs that expose ledger data. The remaining
company-scoped SECURITY DEFINER RPCs are writes / sequence generators with their
own internal authorization; a broader audit of that set is tracked separately.

pg-real coverage: a company-B member probing company A gets zero rows from both
RPCs, while a company-A member and direct/superuser access still see the data.

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

* fix(security): revoke EXECUTE from PUBLIC/anon on the GL-line read RPCs

Defense-in-depth follow-up to the tenant guard. The guard already returns zero
rows to an anon/authenticated caller probing another company; this additionally
strips the EXECUTE privilege so an unauthenticated (anon) caller cannot invoke
the financial-ledger RPCs at all. Supabase grants EXECUTE to PUBLIC as well as to
anon, and anon is a member of PUBLIC — revoking only anon is insufficient, so
revoke both, then keep the two legitimate callers: authenticated (the API routes
call via the user's session; the in-function guard scopes them to their own
companies) and service_role (the enable-banking reconciliation cron).

Verified on the DB: anon EXECUTE = false, authenticated/service_role = true.
Adds an anon-role pg test asserting the call is rejected at the privilege layer.

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

* fix(security): read JWT role from claims object in the GL-line RPC guard

The 20260611120000 guard used auth.role() to detect the caller's role, but
auth.role() reads the individual request.jwt.claim.role GUC first and only some
installs fall back to the claims object. PostgREST sets the claims OBJECT (the
individual claim.* GUCs are deprecated), and the pg-real harness sets
request.jwt.claims (+ claim.sub for auth.uid()) but NOT claim.role — so on an
auth.role() without the object fallback it returns NULL and the guard's
NOT IN ('anon','authenticated') branch was TRUE, skipping the membership check.
A pg-real test caught it: an authenticated non-member could still read another
company's GL lines (the guard failed open in that environment).

Read the role straight from request.jwt.claims (exactly what auth.role() itself
falls back to), so the guard enforces in every environment regardless of which
JWT-claim GUCs are populated. Verified on the DB: an authenticated non-member
evaluates both guard branches false → row excluded.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:24:18 +02:00
Jakob Wennberg 094bd85e81 fix(reconciliation): secondary-account scoping, dialog clipping, and N:1 matching (#624)
* fix(reconciliation): secondary-account scoping, dialog clipping, and N:1 matching

Three follow-ups to per-account bank reconciliation (PR #623):

- Secondary same-currency accounts (e.g. a 1931 savings account) double-counted
  the company's unassigned (NULL cash_account_id) transactions, inflating their
  bank total and showing a large bogus difference while 1930 still reconciled.
  Only the primary cash account now claims NULL rows; every other account scopes
  strictly to its own id. `includeUnassigned` is threaded through all
  status/run/list call sites from cash_accounts.is_primary.

- The "Matcha mot befintlig verifikation" picker's dropdown was absolutely
  positioned inside the dialog's overflow-y-auto container and got clipped. Add
  an `inline` mode that renders the candidate list in normal flow; the dialog
  uses it, the reconciliation view keeps the compact overlay.

- N:1 matching: several bank transactions can now settle one verifikat (a salary
  run paid in multiple transfers, an invoice paid in instalments). New
  get_account_gl_lines_for_matching RPC surfaces already-matched vouchers with a
  linked_transaction_count behind a "Visa även matchade verifikationer" toggle;
  manualLink's 1:1 guard is relaxed (the aggregate difference still catches
  mis-links).

Tests: extended bank-reconciliation unit tests (strict scope + N:1), rewrote the
cash_account_id isolation pg test to prove NULL rows land on the primary account
only, and added a pg test for the new RPC.

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

* fix(reconciliation): address PR review — accurate "att matcha mot" count

- BankReconciliationView: the "N verifikationer att matcha mot" hint counted
  glLines (which includes already-matched vouchers when "Visa matchade" is on),
  overcounting the vouchers that still need a transaction. Use unmatchedGlLines
  so the label is correct regardless of the toggle (matches the table below).
- MatchVerifikationPicker: document that `open` is overlay-only; the setOpen()
  writes are intentional no-ops in inline mode.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:37:30 +02:00
Jakob Wennberg b5c3c3ec04 fix(reconciliation): surface bank tx + match to existing verifikat, delete UX, DPA links (#623)
* fix(reconciliation): surface bank transactions via robust cash_account_id scoping

The per-account reconciliation scoping silently returned zero transactions for
companies whose rows were NULL or mis-assigned mid-backfill (e.g. Arcim: 138
transactions, 101 unbooked, yet Bankavstämning showed "0 kr" while the 1930 GL
movement and a large difference still displayed). Two causes, both fixed:

- scopeTransactionsToAccount used a fragile nested or(...,and(is.null,...))
  PostgREST filter. Replace it with a flat, reliable
  `currency = X AND (cash_account_id = id OR cash_account_id IS NULL)` and share
  the one implementation with /api/transactions so the status card and the lists
  can never drift.
- The original best-effort backfill only touched NULL rows and an earlier
  revision mis-assigned cash_account_id (the since-fixed min(uuid) bug), which
  migrations cannot self-correct. Add an idempotent repair migration that
  re-seeds the default 1930 account and re-derives cash_account_id (correcting
  non-NULL mis-assignments) for booked rows and single-account companies.

Also localise manualLink's user-facing errors to Swedish. Adds unit coverage for
the new filter shape and pg-real coverage for the repair (incl. the Arcim
single-account reproduction).

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

* feat(transactions): match a bank transaction to an existing verifikat

Adds a "Matcha mot befintlig verifikation" action to the Transactions inbox so a
bank line that is already booked elsewhere (a salary run, a Fortnox/manual
voucher, an invoice paid from the invoice page) can be linked to that existing
verifikat with no new bokföring — the capability previously lived only in
Reports → Bankavstämning.

- Extract the searchable MatchVerifikationPicker into a shared client component.
- New MatchVoucherDialog: resolves the tx's cash account, fetches candidates
  ranked server-side by reconciliation confidence, links via
  /api/reconciliation/bank/link (so the link is undoable in Bankavstämning).
- unmatched-entries route gains an optional transaction_id that ranks candidates
  (ranking stays server-side; the recon lib is not client-safe).
- Inbox row's overflow (⋯) menu gains the new action.

Also fixes the Bankavstämning view: editing the date no longer auto-reloads
(applies on Filtrera / account change only) and Datum till defaults to today.

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

* fix(transactions): actionable Swedish errors when a bank tx cannot be deleted

The delete route returned a hardcoded English 409 that getErrorMessage mapped to
the misleading generic "En konflikt uppstod. Ladda om sidan...". Return
structured bilingual envelopes instead:

- TRANSACTION_DELETE_BOOKED (409) for a booked/linked row — steers the user to
  unlink in Bankavstämning or storna the voucher.
- TRANSACTION_DELETE_HAS_AUDIT_TRAIL (409) for the real, common case where an
  unbooked row carries payment_match_log rows: the cascade hits the
  audit-immutability trigger (P0001), previously surfaced as a bare 500. Steers
  the user to match-to-voucher or ignore instead.

Updates the DELETE test suite and adds the P0001 case.

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

* feat(settings): surface DPA and privacy policy links

The /dpa page (personuppgiftsbiträdesavtal, GDPR Art. 28) was complete but
linked from nowhere. Add a "Sekretess och avtal" card on Inställningar → Konto
linking to /privacy and /dpa, and a reciprocal link to the DPA from the privacy
policy's sub-processor section. (The DPA already links back to /privacy.)

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

* fix(review): address PR #623 feedback

- unmatched-entries: when transaction_id is supplied but resolves to no row in
  the caller's company, return an empty candidate list instead of silently
  falling back to the full unranked list (Compliance Swarm V8.2.1, high).
- MatchVoucherDialog: preserve a manually-picked voucher when the candidate
  list reloads (e.g. "Visa alla datum") instead of discarding it (Greptile P2).
- DELETE /api/transactions/[id]: return the 404 as the structured
  { error: { code, message, message_en } } envelope like the handler's other
  errors, for a uniform contract (Greptile P2). Test updated.

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

* feat(bookkeeping): allow editing notes on a committed journal entry

Saving a note on a posted verifikation failed with "Committed entries are
immutable": enforce_journal_entry_immutability() had no posted→posted path, so a
notes-only UPDATE fell through to the final RAISE. `notes` is internal annotation
metadata (not verifikation content under BFL 5 kap. / BFNAR 2013:2), so add a
narrow carve-out that permits a notes-only change on a committed entry — verified
with a whole-row to_jsonb() diff so any other field change still raises, and only
when status is unchanged. Period-lock enforcement is unaffected.

CREATE OR REPLACE in a new migration (same pattern as
20260428160000_fix_journal_entry_immutability_delete_bypass); the migration-017
protections are extended, never weakened. Covered by a pg-real test asserting a
notes edit succeeds while amount/description/account edits still fail.

(Already applied to production; committing the file + test for repo consistency.)

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

* fix(sandbox): make pre-staged pending operations executor-complete

The seeded pending_operations existed only as display previews — approving them
failed because commit executors in lib/pending-operations/commit.ts validate
required fields on "Godkänn". Seed a backing invoice_inbox_items row and fill the
supplier-invoice and categorize params with every field the executors require
(inbox_item_id, full items array; real uncategorized transaction_id + category),
so the sandbox approval queue is actually approvable end to end.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:08:06 +02:00
Jakob Wennberg 953980c875 Per-account bank reconciliation + overdue/inbox/privacy fixes (#619)
* feat(reconciliation): scope bank reconciliation per cash account via transactions.cash_account_id

A company with two same-currency cash accounts (e.g. checking 1930 + a
savings account) saw every SEK transaction on every account, and the
status card summed across both — reconciliation filtered transactions by
CURRENCY while filtering GL lines by ACCOUNT (issue #604).

Bind each bank transaction to the cash_accounts row it settled on:

- New nullable transactions.cash_account_id FK (ON DELETE SET NULL —
  a bank transaction is räkenskapsinformation, BFL 7 kap, and must
  survive cash-account deletion) + a best-effort 4-pass backfill.
- All reconciliation/transaction queries scope to the selected account
  with a NULL->currency fallback, so legacy/un-backfilled rows never
  disappear mid-backfill.
- ingestTransactions stamps cash_account_id from the batch's
  settlementAccount; categorize + manualLink resolve and use it.
- Bank leg now books to the transaction's actual settlement account via
  applySettlementAccount (no-op for 1930), so interest/fees on a
  savings/EUR account reconcile instead of mis-booking to 1930.
- manualLink cross-checks the transaction's account and requires a
  voucher line on the selected account (no silent cross-account links).
- BankReconciliationView: quick-book menu for any settlement account,
  in-flight request abort on account/date switch, 500-row truncation
  notice, per-account state reset.
- pg-real coverage for the FK, all backfill passes, account-scoped
  query isolation, and cross-company isolation.

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

* fix(supplier-invoices): stop marking paid invoices and credit notes as overdue

update_overdue_supplier_invoices() (the daily pg_cron job) flipped every
past-due 'registered'/'approved' row to 'overdue' without looking at the
outstanding balance. Credit notes — created 'registered', remaining 0,
due today — got flipped the next day, surfacing as "Förfallen" with
"kvar att betala 0 kr"; so did any fully-paid invoice left in
'registered'/'approved'.

Guard the cron on remaining_amount > 0.005 (the "fully paid" threshold
used by the payment/match paths) and is_credit_note = false, and backfill
the rows already mis-flagged (credit notes -> 'registered', paid ->
'paid' with paid_at stamped only when missing). pg-real coverage for the
guarded function and the one-off backfill.

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

* fix(invoice-inbox): refresh dokumentinkorg on realtime row changes

The InvoiceInboxWorkspace only refetched on mount and on explicit
in-component actions. When an inbox item was resolved out of band — the
in-app agent sheet committing a staged create_supplier_invoice_from_inbox
/ book-direct op, the /pending page approving one, or another tab booking
it — none of those paths called fetchItems(), so the booked underlag
stayed in "Att göra" until a manual reload (issue #600).

Add invoice_inbox_items to the supabase_realtime publication (mirrors the
/pending fix in 20260520120100) and subscribe in the workspace, refetching
the whole list on any change so derived status/counts/ordering stay
authoritative. RLS scopes the channel to the user's company. fetchItems
now preserves optimistic upload placeholders so a refetch firing
mid-upload can't drop an in-flight row.

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

* docs(privacy): disclose EU AI inference via Amazon Bedrock (eu-north-1)

Update the privacy policy and DPA to state that AI inference, when AI
features are enabled, runs inside the EU via Amazon Bedrock (eu-north-1,
Stockholm) using Anthropic's Claude models — no transfer to a third
country, prompts not retained after the call or used for model training.
Add AWS as a subprocessor row and refresh the "last updated" dates.

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

* fix(migrations): rename invoice_inbox_realtime to avoid version collision

main's #617 shipped 20260605120000_transactions_original_description.sql —
the same version this branch used for the inbox-realtime publication. The
Supabase migration tracker keys on the numeric version, not the filename, so
the preview branch failed with a duplicate-key error on
supabase_migrations.schema_migrations (version 20260605120000 already
exists). Rename to the unique version 20260605120500; the body
(ALTER PUBLICATION) is order-independent.

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

* fix(reconciliation): align run guard with status; harden filter interpolation

Addresses PR review (greptile + compliance swarm):

- The v1 and core bank/run routes rejected an unknown account uniformly,
  including the default '1930', while the status routes were lenient for
  '1930'. A company reconciling its primary SEK account without a
  cash_accounts row got 200 from status but 400 from run. Make run match
  status: '1930' falls back to currency-only scoping (cashAccountId
  undefined); non-default unknown accounts are still rejected. Adds a test.
- /api/transactions accepts a user-supplied `currency` query param that was
  interpolated raw into a PostgREST .or() filter. Reject anything that isn't
  a 3-letter ISO code — RLS already scopes to the company, but an
  unsanitized value could otherwise malform/widen the filter. Assert
  currency/cashAccountId shape in scopeTransactionsToAccount as well.
- categorize: log (instead of silently swallowing) a cash_accounts
  settlement-account lookup error, so a fall-back-to-1930 mis-booking is
  observable in the audit log.

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

* fix(migrations): correct backfill UPDATE..FROM join; idempotent realtime publication

Two SQL errors that only surface on real Postgres (CI pg-real + Supabase
preview) — the unit suite mocks Supabase, so neither was caught locally.

- Backfill pass (a): `UPDATE transactions t ... FROM journal_entry_lines jel
  JOIN cash_accounts ca ON ca.company_id = t.company_id` referenced the UPDATE
  target `t` inside the FROM join's ON clause, which Postgres rejects ("invalid
  reference to FROM-clause entry for table t"). Move the company match to WHERE;
  the JOIN now relates jel<->ca only. Semantics unchanged.
- invoice_inbox_realtime: `ALTER PUBLICATION ... ADD TABLE` is not idempotent
  (SQLSTATE 42710 if the table is already a member). The earlier
  version-collision push partially applied it on the Supabase preview branch, so
  the re-apply errored. Guard with a pg_publication_tables existence check.

Both statements validated against a real Postgres: the single-line tx binds, the
two-bank-line transfer stays NULL, and the publication add runs twice cleanly.

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

* fix(migrations): backfill pass (c) uses array_agg, not min(uuid)

Postgres has no min() aggregate for uuid, so pass (c)'s min(id) raised
"function min(uuid) does not exist" on apply (CI pg-real + Supabase). The
HAVING count(*) = 1 already guarantees one row per group, so (array_agg(id))[1]
returns that single id.

Validated the full backfill (all four passes) and the overdue migration against
a real Postgres: every pass binds / falls through as intended, and the overdue
guard + backfill produce the right statuses.

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

* docs(compliance): add RoPA entry for Amazon Bedrock AI inference (GDPR Art.30)

The privacy policy now discloses AI inference (transaction categorization +
document/receipt OCR) via Amazon Bedrock as a processing activity, but
.compliance/ropa.yaml had no matching Art.30 record. Add it: opt-in consent
basis, EU-region (eu-north-1) inference with no third-country transfer, prompts
not retained or used for model training. Mirrors the privacy-page disclosure
shipped in this PR.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:26:13 +02:00
Mattsson c6c86cded4 Mcp/template data feedback (#617)
* fix(booking-templates): scope template list to the active company

GET /api/settings/booking-templates relied solely on the btl_select RLS
policy, which is membership-wide (user_company_ids) and returns templates
from every company the user belongs to. A user who owns multiple companies
saw all their templates merged regardless of which company was active.

Narrow the list in the API layer (mirroring counterparty-templates) to
system + the active company + the active company's team. RLS stays the
security backstop; this fixes the cross-company merge within a single
user's own view (it was never a cross-tenant data leak).

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

* fix(import): show proper message for duplicate bank file upload

The bank file import page mis-parsed the structured error envelope
({ error: { code, message, details } }), so a BANK_FILE_DUPLICATE
(409) fell through to the generic "Kunde inte läsa filen" fallback.
The upload step also hardcoded that same string as the error heading,
so duplicates were doubly misreported as parse failures.

- Parse the structured envelope by error.code; surface error.message
  for all codes instead of rendering the error object.
- Add a dedicated BANK_FILE_DUPLICATE message using the importedAt /
  importedCount details the route already returns.
- Add an optional errorTitle prop to BankFileUploadStep (defaults to
  the previous text) and pass "Filen är redan importerad" for dupes.

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

* feat(tests): add comprehensive tests for recordateEntry, inbox-linking, and external-id handling

- Implemented unit tests for recordateEntry in the bookkeeping module to validate various scenarios including date changes, non-posted entries, and fiscal period restrictions.
- Created tests for inbox-linking status in pending operations to ensure correct handling of invoice inbox items and supplier invoices, addressing historical bugs related to status updates.
- Added tests for external-id utilities to ensure consistent handling of monetary amounts and deduplication keys across different transaction sources.
- Introduced new functions in external-id.ts for stable external ID generation and normalization of imported descriptions, enhancing transaction deduplication reliability.

feat(migrations): add new database migrations for transaction handling

- Created migration to exclude storno and correction vouchers from unmatched GL lines, ensuring accurate reconciliation.
- Added a migration to preserve original bank transaction descriptions in a new immutable column, allowing for user edits while maintaining audit trails and deduplication integrity.

* feat(migrations): add function to exclude storno/correction vouchers from unmatched GL lines

* feat(transactions): enhance transaction handling with improved description normalization and preloaded original entries

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:45:49 +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 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