Commit Graph
5 Commits
Author SHA1 Message Date
Jakob WennbergandClaude Opus 4.7 05078c9d8e feat(bokslut): year-end wizard with bokslutsdispositioner + asset register (#508)
* feat(bokslut): year-end wizard with bokslutsdispositioner + asset register

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Swedish accounting review:

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

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

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

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

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

False positives intentionally not changed:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:55:27 +02:00
Jakob WennbergandClaude Opus 4.7 5eee06a56d fix(reconciliation): exclude opening balance from unmatched-1930 set (#443) (#485)
* fix(reconciliation): exclude opening balance from unmatched-1930 set (#443)

Reconciliation incorrectly counted IB (source_type='opening_balance')
vouchers on 1930 as unmatched bank transactions and included them in the
GL period total. Result: an SIE-imported book always showed a phantom
unmatched voucher and a difference equal to the IB amount, even when
every real bank transaction was matched. This was the root of the
"reconciliation says broken but numbers look right" complaint.

The fix is two-sided:

- get_unlinked_1930_lines RPC now skips source_type='opening_balance'
  rows. IB has no counterpart in the bank feed by definition.
- getReconciliationStatus splits glOpeningBalance out of the period
  movement and computes difference = bankTotal − glPeriodMovement
  (was bankTotal − glBalance). gl_1930_balance is preserved unchanged
  for back-compat; new gl_1930_period_movement and
  gl_1930_opening_balance fields let the UI show both perspectives.
- BankReconciliationView shows period movement vs bank in the diff and
  folds IB into a small caption ("Ingående balans … räknas inte i
  avstämningen") only when non-zero.

Mirrors Fortnox's "Ingående saldo vid avstämningsstart" pattern — IB is
verified separately, never surfaced as unmatched.

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

* fix(reconciliation): use IS DISTINCT FROM for NULL-safe source_type filter

`source_type <> 'opening_balance'` evaluates to NULL (not TRUE) when
`source_type IS NULL` under SQL three-valued logic. journal_entries.source_type
is NOT NULL today so the practical difference is zero, but if that constraint
is ever relaxed `<>` would silently drop NULL rows from the unmatched-1930 set,
making them invisible to reconciliation.

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

* fix(test): use valid source_type 'bank_transaction' in pg-real fixture

CI pg-real failed because 'bank_import' is not in the journal_entries
source_type CHECK constraint. The valid value for bank-imported transactions
is 'bank_transaction' (per migration 20260513170001 + earlier).

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

* docs(reconciliation): deprecate gl_1930_balance on ReconciliationStatus

Mark the IB-inclusive balance field as @deprecated on both the server-side
ReconciliationStatus interface and the component-side mirror. Downstream
consumers that read this field expecting it to drive the diff will be off
by the IB amount whenever a SIE-imported opening balance exists on 1930.
The new gl_1930_period_movement field is the correct basis for the diff and
is what the difference field is computed against.

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

* refactor(reconciliation): tighten review feedback on PR #485

Three tweaks from the Swedish-accounting bot review:

1. Drop the gl_1930_period_movement ?? gl_1930_balance fallback in
   BankReconciliationView. gnubok has no persisted reconciliation snapshots
   and Vercel deploys atomically, so the old-server/new-client case can't
   occur — the fallback was defensive code for an impossible state. Per
   CLAUDE.md: don't add back-compat shims when you can just change the code.
   gl_1930_period_movement and gl_1930_opening_balance are now required on
   the type.

2. Clarify in the migration comment that source_type='opening_balance' is
   reserved for the fiscal-year IB voucher (always at period_start). The
   bot questioned whether the unconditional filter could hide a mid-year
   corrective IB entry; document the invariant — mid-year corrections use
   source_type='correction' or 'manual', never 'opening_balance'.

3. Test fixture 2099 → 2091. 2099 is årets resultat (current year);
   2091 is balanserad vinst/förlust, the realistic counterpart for a
   carried-forward bank IB. RPC filters on 1930 only so this is cosmetic
   but matches what a real SIE import would produce.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:39:38 +02:00
Jakob WennbergandClaude Opus 4.7 17c67fece0 Inbox UX overhaul + cross-currency supplier-invoice fixes (#444)
* feat(kpi): expense mix and top suppliers charts

Replace the single monthly-trend chart with two additional compact visuals
on /kpi: expense composition donut (BAS class 4-7) and top suppliers bar
(supplier_invoices sum_sek over the fiscal period). KPIReport gains
expenseComposition and topSuppliers fields, computed from the trial
balance and supplier_invoices rows already fetched in the API.

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

* feat(nav): swap Deadlines sidebar slot for Dokumentinkorg

Sidebar main-menu slot now points to the invoice-inbox extension. The
/deadlines page stays accessible via dashboard widgets and direct links —
only the prominent nav entry changes. Most users open gnubok to act on
incoming documents, not to read tax deadlines.

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

* fix(supplier-invoices): cross-currency totals, FX residual, review SEK display

Five fixes around foreign-currency supplier invoices:

- Form layout: move Valuta / Växelkurs / Reverse charge from collapsed
  "Övrigt" into a visible row above the line-item table. Auto-fetch the
  Riksbanken rate when switching to a non-SEK currency; never clobber a
  user-typed rate; clear it when switching back to SEK.

- Form submit: reset() the form on successful submit so the
  useUnsavedChanges hook detaches its beforeunload listener before the
  router.push, killing the "Are you sure you want to leave?" prompt that
  fired during Turbopack-mediated navigations.

- BankTransactionPicker: drop the strict currency filter that hid every
  SEK transaction when the invoice was in EUR/USD. Cross-currency rows
  fall to the bottom with an "Annan valuta" hint instead of producing a
  meaningless numeric diff.

- match-supplier-invoice route: when the bank transaction currency
  differs from the invoice currency, compute the FX diff against the
  AP-booked SEK and pass it to createSupplierInvoicePaymentEntry so
  7960/3960 catches the residual instead of leaving a permanent stub on
  2440. Fix also covers the "EUR transaction paying a SEK invoice" case
  that the first iteration missed.

- Review dialog: buildJournalPreview now multiplies amounts by the
  exchange rate so the "Verifikation som bokförs" table shows the actual
  SEK numbers that hit the DB, not the EUR magnitudes labelled with no
  unit. Header gains an "(i SEK)" hint when foreign currency.

Test coverage for the FX residual path covers SEK-SEK (no diff),
SEK-into-EUR-invoice (loss), SEK-into-EUR-invoice (gain), foreign-tx-
into-SEK-invoice, and the no-rate fallback.

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

* feat(inbox): rate limits, multi-file UX, onboarding, retry, supplier autolink

Big workspace pass on /e/general/invoice-inbox. Highlights:

Backend
- New table inbox_rate_counters + RPC check_and_increment_inbox_quota.
  Postgres-backed (no Upstash dep) per-company limit: 30/min, 500/day.
  Applied at /upload, /inbound, and /items/:id/retry-extraction.
- POST /items/:id/retry-extraction — re-runs the deterministic extractor
  on a stored document when the previous attempt errored.
- POST /items/:id/match-supplier — links a freshly-created supplier
  back to the inbox item so the next action prefills correctly.
- POST /api/transactions/create-from-document — creates an uncategorized
  manual transaction from an inbox item for the "I have a receipt, no
  bank transaction" case. The user categorizes through the normal flow.
- /inbound caps email at 20 attachments/email; truncated count goes to
  processing_history as AttachmentsTruncated. Rate-limit drops emit
  RateLimitedDropped and return 200 so Resend doesn't retry.
- attach-document side effect: when the document came from an inbox
  item, the inbox row's matched_transaction_id is updated so the UI can
  flip it to "Kopplad till transaktion" without a round-trip.

New migration: re-introduces matched_transaction_id on
invoice_inbox_items as a plain FK (the AI metadata that the previous
migration stripped doesn't come back).

Workspace UI
- Onboarding card replaces the thin empty-state with a 3-step
  checkmark guide (Aktivera adress → Ladda upp → Matcha eller bokför).
  Auto-hides when all three steps are done; localStorage-backed dismiss.
  Beta badge + link to gnubok.se/priser.
- Responsive layout: 3-pane at lg, 2-pane at md, master-detail toggle
  on phone (list xor detail with a back button).
- Filter pills (Alla / Behöver åtgärd / Bearbetade / Fel) + search
  input above the list — client-side over the existing items list.
- Multi-file upload queue with "Laddar X av N…" progress counter on
  the button. Sequential to avoid hammering pdfjs. Selection stays put
  during a batch (only single-file drops auto-jump the detail pane).
- Bulk select + delete with sticky action bar. Items linked to a
  supplier invoice are skipped with a count toast.
- Retry button in the FieldsRail error branch.
- "Skapa transaktion från underlag" CTA in the match dialog when no
  unmatched bank transactions exist. Prefills date/amount/description
  from the extracted data; user picks the sign.
- "Skapa leverantör" inline CTA when the extractor caught a supplier
  name with no match against existing suppliers. POSTs /api/suppliers
  with the extracted fields, then auto-links via /items/:id/match-supplier.
- Matched-state CTA renamed to "Bokför transaktionen" with link to
  /transactions?highlight=<id> so the categorize panel auto-opens.

Tests
- lib/rate-limits/__tests__/inbox.test.ts — RPC wrapper happy/error/scope
- app/api/transactions/create-from-document/__tests__/route.test.ts —
  auth, validation, 404/409/200/500, inbox-link failure tolerated
- extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts —
  auth, rate limit, 404, 409, 400 no-doc, success, extraction failure
- attach-document tests extend coverage to the new inbox-link side
  effect (both success and best-effort failure paths)
- inbound-webhook test mocks the rate-limit module so the queued-mock
  sequence in each existing test doesn't have to know about it

CLAUDE.md gains a row for lib/rate-limits/ so the new helper is
discoverable.

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

* feat(transactions): paperclip indicator and highlight-row param

Close the feedback loop after a user attaches a receipt to a transaction
from the inbox: the row in /transactions now shows a paperclip icon
when transaction.document_id is set, with a click handler that fetches
a signed download URL and opens the document in a new tab. Works for
both uncategorized and history views.

When the inbox sends a user to /transactions?highlight=<id>, the page
now scrolls that row into view and auto-opens the categorize panel if
the transaction is still uncategorized. Behind a double-rAF so the row
DOM exists when scrollIntoView fires.

QuickReviewDialog no longer prompts to upload underlag when the
transaction already has a doc attached (which it does after the inbox
match flow). Shows "Underlag bifogat — Visa" instead, opening the
existing doc in a new tab.

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

* fix(pr-444): address review feedback (Greptile + compliance bots)

Migration rules
- New migration 20260512092423: adds updated_at trigger on
  inbox_rate_counters (CLAUDE.md rule 2) and explicit USING (false) RLS
  policies for the four DML verbs to make the SECURITY DEFINER-only
  intent explicit (rule 1).
- New pg-real test inbox-rate-limit.pg.test.ts covering happy path,
  minute-cap rejection, day-cap rejection, per-company isolation, and
  the updated_at trigger firing. CLAUDE.md mandates *.pg.test.ts for
  every new RPC because mocks pass on broken PL/pgSQL.

Bugs
- Stale exchange rate on currency switch (Greptile P1) —
  userTouchedRateRef was scoped per session, not per currency. Switching
  EUR (with a hand-edited rate) → USD kept the EUR rate. Now tracks the
  last fetched currency in a ref and resets the touched flag on
  currency change while still honoring manual edits within a single
  currency.
- topSuppliersResult.error silently swallowed (Greptile P2) — failed
  queries used to render an empty chart matching the no-data state.
  Logged now.
- Currency from extracted_data not validated (GDPR Art.25(2), OWASP V4.5,
  Swedish compliance bot) — extracted PDF currency was inserted into
  transactions.currency without sanitisation. Allowlisted against the
  six supported ISO 4217 codes; coerce to SEK otherwise.
- Idempotency gap on create-from-document (OWASP V2.3) — two concurrent
  POSTs with the same inbox_item_id could each pass the
  matched_transaction_id IS NULL read and insert duplicate transactions.
  UPDATE now includes .is('matched_transaction_id', null) as an
  optimistic-lock release and returns 409 with an orphan-transaction
  rollback when the predicate doesn't match.
- FX residual on cash-method match path (Swedish compliance bot) —
  createSupplierInvoiceCashEntry has no exchange_rate_difference path,
  so a cross-currency match would silently leave a 1930 reconciliation
  gap. Added a guard that returns MATCH_SI_CASH_FX_UNSUPPORTED (400)
  before the JE is created. Users on cash method can switch to accrual
  or book the FX diff manually.

Design system
- gap-y-1.5 / gap-1.5 in KPIExpenseMixChart — replaced with gap-y-2 /
  gap-2 (CLAUDE.md design tokens; 2.5/1.5/5/hardcoded pixels are
  forbidden spacing values).

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

* chore(migrations): rename to match applied versions

The mcp__plugin_supabase_supabase__apply_migration tool stamps its own
timestamp when it applies a migration to the live project, so the
version recorded in supabase_migrations.schema_migrations differs from
my local generation-time filenames. Renaming the local files so a
production CD run sees the migrations as already-applied (matching
versions) instead of trying to re-apply them — which would fail for
the trigger/RLS migration (CREATE TRIGGER and CREATE POLICY don't
support IF NOT EXISTS).

Follows the pattern from d854efcd ("chore(migration): rename to match
applied version").

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

* fix(create-from-document): scope orphan rollback DELETE by company_id

Defence in depth on the inbox-link race rollback. newTx.id is a fresh
UUID from a company-scoped insert two statements above, so the existing
single-key DELETE is already safe, but adding .eq('company_id', companyId)
makes the cross-company invariant explicit on every write — addresses
the OWASP ASVS V2.3 finding from the compliance swarm on PR #444.

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

* feat(nav): mark Dokumentinkorg with Beta badge

Same signal we use for Löner and Anställda — the inbox flow (AI
extraction, supplier autolink, manual transaction creation) is in
end-to-end customer testing.

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-12 13:12:17 +02:00
Jakob WennbergandClaude Opus 4.7 94f15b9c6c feat(invoice-inbox): pin documents to bank transactions + MCP tools (#397)
* feat(invoice-inbox): pin documents to bank transactions + MCP tools

Adds a first-class flow for attaching unmatched inbox documents to bank
transactions, separate from the existing supplier-invoice convert path:

- new transactions.document_id FK → document_attachments (ON DELETE SET NULL)
- POST/DELETE /api/transactions/[id]/attach-document
- categorize route propagates the link to journal_entry_id on commit
- three new MCP tools: gnubok_list_unmatched_documents,
  gnubok_get_document_content (5-min signed URL),
  gnubok_attach_document_to_transaction (staged via pending_operations)
- InvoiceInboxWorkspace gains a "Koppla till transaktion" picker dialog
  ranked by amount-match, plus a "Bilaga" badge in SwipeCategorizationView
- regex extraction unchanged; supplier-invoice convert flow unchanged

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

* fix(invoice-inbox): address PR #397 review findings

- categorize: destructure { error } from the document-link update so
  Supabase-level failures are logged instead of silently dropped (BFL 5 kap 6 §
  receipt-on-verifikation contract).
- list_unmatched_documents: emit next_cursor whenever the inbox query may have
  more rows, not only when the post-filter slice was full; switch to composite
  (created_at, id) cursor to avoid same-second collisions.
- DELETE /attach-document: return 404 when the tx isn't in the company; return
  409 when the linked document already has journal_entry_id set
  (räkenskapsinformation immutability).
- risk tier: attach_document_to_transaction medium (was low) — link becomes
  part of verifikation underlag once categorize propagates it.
- pg-real test: stop reusing $2 across uuid + text-concat contexts (Postgres
  couldn't deduce the parameter type).

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

* fix(migrations): break duplicate version 20260505120000 (Supabase Preview)

Two migrations on main share filename version 20260505120000:
  - 20260505120000_api_keys_refresh_token.sql (PR #392)
  - 20260505120000_drop_agent_auto_commit.sql (PR #394)

The schema_migrations primary key is (version), so any fresh DB doing
`supabase db push` over both files conflicts on the second insert. This is
why every PR with a migration since #394 has had Supabase Preview either
fail or skip.

Renaming _drop_agent_auto_commit to 20260505190027 — that matches the
timestamp recorded in prod schema_migrations from when apply_migration was
called for it, so future `db push` against prod sees the file as already-
applied (no re-run). The migration body is fully idempotent (IF EXISTS on
every drop) so a re-run would be a no-op anyway.

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

* fix(invoice-inbox): address PR #397 round-2 compliance review

Two BFL gaps the compliance bot flagged on the round-1 fix commit:

1. commitAttachDocumentToTransaction silently broke verifikation→underlag if
   the transaction was categorized between staging and approval. Now reads
   transactions.journal_entry_id at commit time and, if non-null, also writes
   document_attachments.journal_entry_id in the same commit so BFL 5 kap 6 §
   is satisfied regardless of order.

2. Application-layer DELETE check was racy (SELECT then UPDATE) and the FK
   ON DELETE SET NULL path could null transactions.document_id even for a
   document that is räkenskapsinformation. Added a BEFORE UPDATE OF
   document_id trigger on transactions that raises check_violation when the
   previously-attached document has document_attachments.journal_entry_id
   set. The app-layer guard stays for friendly Swedish messaging; the
   trigger is the DB-level safety net.

pg-real test extended to cover both directions of the trigger (block detach
+ block swap) and the happy-path detach when there's no JE link yet.

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

* fix(invoice-inbox): address PR #397 round-3 compliance review

Four findings from the round-2 update of the compliance bot. The first three
are genuine compliance gaps; the fourth (preview metadata distinguishing pre-
vs post-categorization overwrites) is a UX nicety left for follow-up.

1. transactions.document_id FK switched from ON DELETE SET NULL to RESTRICT
   (migration 20260506100000). Removes the "trigger ordering" concern: a doc
   that's pinned to any tx now cannot be deleted at all without explicit
   detach first. Belt-and-braces with block_document_deletion.

2. commitAttachDocumentToTransaction now does:
   - pre-check that mirrors the DELETE route's 409 when the existing pinned
     doc is räkenskapsinformation, so the same Swedish message is returned
     in both paths;
   - UPDATE…RETURNING journal_entry_id so the propagation decision uses the
     post-update state, closing the read-then-write race with concurrent
     categorize. Either ordering of attach-then-categorize or
     categorize-then-attach now lands at the same correct final state.

3. Both DELETE /attach-document and the MCP commit path catch the trigger's
   check_violation (SQLSTATE 23514) and translate to 409 with the Swedish
   underlag message. The trigger remains the DB-level safety net; the app
   layer is responsible only for friendly UX.

pg-real test rewritten for ON DELETE RESTRICT (blocks deletion of pinned doc;
detach-then-delete works). Unit coverage added for commitAttach: 404, two
distinct 409 paths (pre-check + trigger-translation), happy-path
uncategorized, and propagation when tx was categorized between staging and
commit.

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

* fix(invoice-inbox): address PR #397 round-4 compliance review

Three of five round-3 findings actioned:

1. commitAttachDocumentToTransaction: surface propagation failure rather
   than logging-and-continuing. If document_attachments.journal_entry_id
   can't be set after the transaction has been categorized, the op fails
   (status 500) with a Swedish message instructing retry. Retry is
   idempotent — same document_id on the tx, same propagate target.

2. Replace check_violation (23514) matching with a stable
   "BFL_DOCUMENT_IMMUTABILITY:" message prefix. The trigger now uses default
   P0001 + tagged message; both the route handler and the executor match on
   the prefix instead of the generic SQLSTATE. Future unrelated CHECK
   constraints on transactions can no longer accidentally surface as the
   räkenskapsinformation message.

3. gnubok_list_unmatched_documents now returns invoice currency alongside
   amount so an agent can FX-normalise before comparing to
   transactions.amount. Description updated to make the requirement
   explicit. Mirrored in the UI: AttachToTransactionDialog ranks
   same-currency rows by amount distance and pushes cross-currency rows to
   the bottom of the list.

Skipped:
- Two-migration window for FK action change is acknowledged as resolved by
  the bot; deploy-atomicity is an ops concern, not code.
- Period-lock check in attach/detach: realistic compliance concern is
  already covered by the existing immutability trigger (post-categorize) and
  by the engine's period-lock enforcement (categorize itself). A dedicated
  period check on pre-categorize attach would only guard against pinning a
  doc to a tx in a closed period — defensible defense-in-depth, but no
  active BFL violation. Left for a follow-up.

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

* fix(invoice-inbox): address PR #397 round-5 compliance review

Three of four findings actioned. The fourth (block_document_deletion
verification) is already covered by 20240101000017_enforcement_triggers.sql
which raises when document_attachments.journal_entry_id IS NOT NULL on a
posted/reversed entry — confirmed via grep, no code change needed.

1. categorize/route.ts: propagation no longer fires-and-forgets. If
   document_attachments.journal_entry_id can't be set after the JE has been
   committed, the response now carries a document_link_warning field with a
   Swedish retry message. The JE is already committed so we can't roll back,
   but the client can no longer mistake a partial attach for a clean
   categorize.

2. Rättelse audit trail (BFL 5 kap 5 §): both the REST POST handler and the
   MCP commit executor now append a TransactionDocumentReplaced event to
   processing_history whenever a non-null document_id is overwritten, with
   previous_document_id and new_document_id in the payload. Best-effort —
   logging failure must not roll back the (compliant) attach. The previous
   doc id is also returned in the response so callers see what was displaced.

3. MCP staging preview now exposes the existing doc's identity
   (existing_document_id, existing_document_file_name) plus an explicit
   existing_document_is_rakenskapsinformation flag, so a human approver sees
   "replaces X.pdf with Y.pdf" rather than just a will_overwrite_existing
   boolean. Mirrors BFL 5 kap 5 § informed-rättelse intent.

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

* fix(invoice-inbox): close trigger SELECT race (PR #397 round-6)

The enforce_transactions_document_immutability trigger SELECTed
document_attachments.journal_entry_id without a row lock. A concurrent
UPDATE setting journal_entry_id on that row could commit between the
trigger's SELECT and its RAISE, letting a detach slip through against a
document that just became räkenskapsinformation.

Add FOR SHARE to the SELECT inside the trigger. A concurrent journal_entry_id
write blocks on our share lock until our transaction commits, so either we
observe the propagation and raise, or we run first and the propagation
observes our committed detach (which is fine because journal_entry_id was
still null at that point).

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

* fix(invoice-inbox): bidirectional immutability + richer staging preview (PR #397 round-7)

Two of six round-6 findings actioned. The other four are recurring
architectural recommendations (atomic audit-log writes, background
reconciliation jobs, migration consolidation, anti-join via materialized
view) that are properly scoped as follow-up work.

1. document_attachments side of the immutability link (BFL 5 kap 6 § works
   in both directions). New trigger enforce_document_journal_entry_immutability
   blocks UPDATE OF journal_entry_id when going from non-null to NULL or to a
   different uuid. The original null→uuid path (initial propagation in the
   categorize / commitAttach flows) still works. Migration
   20260506130000.

2. gnubok_attach_document_to_transaction staging preview now joins on
   invoice_inbox_items.extracted_data and surfaces vendor/amount/currency/
   invoice_date alongside the existing doc filename/mime metadata. Gives the
   human approver the same hints the agent saw before choosing the
   attachment.

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-06 10:24:57 +02:00
Jakob WennbergandClaude Opus 4.7 ab63da8324 test: add real-Postgres smoke gate (pg-real) (#357)
* test: add real-Postgres smoke gate (pg-real)

Mocked Supabase tests cannot exercise triggers, RPCs, or RLS policies —
a migration that drops enforce_period_lock, mangles user_company_ids(),
or weakens an RLS policy ships green today. Closes that gap with a
small Vitest project `pg-real` running 5 smoke tests against a real
supabase/postgres:15 container in CI.

Covers: closed-period INSERT rejection, commit_journal_entry voucher
atomicity under concurrency, posted-entry immutability, RLS tenant
isolation on journal_entries, and audit_log UPDATE/DELETE rejection.

Also lands the bankid anonymization migration that was sitting
untracked from a prior task.

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

* test(pg-real): fix storage schema bootstrap + de-scope + PR review fixes

- Drop bankid anonymization migration from this PR. That change is
  separate scope (and has open compliance questions flagged by the
  Swedish review bot on #357); it will land in its own PR.
- Add tests/pg/bootstrap.sql to align storage.buckets/objects/foldername
  with what migrations expect before the replay loop. The supabase/postgres
  image ships only a partial storage schema; the rest comes from the
  storage-api service at runtime, which CI does not run. First pg-real run
  failed at migration 24 on "column public of relation buckets does not exist".
- Add concurrency group to the workflow so stacked PR commits cancel
  in-progress runs instead of queueing.
- Gate the pg-real vitest project on DATABASE_URL so a bare `vitest run`
  with no DB configured runs only the unit project. npm run test:pg is
  the opt-in entry point.

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

* test(pg-real): widen JWT claim setup so auth.uid() resolves under RLS

The rls.pg test came back with 0 rows instead of 1 — user_company_ids()
returned empty because auth.uid() didn't resolve to the seeded user.
Two fixes:
- Set both request.jwt.claims (whole object) and request.jwt.claim.sub
  (individual claim). Different Supabase auth.uid() versions read one or
  the other.
- Assert auth.uid() = expected userId immediately after the context
  switch, so the next failure points at the right layer instead of an
  unrelated empty-result assertion.

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-04-24 11:24:45 +02:00