Commit Graph

103 Commits

Author SHA1 Message Date
Jakob Wennberg 9ef7de861f feat(peppol): poll outbound delivery status + cap receiving registrations (#1793)
* feat(peppol): poll outbound delivery status from the access point

Qvalia's webhook API answers 404 on its production host (the sandbox answers
204), so without this the prod lifecycle would stop at submission_accepted.
The transport gains pollDeliveryStatus(); the Qvalia adapter reads
/invoices/outgoing/status and maps the message-log status through the same
tolerant mapping as a document_delivery webhook, with the same dedupe key, so
a later webhook for the same transition is a harmless duplicate. A cron four
times an hour walks the open deliveries of the last 45 days, records the
answer through the append-only lifecycle RPC and fetches evidence once a
delivery reaches transport or a terminal state. Kept as the safety net for a
missed webhook once Qvalia ships them to prod.

Refs #546

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* feat(peppol): cap receiving registrations at the contracted tenant count

The Qvalia partner contract is priced per tenant (10 to start), so the
registration refuses the next company with PEPPOL_REGISTRATION_CAP_REACHED
once PEPPOL_RECEIVING_MAX_REGISTRATIONS live registrations exist, instead of
silently exceeding the contract. A company that already holds a live row is
never counted twice; unset means no cap (own provider account).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 17:06:57 +02:00
Jakob Wennberg f93152c397 feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery (#1789)
* feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery

Second Peppol slice (#546). Qvalia confirmed that sending needs no
per-company account, so receiving keeps the consolidated partner account:
each company publishes its 0007:orgnr on our account and inbound documents
are routed by the AccountingCustomerParty endpoint.

- PeppolTransport grows optional receiving methods (registerRecipient,
  unregisterRecipient, listInboundDocuments, fetchInboundDocumentXml); the
  Qvalia adapter implements them (PUT/DELETE /peppol/{id}, readinvoices /
  readcreditnotes, exact XML fetch).
- lib/invoices/peppol-inbound-ubl.ts reads the provider's UBL-JSON
  (xml2js-style prefixed keys, verified against Qvalia's real inbound test
  invoice, kept as a fixture) into a neutral document: parties, payment
  means with SE:BANKGIRO/SE:PLUSGIRO/IBAN, totals, VAT subtotals, lines,
  embedded attachments, credit notes.
- Migration 20260821170000: peppol_registrations (one live row per company
  and participant), peppol_inbound_documents (exact XML immutable and
  undeletable, routed once), invoice_inbox_items.source gains 'peppol' with a
  per-channel dedupe index; pg-real test covers RLS, uniqueness, immutability
  and routing.
- POST/DELETE/GET /api/settings/peppol + "E-faktura via Peppol" switch in
  Settings > Fakturering; personnummer-based companies are refused until 0088
  GLN exists; sandbox refused.
- GET /api/peppol/inbound/cron every 10 minutes: archive, route, deliver.
  lib/invoices/peppol-inbox-delivery.ts archives the XML as a WORM document
  (upload_source e_invoice, extractionOwner none), an embedded PDF when
  present, and creates the inbox row with the extraction filled from the UBL
  (confidence 1, no model pass), matching the supplier by org number. The
  existing inbox review/convert flow takes over.
- document-service accepts application/xml for the archive; inbox list shows
  a Peppol icon.

Refs #546

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* test(peppol): archive contract, pg fixture and phantom-column ceiling for the receiving tables

The two new tables are räkenskapsinformation and join MASTER_DATA_DUMP_TABLES;
the pg fixture for a deregistered row now carries deregistered_at as the
status-shape constraint requires; the archive insert is an inline literal and
the one generic processing-state updater is accounted for in the ceiling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:56:32 +02:00
Jakob Wennberg 99a872987e feat(reports): behandlingshistorik as PDF + systemdokumentation pointer and version (#1790)
PR 2 of the behandlingshistorik plan (stacked on #1787).

- lib/reports/behandlingshistorik-pdf-template.tsx: landscape A4 react-pdf
  document. Fixed header (räkenskapsår, urval, legal reference, company) and
  footer (page x of y, generated in Europe/Stockholm), repeated table header,
  wrap={false} rows, no `break` props. Two sections in the order the reader
  needs them: "Ändringar i bokföringssystemet" (p. 9.16 second paragraph)
  then "Bokföringsposter i registreringsordning" (first paragraph). Meta row:
  generated, programversion, antal händelser, källor. Details as one wrapped
  paragraph per row (real-data render 371 events: 1.5 s, 23 pages). Glyphs the
  bundled Helvetica lacks (arrow, true minus) are mapped to ASCII.
- GET /api/reports/behandlingshistorik?format=pdf with a 4 000-event guard
  (413 REPORT_PDF_TOO_LARGE, CSV/XLSX remain complete); PDF first in the
  export menu; catalog exports pdf+xlsx.
- lib/reports/app-version.ts shared by the route and the archive:
  revision/systemdokumentation.json now carries system.version and a
  behandlingshistorik block (where and how it is produced, p. 9.15); the
  shipped systemdokumentation template §9.3 points at Rapporter >
  Behandlingshistorik (PDF/CSV/Excel) as well as the backup ZIP.
- Settings values that are objects render as "key: value" pairs in every
  format; report carries category_filter so the document states its urval.
- Tests: 4 PDF template tests (valid PDF, empty report, filtered range,
  220-row pagination), route pdf 200 + 413, route "unknown format" moved off
  pdf. Prod read-only render verified visually (header, sections, paging).


Claude-Session: https://claude.ai/code/session_01Kw2CFCEt8MxzbJiXMAgMVi

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:47:19 +02:00
Jakob Wennberg 524d9978f1 fix(migration): resumable underlag import without inline extraction + same-origin MCP storage URLs (#1783)
* fix(migration): resumable underlag import without inline extraction, same-origin MCP storage URLs

The Fortnox underlag import ran every file's AI extraction inline inside
one request and hit the hosted 300 s function limit after ~17 of 113 files
(twice on 2026-08-21); the UI showed the generic "underlagen kunde inte
importeras" although the files it did reach were linked. The import now
works in time-budgeted slices with a stable cursor (the UI loops until the
server reports the end and shows "x av y") and opts out of extraction
(extractionOwner 'none', stamped skipped:opted_out): every file is linked
to its posted verifikat on arrival, so the booking is already known.

MCP signed Storage URLs (upload_url, signed_url, download_url) are served
through a same-origin proxy, /api/storage/[...path], because Claude
Desktop's sandbox only reaches the MCP host and blocked the PUT to
<project>.supabase.co. The signed token stays the only credential; the
proxy forwards only signed documents-bucket paths to our own Storage host
and is a no-op rewrite when NEXT_PUBLIC_APP_URL is unset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YoZ8iboyTj221axW6Gdtm

* fix(mcp): keep the storage-proxy note out of the size-capped tool descriptions

The per-tool 280-char cap and the tools/list payload ceiling both tripped on
the two sentences added to gnubok_create_document_upload and
gnubok_get_document_content; the why now lives in a code comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YoZ8iboyTj221axW6Gdtm

* fix(review): id cursor, stall = error, capped upload body, encoded dot segments

Review follow-ups on #1783:
- the import cursor is the last handled provider attachment id, not an
  index, so a file Fortnox adds or removes mid-sweep shifts nothing
- a partial answer whose cursor does not advance (or the round guard) is
  reported as ARCIM_DOCUMENT_IMPORT_STALLED instead of "complete"; the
  slices already landed stay reported and the retry button resumes
- the storage proxy reads the PUT body as a capped stream instead of
  buffering an unbounded payload before measuring it
- object paths are rejected when any segment decodes to "." or ".." (or
  holds a separator), and the URL fetch() would actually request is
  re-checked against the allowlist after normalisation
- download_url description no longer claims a direct Storage URL

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YoZ8iboyTj221axW6Gdtm

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:28:37 +02:00
Jakob Wennberg 05c3c6ebd9 feat(peppol): Qvalia access-point adapter, send flow and delivery webhook (#1780)
* feat(peppol): Qvalia access-point adapter, send flow and delivery webhook

Qvalia is the contracted Peppol Access Point (signed 2026-08-21). This fills
the provider-neutral PeppolTransport seam from #1595 with a real adapter and
turns the disabled "Skicka via Peppol" menu item into a working send flow.

Adapter (lib/invoices/transports/qvalia.ts): partner-scoped recipient lookup,
XML submission to /invoices/outgoing with integrationId correlation, 409
recovery only when the stored copy carries the same seller endpoint, tolerant
mapping of Qvalia's free-text webhook statuses onto the 11-state lifecycle,
constant-time shared-secret webhook verification (Qvalia does not sign
webhooks), and evidence retrieval of the message-log status plus Qvalia's
stored XML copy. Registered from the environment in lib/init.ts; switched on
per deployment with PEPPOL_TRANSPORT_PROVIDER=qvalia.

POST /api/invoices/[id]/peppol/send: stage the exact XML, look up the
recipient, record recipient_verified and submitting, submit, record
submission_accepted, then issue a draft with the mark-sent semantics
(issueAndBookInvoice) only after the network accepted it. A sync rejection is
a terminal failed event so the identical document is never re-sent; an
operational failure is retryable; an already-submitted XML replays
idempotently.

POST /api/webhooks/peppol/qvalia resolves the delivery by integrationId,
persists the verified event via the service-role RPC and stores evidence
best-effort; unknown submissions answer 200, our own persistence failures 500.

UI: the send item is availability-driven with a confirm dialog, the invoice
page shows the latest Peppol status, and drafts can be sent (the number is
assigned server-side). Probe script for the first sandbox contact under
scripts/peppol/qvalia-probe.ts.

Refs #546

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* fix(peppol): Qvalia sandbox facts from first live contact: bare-key auth, api-test host, SMP-URL document types

The onboarding mail and a live probe against the sandbox (partner
SE5595386219) corrected three assumptions from the public docs: the key is
accepted bare in the Authorization header (the ApiKey prefix answers 401), the
sandbox host is api-test.qvalia.com, and the recipient lookup returns document
types as SMP service URLs, so capabilities are now normalized to bare Peppol
document type ids before comparison.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* feat(peppol): probe commands to inspect and configure the Qvalia webhook subscription

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* fix(peppol): decode UBL entities in one pass (CodeQL js/double-escaping)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:45:11 +02:00
Mattsson 60920ec794 feat(skatteverket): expose filed VAT declarations and decisions via the v1 API (#1773)
* feat(skatteverket): expose filed VAT declarations and decisions via the v1 API

Add GET /api/v1/companies/:companyId/skatteverket/vat-declarations, returning
a period's momsdeklaration as Skatteverket has it on file: the submitted
declaration (SKV /inlamnat) and Skatteverket's beslut (SKV /beslutat), either
individually via ?state= or both.

- Auth: compliance:read scope; member-visibility read model per #1673
  (resolveReadAuth: caller's token, any member's active token, or system
  credentials with a verified ombud grant).
- Architecture: core reaches the Skatteverket extension through the
  registry-resolved services channel (contract in
  lib/skatteverket/declaration-status.ts), so core never imports from
  @/extensions/.
- New structured error SKATTEVERKET_API_ERROR (502) for upstream SKV
  failures; 404 from SKV maps to submitted/decided = null with HTTP 200.
- 19 new tests (route: auth, validation, extension-disabled, happy path;
  extension service: auth resolution, state filtering, SKV error mapping).

Fixes #1663

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

* fix(skatteverket): address review findings on the vat-declarations read API

Consolidated fixes for PR #1773 review round:

- apiskill sync (core-build Checks): map the new skatteverket endpoint
  group into the periods.md reference and regenerate skills/accounted-api
  (124 -> 125 operations).
- CodeRabbit: parse the SKV 2xx body before writing the audit row, so an
  unreadable body is audited as skv_error and returns the structured
  SKATTEVERKET_API_ERROR 502 instead of escaping as an internal 500;
  regression test added.
- Compliance swarm (ISO A.8.12 / SOC2 CC6.1): stop forwarding the raw
  upstream SKV response body to API consumers; the caller now gets the
  status code and a generic Swedish message, the body is logged
  server-side only.
- Compliance swarm (GDPR Art.30): add the moms.declaration_status_read
  processing activity to .compliance/ropa.yaml (live read, no payload
  persisted, audit-log metadata only).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:12:18 +02:00
Jakob Wennberg 0de766c6a4 fix(bokslut): stop step 3 (Dispositioner) from failing for every pre-2025 räkenskapsår (#1777)
The schablonintäkt SLR table only had closing years 2025 and 2026, and the
dispositions builder consulted it unconditionally, so every aktiebolag
running the year-end wizard for 2024 or earlier got "Ett oväntat serverfel
uppstod" at the Dispositioner step (126 open FY2024 periods on prod, plus
older years), even when the company holds no periodiseringsfonder at all.

- Backfill SCHABLONINTAKT_RATE_BY_CLOSING_YEAR for 2020-2024 from
  Riksgälden's 30 November SLR (2019: -0.09 %, 2020: -0.10 %, 2021: 0.23 %,
  all floored to 0.5 %; 2022: 1.94 %; 2023: 2.62 %). 2019 and earlier stay
  unmapped: the 100 %-of-SLR rule keys on beskattningsår starting
  2019-01-01+, so a 2019 closing can be a brutet år under the old 72 %.
- Resolve the rate lazily (resolveSchablonintaktRate): a company without
  an opening 212X balance never touches the table, so an unmapped year can
  no longer break a no-fond bokslut. Used by the builder and all three POST
  item paths; POST overrides still win.
- Typed SchablonintaktRateNotConfiguredError with registry code
  SCHABLONINTAKT_RATE_NOT_CONFIGURED (500, Swedish message) so the rare
  fond-holding-company-on-unmapped-year case tells the user what is wrong
  instead of a generic server error, while still surfacing in runtime-error
  clustering for the December table update.
- Tests: rate table + resolver units, new builder test (no-fond FY2024 and
  unmapped-year cases, SLR folded into the tax base), GET route tests.


Claude-Session: https://claude.ai/code/session_01SyEZHx14jBvibkZmz8uAUC

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:47:29 +02:00
Jakob Wennberg f3e4fdcf32 fix(providers): stop the Fortnox reconnect loop, and make the attachment scopes opt-in (#1761)
The Fortnox document import needs the archive and connectfile scopes, which the
registered Fortnox app does not have. Since #1549 pulled them out of the connect
request (they broke every connect with invalid_scope before login), every
attachment call fails and the user was told "Koppla om Fortnox och godkann
behorigheterna", under a button that reruns an authorize URL still not asking
for those scopes. Klura AB followed that loop four times and bought the Fortnox
Arkiv module trying to satisfy it. Prod evidence: no Fortnox attachment has ever
imported, across 166 companies and 24 consents since the feature shipped, and no
live token carries the scopes.

The error and the scope list now derive from one flag,
FORTNOX_DOCUMENT_SCOPES_APPROVED. While it is false a permission failure maps to
a new PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE, which says the permission is missing
on our side, that reconnecting will not help, and that the rest of the migration
came through; the card offers no button, because no user action can succeed.

The attachment scopes also become an opt-in consent rather than part of every
connect. Fortnox derives customer licence requirements from what an integration
requests, so asking everyone for Arkivplats would put a licence in front of
customers who never import a receipt; and keeping it off the default connect
caps the blast radius of a wrong portal registration at the underlag flow rather
than every Fortnox connection. buildFortnoxAuthUrl already took per-call scopes,
provider-client simply never passed any, so this threads documentScopes from
that one button through /connect into the authorize URL.

A document consent is always a superset of an ordinary one: the callback
overwrites the consent's tokens in place, so a narrower grant would revoke the
migration's own ledger access. Pinned by a test that holds either way the flag
is set, alongside one for the 400-with-behorighet answer that six companies hit
between 08-13 and 08-19 and saw only a generic retry for.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 08:33:34 +02:00
Mattsson 47c039453c feat(import): undo a bank file import including ignored transactions (#1764)
* feat(import): undo a bank file import including ignored transactions (#1672)

A mis-parsed bank CSV could not be cleaned up: re-importing dedup-skips
the bad rows, the single-row DELETE refuses imported rows by design
(TRANSACTION_DELETE_IMPORTED), and there was no bulk action. Transactions
also never recorded which import batch inserted them, so a strictly
scoped undo was impossible.

- transactions.bank_file_import_id: batch link stamped at ingest by both
  bank-file import paths (dashboard execute route, v1 REST route). PSD2/
  manual/MCP rows stay NULL. No retroactive backfill: fuzzy attribution
  could delete rows belonging to a different import.
- undo_bank_file_import RPC: owner/admin-only bulk delete of the batch's
  unbooked rows, ignored INCLUDED. Booked rows (journal link, payment
  rows, voucher links) and rows with append-only payment_match_log
  history are skipped and reported, mirroring the single-row route's
  guards. Marks the import 'undone' (re-import reuses the row via the
  company_id+file_hash upsert), writes one audit_log summary row, and
  hardens the actor gate like undo_sie_import: p_user_id honored only
  for service_role callers, 42501 otherwise, no anon EXECUTE.
- DELETE /api/import/bank-file/[id]/undo returns the deletion report;
  RPC 42501 maps to BANK_FILE_UNDO_FORBIDDEN (403).

Closes #1672

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

* fix(import): return 404 when the bank-file undo target does not exist

An unknown or out-of-company import id answered 400 BANK_FILE_UNDO_FAILED,
hiding the not-found semantics the SIE import routes already expose
('Import not found', 404). Flag the case in undoBankFileImport (notFound)
and map it to a new BANK_FILE_UNDO_NOT_FOUND structured error (404);
status-refusals and RPC failures keep the 400 envelope.

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

* feat(import): show bank file import history with undo on the import tab

The undo shipped for issue #1672 was API-only: no surface listed a
company's bank_file_imports, so neither users nor founders could reach
DELETE /api/import/bank-file/[id]/undo, and the deletion report existed
only in JSON. Mirror the SIE pattern (SIEImportHistory, #1574):

- GET /api/import/bank-file: list the company's imports newest-first,
  same { data, count, limit, offset } shape as GET /api/import/sie.
- BankFileImportHistory: fold-open 'Tidigare bankfilsimporter' row on
  the Importera tab with filename, date, format, imported count and
  status per import, plus an undo action on completed rows behind a
  DestructiveConfirmDialog. The undo stays owner/admin-only via the
  undo_bank_file_import RPC's actor gate, like the SIE one.
- After undo the toast shows the full report: transactions removed,
  booked rows skipped, rows with match history skipped, so nothing
  disappears silently from the ledger's surroundings.
- i18n strings in messages/sv.json and messages/en.json following the
  sie_history_* key style; list-route test mirroring the SIE list test.

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

* chore(migrations): move undo_bank_file_import after main's 2026-08-19 migrations

Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(import): validate bank-file list params, fail closed on undo lookup, log lost batch attribution

Review findings on #1764 (CodeRabbit):
- GET /api/import/bank-file rejects non-integer/negative/oversized limit
  and offset and unknown status with a mapped 400
  (BANK_FILE_LIST_INVALID_QUERY), limit capped at 100; boundary and
  invalid-input tests added.
- undoBankFileImport distinguishes PGRST116 (zero rows -> notFound/404)
  from other lookup failures, which now return an error instead of
  masquerading as a permanent 404.
- The v1 import route no longer discards the bank_file_imports upsert
  error: kept non-fatal by design (an unattributed batch imports fine and
  never appears in undo history), but the failure is now logged loudly.
- Route test beforeEach clears the event bus (repo convention).

Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:25:18 +02:00
Jakob Wennberg e6c4fe2cf8 fix(customers): personnummer guard + personal_number on v1 + payment terms from settings (#1724)
* fix(customers): stop personnummer landing unmasked as org_number, persist personal_number on v1, default payment terms from settings

Closes #1707. Closes #1708.

Personnummer (#1707, Discord kalletoxic):
- CreateCustomerSchema rejects an org_number shaped like a Swedish
  personal identity number on business customer_types. Only
  customer_type=individual rows are masked in lists, so accepting one
  stored an unmasked personal identifier (GDPR art. 5.1 c). The shape
  check uses the month-position rule (legal-entity orgnr always
  carries >= 20), so real orgnr can never false-positive.
- The v1 create, v1 PATCH and bulk-create endpoints accepted
  personal_number through the shared schema but silently dropped it.
  They now store it encrypted, expose it masked (********-1234) on the
  single-customer surfaces, and treat the masked form as unchanged,
  mirroring the internal routes.
- Route-level guards on both PATCH routes (new 400
  CUSTOMER_ORG_NUMBER_IS_PERSONAL) plus a client-side message in
  CustomerForm (sv + en).

Payment terms (#1708, Discord kalletoxic):
- New resolveDefaultPaymentTerms: provided value, else
  company_settings.invoice_default_days, else 30. Wired into the UI
  new-customer dialog, the internal POST, v1 create (incl. dry-run),
  bulk-create and the MCP staged create_customer.

apiskill regenerated; no migrations.

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

* docs: record what the CI build OOM actually was

main raised the build heap to 8192 in parallel with this branch, so the
fix itself is already in and this keeps it untouched. What was missing
is the diagnosis.

Measured with tsc --noEmit --extendedDiagnostics, type-checking the repo
needs 4 192 550 K at 506d030b and 4 187 096 K on this branch, 5 MB less
and 0.26% more instantiations. So the ceiling is the type-check pass at
steady state against Node 20's ~4 GB default old-space, not bundle
growth and not any single PR. Worth writing down so the next person who
sees "Ineffective mark-compacts near heap limit" does not go looking for
it in their own diff.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 10:41:24 +02:00
Jakob Wennberg 6b9338f17e feat(invoices): Betald-stämpel i faktura-PDF + betalningsbekräftelse till kund (#1700)
* feat(invoices): Betald-stämpel i faktura-PDF + betalningsbekräftelse till kund

Closes #1693. A paid faktura re-renders with a BETALD banner (paid date
and amount) and "Betalt: X" followed by "Att betala: 0"; partially_paid
gets the Betalt / Att betala (remaining) rows without a banner. Credit
notes and proformas are unchanged. Labels in sv and en.

The paid copy is its own document, a betalningsbekräftelse, never the
archived original: GET /api/invoices/[id]/pdf?variant=paid refuses
anything but status paid (409 INVOICE_PAYMENT_CONFIRMATION_NOT_PAID),
names the file Betalningsbekraftelse-<nr>.pdf and never reads or
replaces the delivery archive. invoice-pdf-source gains the
'payment_confirmation' re-render reason so the UI caveats it like any
re-render. POST /api/invoices/[id]/send-payment-confirmation emails the
paid PDF with a dedicated subject/body through the existing email
service and recipient routing, without touching status, sent_at,
journal entries or invoice_deliveries (no kind column there; logged via
the route logger instead).

Detail page: the two actions sit inside the Betald card (download paid
copy, send confirmation with an up-front confirm dialog), not in the
header row. No migrations.

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

* fix(invoices): one-line hint for the betalningsbekräftelse actions

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:17:18 +02:00
Mattsson 3a1b842e4a feat: add safe owner-only migration reset (#1682)
* feat: add safe company migration reset

* fix: harden company reset eligibility

* fix: close company reset compliance gaps

* test: fix migration reset pg-real probes

* fix: preserve migration archive access

* docs: explain migration numbering continuity

* fix: block reset with VAT workflow state

* fix: block externally staged reset data

* fix: address migration reset review findings

* fix: clear stale migration archive estimate

* fix: retry migration archive estimates
2026-08-19 12:04:24 +02:00
Mattsson 43cde6deb9 fix: unignore transactions during categorization (#1683)
Fixes #1660
2026-08-19 11:00:02 +02:00
Mattsson 3ec76d39db fix(providers): correct Bokio v1 connection validation (#1681)
Fixes #1670
2026-08-18 23:00:44 +02:00
Jakob Wennberg 387e1fb7f1 fix(import): let a skattekontoutdrag that does not sum through a confirm gate (#1675)
* fix(import): let a skattekontoutdrag that does not sum through a confirm gate

The skattekonto file parser refused any statement where ingående saldo plus
händelser did not equal utgående saldo with a bare 400 and no figures. A
real export hit it on 2026-08-18 and the user had no way forward, and the
logs carried nothing to diagnose it with. Nothing is booked at import and
the dedup contract makes a later complete re-import safe, so refusing the
file only blocked the rows that WERE readable.

- Parser: report events_sum / sum_difference / unreadable_amount_rows
  instead of just a boolean; reduce several marker pairs to the earliest
  opening and latest closing (per-year sections, newest-first files); read
  a marker saldo from a trailing running-saldo column when the belopp cell
  is empty; accept U+2212 and dash lookalikes as minus and a leading plus.
- Route: no longer 400s on sum_valid=false; logs the figures (amounts and
  counts, never row text) so the next report is diagnosable. Zero readable
  rows still refuses. SKATTEKONTO_FILE_SUM_MISMATCH removed (unused).
- Preview: an "Utdraget summerar inte" card with ingående, händelser,
  ingående+händelser, utgående and differens plus a confirm checkbox that
  gates the import button, mirroring the orgnr-mismatch gate. A one-line
  note explains that nothing is booked at import and that events already
  carrying a 1630 verifikat are offered as a link, not a second booking.

Verified end to end in the sandbox: gate renders, import proceeds after
confirmation, rows land on /skattekonto with Matcha/Bokför.

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

* fix(import): round the derived händelser total and fall back to the date cell for an invalid marker date

Review nits on #1675.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 10:33:51 +02:00
Jakob Wennberg 4921d1da5e feat(import): import skattekontoutdrag files into the skattekonto pipeline (#1637)
* feat(import): import skattekontoutdrag files into the skattekonto pipeline

Users can now upload the kontohändelse export from Skatteverket's
skattekonto e-service (current CSV layout, verified against a real
2026-08 export, plus legacy .skv files) instead of needing the paid API
connection. Parsed rows land in skattekonto_transactions as booked
file_import rows and inherit the existing 1630 rules engine, bulk
booking, match-to-verifikat and both UIs unchanged.

- Core parser lib/import/skattekonto-file/ with strict detection
  (orgnr header + saldo markers, or two distinct SKV vocabulary terms
  plus row shape), sum-integrity check (opening + rows must equal
  closing) and a wrong-company guard against company_settings.
- computeDedupKey moves to core (lib/skatteverket/skattekonto-dedup);
  the extension re-imports it. File rows hash-key; content-signature
  partitioning skips rows already booked (either key form) and promotes
  matching upcoming rows in place.
- syncSkattekonto gains a takeover step: an id-keyed API row adopts a
  matching hash-keyed imported row in place, so journal links survive
  connecting the API after a file import. Upcoming rows can no longer
  clobber a booked row on hash collision.
- New skattekonto_file_imports table (company-scoped file-hash dedup)
  plus source/file_import_id provenance columns on
  skattekonto_transactions.
- /import gains a Skattekontoutdrag wizard (upload/preview/result,
  deep link ?mode=skattekonto); the bank-file flow detects skattekonto
  files and redirects instead of importing them as bank rows.
- /skattekonto renders imported rows for unconnected companies (attn
  line + import CTA) instead of discarding them behind the StartCard.
- Free for everyone: the local-data booking/match routes were already
  ungated; only API sync/saldo stay capability-gated.

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

* fix(skattekonto): align the EF F-skatt rule with the 2012 -> 2013 decision

20260810120000 established that 2012 is not standard BAS and moved the
booking templates to 2013 (owner taxes in an enskild firma are an eget
uttag), but the skattekonto_rules seed still booked EF preliminarskatt
against 2012. The file importer makes this rule fire for every EF
F-skatt row, so bring it onto 2013 too.

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

* fix(import): apply review findings on the skattekonto file import

- Fix the takeover candidate comparator: the single-argument sort was an
  inconsistent relation and could adopt a stale upcoming row ahead of the
  booked file row in a 3+ candidate queue (regression test added), and
  page the candidate scan with fetchAllRows so a multi-year window is not
  silently capped at 1000 rows.
- Fail parsing when a statement HAS saldo markers but not both readable
  balances: a file cut off before "Utgående saldo" previously skipped the
  sum check entirely. sum_valid stays null only for marker-less legacy
  files.
- Count a promotion only when the UPDATE matched a row, so a concurrent
  sync cannot inflate promoted_count; log a failed finalize of the import
  record instead of discarding the error.
- Migration (unshipped, edited in place): user_id is nullable with
  ON DELETE SET NULL so import records and their file-hash dedup survive
  user deletion, and the INSERT policy binds user_id to auth.uid() so a
  member cannot attribute an import to a colleague. pg tests cover both.
- Make the upload drop zone keyboard-reachable (role, tabIndex, Enter/
  Space) and give the six count-bearing strings ICU plural forms in both
  locales.

Skipped with reasons on the PR: binding execute rows to file bytes and
re-checking orgnr in execute (same client-trust model as the shipped
bank-file execute; Zod + RLS scope writes to the caller's own company),
a 404 test (the route has no not-found path), event-bus clearing in the
route test (the route touches no events), and FK NOT VALID (new column
referencing a brand-new empty table).

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:18:32 +02:00
Mattsson 2deea05d42 feat(import): attach underlag to SIE-migrated verifikat by filename (#1627)
* refactor(documents): lift the SIE voucher-ref resolver into core

The provider migration sweep resolved a source voucher reference to the
verifikat it became with an in-memory (period, series, number) index built
inside extensions/general/arcim-migration. The underlag filename import needs
the identical resolution, and core must never import from @/extensions, so the
index, its ambiguity handling and the two paged reads move to
lib/documents/voucher-ref-resolver.ts.

Behaviour-preserving for the extension: same index construction, same "drop
both when one key repeats inside a fiscal year" rule, same dateTo-window
resolution. The arcim tests pass unchanged.

Two deliberate additions on top of the lift:
  - series comparison is now case-insensitive on both sides. SIE writes series
    uppercase in practice but the spec does not require it, and a filename is
    whatever the exporting tool produced.
  - byNumber and fetchVouchersForNumbers serve the filename flow, which
    resolves a handful of refs per request and must not pull every migrated
    entry into memory to do it.

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

* feat(import): attach underlag to SIE-migrated verifikat by filename

A SIE file carries the ledger but not the underlag, so a migrating customer
brings the receipts over separately and today has to open every verifikat and
attach them by hand. Systems that export both name each receipt after its
verifikat (A31_<internal-id>.pdf), and the SIE import already preserves that
identity on every entry (source_voucher_series / source_voucher_number), so
the pairing is a lookup, not an interpretation: no AI, no amount matching, no
date windows.

Separate optional import mode (/import?mode=underlag), NOT a step inside the
SIE wizard: the receipts normally arrive later and from a different export, so
a migration must never be blocked on having them ready.

  lib/documents/filename-voucher-ref.ts  reads the ref out of a filename
  lib/documents/underlag-import.ts       builds the plan (reads only)
  POST /api/import/documents/preview     filenames in, match plan out
  POST /api/import/documents/attach      one file, archived and linked
  components/import/UnderlagImportWizard review, adjust, run

Guards, because a document linked to a posted verifikat is
räkenskapsinformation and can never be re-pointed (BFL 7 kap):

  - Matching keys on the SOURCE voucher number, never our own. The importer
    renumbers per target series, so a file named after our number would land
    on the wrong verifikat exactly when the import skipped a voucher.
  - Nothing is uploaded until the whole plan has been shown: the preview
    sends filenames only, the bytes stay in the browser.
  - A ref that hits several migrated years is surfaced as a choice, never
    resolved by guessing. So is a filename with a number but no series, which
    is resolved but never pre-selected.
  - A date-named file (20240131.pdf) is refused outright rather than read as
    voucher 20240131.
  - A target in a closed or locked period is shown but not selectable:
    enforce_period_lock_documents would refuse the write anyway.
  - The attach route re-resolves the filename server-side and 409s when it
    does not name the target the client sent, so a stale plan cannot scatter
    underlag permanently. An explicit manual assignment opts out of that check
    and is flagged as such; company ownership of the entry is always verified.
  - Idempotent per (verifikat, content): a re-run converges on the same
    document row instead of archiving duplicates.

tests/pg/underlag-attach-period-lock.pg.test.ts pins the period-lock contract
the plan surface promises, including that the lock guards the LINK and still
lets an unlinked document be archived.

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

* fix(import): scope underlag matching to a declared fiscal year

Adversarial review of #1627 refuted the resolver: it looked a ref up
company-wide and treated "exactly one candidate exists" as proof of identity.
Source systems restart voucher numbering every year and a filename carries no
year, so with a partial migration, or with that year's A31 among the vouchers
the importer routinely skips (empty, single-line, unbalanced), a 2023 receipt
was silently attached to a 2025 verifikat. Permanent under BFL 7 kap, and
invisible afterwards. Cardinality is not identity.

Every batch now declares its fiscal year and candidates outside it are dropped
before the index is built, so no downstream branch can see, count or propose
one. The attach route takes the year for its re-resolution from the TARGET
entry, never from the client, so the check cannot be widened by naming a
different year. Scoping cannot make the year inferable; it makes it asserted,
and the confirm dialog reads it back because it is the one input the files
cannot corroborate.

Four further defects from the same review:

  - npm test went red: hoisting the column list into a VOUCHER_SELECT constant
    hid it from the no-phantom-columns AST scan (ceiling 377 -> 379) and
    dropped all eight journal_entries columns out of the guard on the one path
    that writes irreversible links. Both selects are inline again, and split:
    the provider sweep no longer fetches three display columns it never reads.
  - The date guard only caught zero-padded hyphenated dates, so
    `2024-1-31 kvitto.pdf`, `2024 01 31 ...`, `2024.1.31` and `24-01-31` all
    parsed as voucher 2024 or 24. Widened to unpadded components, two-digit
    years and space/slash separators; a bare year-shaped number is refused.
  - `Verifikation 31.pdf` parsed as series ION: the alternation matched
    `ifikat` and left `ion` for the series group. Reordering alone was not
    enough (the engine backtracks into it), so the prefix now requires the
    word to end.
  - The manual-reference box was an unguarded write path: typing a date got
    path-split down to a voucher number, marked the row selected, and posted
    with override, which skips both server checks, while the row still showed
    "Kan inte tolkas". Directory splitting is gone from the parser, the row
    status is updated on resolve, and picking a server-proposed candidate no
    longer counts as an override, which had disabled the filename check on
    exactly the ambiguous rows it exists to protect.

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

* fix(import): enforce the declared fiscal year on the server

The second adversarial pass refuted the previous fix. The attach route took
the year for its re-resolution from the TARGET entry, which is tautological:
an entry is by construction inside its own fiscal_period_id, so the filter
could never drop it and the year axis was unfalsifiable. Server-side year
enforcement was zero; the declared year existed only as React state and was
never sent. The regression test that "proved" otherwise passed only because
the mock let one journal_entries row report two different fiscal_period_id
values to two different reads, a state Postgres cannot produce. A test that
could not fail.

The attach request now carries the year the user actually reviewed, echoed
back from the plan, and the route asserts it equals the target's own period
BEFORE any other check and including overrides: an override is a statement
about which verifikat, never about which year. Its test asserts that directly
instead of a mock artifact.

Also from the same pass, a UI race that made the confirm dialog lie: FyPicker
stayed interactive while a preview of up to 2000 filenames was in flight, so
the summary and the confirm text could read back a year the plan was not built
from, and a manually resolved row could join the batch from another year
entirely. The wizard snapshots the plan's year, every downstream read uses the
snapshot, manual re-resolution goes through the server's own echoed
plan.fiscal_period_id, and the picker is frozen while a preview runs.

Parser, from the corpus pass (~360 realistic filenames plus 200k random uuids,
no ReDoS found: 2000 hostile inputs in 26ms):

  - Day-first and US dates parsed as voucher numbers: `31.01.2024` became
    voucher 31, a number that always exists in the year. The guard now covers
    both orders.
  - `ver 31.pdf` parsed as series VER and came back auto-selectable, while
    every spelled-out `Verifikat 31.pdf` correctly yielded a series-less
    reference needing confirmation. Same filename, two trust levels, decided
    by an abbreviation. `ver` is no longer a series.

Known residual, stated rather than papered over: a scanner's `A4.pdf` or a
`K10.pdf` blankett in the receipts folder still matches verifikat A4 or K10
when that year has them. No parser can separate those from a genuine
reference; they appear in the review table with the target's date and
description.

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

* fix(import): make the user actually declare the fiscal year

The third adversarial pass found that the central guarantee of the previous
two commits was fiction. FyPicker auto-selects the newest fiscal period when
nothing is stored, and the wizard passes a page-specific storage key, so that
branch fired on every first use. A user migrating 2023 receipts who never
opened the picker resolved them against the newest year; A31 exists in
essentially every year, so those rows came back `matched`, pre-selected, with
only the confirm dialog between them and permanent links. Every commit message
and code comment claiming "the year the user named" described behaviour the UI
did not have.

FyPicker gains an opt-in `requireExplicitChoice` prop, default off so no other
caller changes, and the wizard uses it. The picker starts empty and the batch
cannot proceed until someone picks. A previously stored explicit choice for
this surface is still restored, which is what makes a multi-batch migration
bearable.

Also: a company with zero fiscal periods hit a disabled picker and a disabled
button with no explanation. There is now a line saying why.

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

* fix(import): close the restore-branch hole and demote collision-prone refs

Round four of adversarial review, two findings, both fixed.

1. `requireExplicitChoice` gated only the newest-period fallback, not the
   localStorage restore branch above it, so the "user declares the year"
   guarantee held only for a user's first-ever batch. From the second on, the
   year was silently pre-filled from an earlier unrelated batch, and in a
   multi-year migration last-used is the worst possible default: the user is
   by definition moving to a different year each round. The prop now gates
   FyPicker's ENTIRE auto-selection block with one outer condition (restore,
   the ALL_YEARS-stored fallback, newest-period, preferLatestEnded), because a
   per-branch gate already missed one branch once. It also suppresses the
   localStorage write, which fired BEFORE onChange and so recorded picks the
   wizard had rejected mid-preview. The wizard drops its storage prefix
   entirely: within one sitting reset() carries the year in state, and
   nothing survives the session.

2. The filename parser pre-ticked `A4 scan.pdf` and `K10.pdf` while requiring
   a click for `31.pdf`, which carries MORE voucher evidence in a
   single-series company. Two independent review passes flagged the same
   inconsistency. Collision-famous refs (A0-A6 paper sizes, K2-K13/N1-N9/
   T1-T2 blanketter, Q1-Q4 quarters) and three-letter series (IMG/DSC/DOC/
   SCN are cameras; real SIE series are 1-2 chars) still parse and resolve
   but are never auto-selected. Demoted, not refused: verifikat A4 genuinely
   exists in every migrated ledger, and its real receipt costs one click.
   Residual documented: an existing short series plus a small number in an
   ad-hoc name (`B2 hyra.pdf`) is indistinguishable from a real ref by
   filename alone.

Also: the attach route's multipart doc now names the required
fiscal_period_id field, and the stale reset() comment describes the actual
persistence model.

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

* fix(import): honor override only for unresolvable filenames + review round

Resolution pass for the PR #1627 review reports (CodeRabbit, Swedish
accounting review, compliance swarm).

The one substantive finding (CodeRabbit, major): `override: true` skipped the
filename consistency check entirely, so a crafted client could attach a
cleanly-named file to any same-year verifikat. The resolver now runs on every
request; an override is honored only when the filename is unresolvable in the
declared year (no parse, or no candidate) or already resolves to the requested
target. The shipped UI only overrides unresolvable rows, so nothing
user-facing changes. planAcceptsTarget is renamed planPermitsAttach and
carries the semantics in one place, with tests for both directions.

The Swedish review finding (BFNAR 2013:2 systemdokumentation): the
planPermitsAttach JSDoc still described the superseded derive-the-year-from-
the-target design. It now states the actual control: the route asserts the
caller-declared year equals the target's own period before this function runs.

CodeRabbit minors and nitpicks:
  - underlag_confirm_body / underlag_run / underlag_locked_warning use ICU
    plural forms in both locales; "1 filer arkiveras" was wrong Swedish.
  - The attach and preview route tests mock @/lib/supabase/server per the
    repo test guideline.
  - fetchVouchersForNumbers narrows to the declared fiscal year at the DB;
    the in-memory filter in buildUnderlagPlan remains the enforced truth.
  - buildVoucherIndex appends into existing arrays instead of copying per
    row: the provider sweep indexes every migrated entry in the company and
    per-row copies made that O(n^2).
  - The pg test reuses its insertDocument helper instead of a duplicated
    INSERT; runAttach clears isLoading in a finally.

Declined, with reasons in DECISIONS.md: message-regex classification of
validateDocumentFile failures (established sibling pattern; validator
contract change is out of scope).

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

* fix(import): attach only to posted or reversed verifikat

Second review cycle on PR #1627: the Swedish accounting review's re-run found
that nothing in the attach route verified the target entry's status. The SIE
import RPC posts every entry inside its own transaction, so a draft carrying a
source ref should be unobservable, but the link this route writes is
irreversible räkenskapsinformation, and an invariant enforced in another file
is not one this surface may lean on. Underlag references a verifikation
(BFL 5 kap 6-7 §), so the target must BE one.

Enforced twice: the route rejects non-posted targets with
UNDERLAG_ENTRY_NOT_POSTED (overrides included), and the resolver reads filter
to posted/reversed so a draft can never even become a candidate. Reversed
stays attachable: a storno'd original remains räkenskapsinformation and its
underlag belongs on it.

Also recorded as confirmed-intentional (review note, no code change): with
override and an unresolvable filename the endpoint links to any same-company,
same-declared-year, posted verifikat, migrated or not, which mirrors the
existing /api/documents/[id]/link capability. The period-lock error-string
regex note restates a disposition already recorded in DECISIONS.md.

The arcim test's Supabase double learns .in(), which the shared resolver read
now uses for the status filter.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:50:32 +02:00
Mattsson 4a9fa5e6c5 feat(inbox): staged upload ack, HEIC/HEIF validation, WhatsApp silence fixes (#1605)
* fix(whatsapp): app-side unmute, close silent intake paths, health visibility

- add POST /link/unmute and a Reactivate control on the Pausad state
- company resolution: transient query errors release the row for sweep
  retry; genuine zero-options sends M19 instead of parking silently
- media from unlinked senders bypasses the hourly greeting throttle
  (10 min burst window, daily cap kept)
- GET /link returns 7-day failed-delivery and parked-inbound counts;
  sweep summary logs outboundFailed24h

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

* fix(documents): real HEIC/HEIF magic-byte validation, bilingual upload errors

- detect ISO-BMFF ftyp brands (heic/heix/heim/heis/hevc/hevx/hevm/hevs,
  mif1/msf1) instead of exempting image/heic from validation; declared
  heic/heif accepts either family member (iOS labels vary)
- new INBOX_UPLOAD_* structured error codes replace raw English strings
  on the inbox upload and attach-document routes
- registry doc corrected to the real 10 MB cap

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

* feat(inbox): staged upload with instant ack and deferred AI extraction

- web uploads insert the inbox item as status processing and respond
  immediately; Bedrock extraction and supplier match run via after()
  with a CAS flip to received (email and WhatsApp channels keep the
  synchronous path)
- widen invoice_inbox_items.status CHECK to include processing
  (migration 20260813180000, pg-real test included)
- crash-recovery sweep cron (*/2) flips stale processing rows;
  bulk-book skips extraction_in_progress items
- workspace: processing chip, in-flight rows disable actions, realtime
  flip, retry-extraction button for empty extractions
- picker accept list drops HEIC/HEIF so iOS transcodes library photos
  to JPEG; server allowlists unchanged (supersedes 2026-08-01 HEIC
  decision, see DECISIONS.md)

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

* fix(migrations): bump inbox processing-status migration past main's latest

Main merged 20260813210000 while this PR was in flight; an inserted
version older than the latest applied aborts the prod db push at merge.
Renamed 20260813180000 to 20260813213000 and updated references.

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

* docs(decisions): log preview-tracker orphan repair after migration rename

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:57:53 +02:00
Mattsson 05380ddf54 feat(bookkeeping): correction-chain depth guard + Bedrock stream retry (#1581)
* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos

Correcting or reversing an entry that already sits 3+ links deep in a
rattelse chain (correction_of_id/reverses_id walked in the DB, never
description matching) now throws CORRECTION_CHAIN_TOO_DEEP, steering the
caller to book ONE correction expressing the chain's net effect. Agents
looped storno+rattelse 10 deep on a live company (63/193 vouchers noise).

The guard is advisory, never a dead end: allow_deep_chain bypasses it on
every surface (correctEntry/reverseEntry option, REST body, MCP tool arg
staged through pending_operations, and confirm dialogs with Ratta anda /
Aterfor anda in the web UI). MCP staging pre-flight fires the guard at
stage time so the agent reconsiders in the same turn, and the executor
re-checks at commit. tools/list payload ceiling bumped 59K -> 59.5K for
the two bypass properties (trimmed to one sentence first).

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

* feat(agent): retry the Bedrock stream once on transient failures

A transient stream death (429/5xx, transport cut, or the two known
stream-corruption signatures: 'Unexpected event order' and 'request ended
without sending any chunks') killed the whole chat turn, stranding the
user mid-answer. The turn now retries once per turn after a short backoff:
safe because nothing is persisted until finalMessage() succeeds. A new
stream_restart event carries the pre-attempt text snapshot so the chat
client resets the partial bubble, drops uncompleted tool chips, and shows
'Forsoker igen...' until the retried stream produces text. Non-transient
errors (403, 400) keep the existing immediate-error path.

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

* fix(api): regenerate accounted-api skill and wire allow_deep_chain through v1

apiskill:check failed: CorrectJournalEntrySchema gained allow_deep_chain,
making references/journal-entries.md stale. Regenerated (hand-applied: the
generator output is deterministic from the registry). While wiring: the v1
correct route validated allow_deep_chain but dropped it, and the v1 reverse
route's strict body schema would have rejected it outright, leaving API
clients no bypass when the chain-depth guard fires. Both now forward the
flag to the engine and document CORRECTION_CHAIN_TOO_DEEP as a pitfall.

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

* chore: re-trigger CI after Vercel infra hang

The preview for e527e4044 compiled in 91s then hung 40 minutes in the
TypeScript phase and was killed with no error output; a CLI redeploy of
the identical code went Ready in 5m. Empty commit to refresh the git-
triggered deployment status.

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

* fix(bookkeeping): address CodeRabbit review on the chain-depth guard

- correction-chain: report rootVoucher only when the walk reached a
  genuine parentless root; a broken link, cycle, or hop-cap now yields
  null instead of presenting an intermediate voucher as the chain root.
- recordate: propagate allow_deep_chain end-to-end (recordateEntry
  option, route schema, and a Flytta anda bypass confirm in the dialog);
  a date move is another storno+rattelse layer and carried the guard
  with no override path.
- v1 correct/reverse: run the chain-depth guard before the dry-run
  return so a dry run gives the same verdict as the real execution.
- dashboard reverse route: 400 on malformed JSON or a non-boolean
  allow_deep_chain instead of silently reversing without the override;
  empty body stays the supported no-body case. Tests added.
- AgentChat stream_restart: discard the dead attempt's reasoning and
  re-arm the post-tool paragraph break so a retried turn doesn't render
  thinking twice or glue its continuation onto restored text.
- v1 reverse route doc comment updated for allow_deep_chain.

Not changed: the journal-list reverse flow (flagged as a dead end) can
never receive CORRECTION_CHAIN_TOO_DEEP: the list renders Aterfor only
for entries that are neither storno nor correction, and such entries
have no backward chain links, so their depth is always 0.

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

* test(bookkeeping): recordate route test expects the new options arg

recordateEntry now takes { allowDeepChain } as a sixth argument; the
route test's called-with assertion predates it.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:32:41 +02:00
Jakob Wennberg 1eebb75269 feat(transactions): move an unbooked transaction to another cash account (#1570)
A bank transaction that ingested under the wrong cash account (or with no
account at all: legacy connections, own-account transfers the backfills
deliberately skipped) surfaces under the primary account's reconciliation
and can never be matched on the account it belongs to, because
cross-account matching is deliberately blocked. There was no first-party
way to fix the binding.

New PATCH /api/transactions/[id]/cash-account moves a movable staging row
(not booked, not invoice/supplier-invoice matched, not anchored via
transaction_voucher_links) to another of the company's cash accounts,
addressed by its BAS 19xx ledger account. Cross-currency moves are
hard-rejected (the row would vanish from every report's currency scope),
and the movable gate is re-asserted atomically in the UPDATE filter
against a concurrent book/auto-match, mirroring the title route. The tvl
check runs as a pre-check query since PostgREST cannot express NOT EXISTS
in an update filter; a tvl row appearing concurrently implies the booking
flow, which sets its own transaction state.

UI: 'Flytta till annat konto' in the transaction inbox row menu (opens a
radio-list dialog of the enabled cash accounts, current one preselected
and disabled) and direct 'Flytta till {name}' items in the bank
reconciliation unmatched-row menu that PATCH and refetch the view.

New structured error codes: TRANSACTION_MOVE_BOOKED,
TRANSACTION_MOVE_UNKNOWN_ACCOUNT, TRANSACTION_MOVE_CURRENCY_MISMATCH.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:20:14 +02:00
Jakob Wennberg c4adc8eb7d fix(salary): stop RLS from failing vab/parental absence registration (#1568)
Migration 20260517135000 rewrote the franvaro-specifikationsnummer trigger
functions to insert audit rows into salary_absence_franvaro_audit, a table
with RLS enabled and zero policies, while leaving the functions SECURITY
INVOKER (its comment claimed implicit SECURITY DEFINER, which is false in
Postgres). Every vab/parental insert from role authenticated (dashboard
absence POST, web /pending approval, in-app Assistenten chat) then failed
with 42501, surfaced as a generic 500, and left no diagnosable trace.

- New migration 20260813120000: ALTER both trigger functions to SECURITY
  DEFINER with search_path pinned to public, pg_temp. No RLS policy is added
  on the audit table: trigger/service-only writes stay the design intent.
- mapInsertError: 42501 now maps to the new bilingual DB_PERMISSION_DENIED
  code instead of INTERNAL_ERROR, and 23514 is split so only the 24h-cap
  trigger's 'Total tid' message becomes ABSENCE_HOURS_CONFLICT; other CHECK
  violations map to VALIDATION_ERROR.
- commitRegisterAbsence/commitDeleteAbsence: log the underlying PG details
  and persist the sanitized structured code in result_data.error_code so the
  next failure is traceable from the op row.
- Dashboard absence route: only ABSENCE_HOURS_CONFLICT passes details.message
  through to the client; every other code shows the registry Swedish message
  instead of raw Postgres text.
- New pg-real regression test locks the authenticated-role parental/vab
  insert path, the shared per-month specnummer sequence, the audit rows, and
  idempotent upsert retries.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:12:32 +02:00
Mattsson a97b0023d4 feat: import Fortnox voucher attachments (#1541)
* feat: import Fortnox voucher attachments

* fix: show Fortnox document import follow-up

* fix: harden optional Fortnox document import

* test: pin optional Fortnox import flow

* fix: use browser timer handle type

* fix: avoid serializing OAuth resume state
2026-08-13 00:33:10 +02:00
Jakob Wennberg bffa57a565 feat(invoices): bulk Bokfor, per-view filter counts, review-queue draft CTA (#1533)
* feat(invoices): bulk Bokfor, per-view filter counts, review-queue draft CTA

Customer feedback: MCP-created invoices land in Granskning and then sit as
unnumbered drafts that each need individual issuance, and the list filter
gives no signal about where the work is.

- New POST /api/invoices/bulk-book: drafts get an F-number + mark-sent
  semantics (no email) and book inline when the company books at issue;
  sent/overdue unbooked invoices get the deferred /book semantics.
  Sequential loop keeps voucher numbers ordered; per-item Swedish errors.
- Extracted the shared cores into lib/invoices/issue-and-book-invoice.ts
  and lib/invoices/book-invoice-deferred.ts, now used by the per-id
  mark-sent and book routes AND the bulk loop, so they cannot drift.
  Per-id route behavior unchanged (existing route tests untouched, green).
- Invoice list: multi-select with hover-reveal checkboxes (supplier-invoices
  shape), bulkbar with mode-aware action label, ConfirmationDialog with a
  draft/sent breakdown, one aggregate toast. Kontantmetoden hides selection
  entirely.
- ContextPicker: count annotations on every status view via the one shared
  predicate (counts always match rows), active view written back to the URL
  (?status=) for shareable views. No seg/chip row: founder-locked pattern.
- Granskning: after a bulk approve that committed create_invoice ops, the
  summary toast links to /invoices?status=draft to finish with bulk Bokfor.

Verified: npm run lint clean, npm test 13845 passed, npm run check:guards
passed. New tests: bulk-book route (11), issueAndBookInvoice (7),
bookInvoiceDeferred (7).

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

* fix(invoices): bulk-book review findings, deferred drafts, dupes, URL params

- Deferred-booking companies (accrual + defer_invoice_booking): a draft in
  bulk-book no longer gets silently ISSUED (F-number consumed, marked sent,
  invoice.sent emitted) while reporting status 'booked' with a null
  journal_entry_id. The draft branch now requires booksInvoicesOnIssue();
  otherwise the item fails per-row with the new INVOICE_BOOK_DEFERRED_DRAFT
  code (Swedish + English) before the invoice is touched.
- Duplicate ids in one request no longer double-book: the second iteration
  read the stale pre-loop snapshot, passed the already-booked check, and
  minted a voucher the CAS claim then cancelled (cancelled verifikat + gap
  explanation per duplicate). Ids are deduped before the loop.
- Bulkbar: the select-all link is hidden when the current view has no
  selectable rows; "Markera alla (0)" only wiped the existing selection.
- Invoice dialog open/close handlers (new invoice, self-billed, ROT/RUT
  payout) rewrite only their own query keys instead of hardcoding
  '/invoices', so the ?status= view write-back survives them.
- /pending: the "Bokfor utkasten" toast CTA is suppressed for kontantmetod
  and deferred-booking companies where the invoice list offers no draft
  bulk Bokfor (dead end); the neutral hint sentence stays.

Tests: deferred-draft rejection (asserts issueAndBookInvoice never called,
sent invoice in the same batch still books) and duplicate-id dedupe (exactly
one booking call); both fail without the route fix.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 21:02:27 +02:00
Jakob Wennberg 7cf0e34434 feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines (#1534)
* feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines

Booking a tjanstepension invoice (e.g. Avanza) needs the buyer's own SLP
beyond the payable: debit 7533 / credit 2514 at 24.26% of the premium
(SLF 1991:687). The item-based debit-only form could not express the
self-balancing pair, so users had to hand-edit the verifikat.

- new leaf module lib/bookkeeping/slp-lines.ts: SLP_RATE (single source,
  re-exported by the bokslut calculator), isSlpPensionAccount (741x),
  generateSlpLines (7533 D / 2514 K, nets to zero)
- migration adds supplier_invoice_items.apply_slp boolean default false
- registration, cash, and privately-paid generators inject the pair for
  flagged 741x items, mirroring the reverse-charge injection; the balance
  guarantees keep 2440/1930/2893 at exactly the invoice total; the credit
  note generator reverses the pair (7533 K / 2514 D)
- privately-paid balance guarantee now subtracts existing credits so the
  SLP 2514 leg never inflates the owner account
- schema field apply_slp + guards in all create paths (main route, inbox
  convert, v1 REST, pending-operations executor): 400
  SI_CREATE_SLP_INVALID_ACCOUNT on non-741x accounts, 400
  SI_CREATE_SLP_ACCRUAL combined with periodisering
- form: advisory hint on unflagged 741x rows with one-click opt-in and a
  quiet confirmation line when applied; totals box untouched (the invoice
  total stays the payable); AB review preview injects the same pair via
  the same generator for parity
- year-end double-count guard: calculateSarskildLoneskatt subtracts SLP
  already posted to 7533 during the year (floored at zero) so bokslut
  never provisions flagged premiums twice

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

* chore(api-skill): regenerate suppliers reference for apply_slp

The apiskill:check CI gate requires the generated accounted-api skill to
stay in sync with the endpoint registry after the apply_slp addition.

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

* fix(slp): carry apply_slp through v1 routes, MCP staging, preview and credit reversal

Review findings on the SLP PR:

- v1 credit route: SI_FULL_COLUMNS now projects items.apply_slp, so
  createSupplierCreditNoteEntry sees the flag and reverses the 7533/2514
  pair booked at registration (it previously stood forever and the
  year-end netting under-provisioned). The flag is also copied onto the
  created credit-note items for parity with the web credit route.
- v1 mark-paid: the items sub-select now includes apply_slp, so a
  kontantmetoden payment via v1 books the cash entry WITH the SLP pair,
  matching the web mark-paid.
- v1 GET ?expand=items: SI_ITEM_COLUMNS includes apply_slp so the flag
  is readable back through the public API.
- credit-note SLP base is abs of the SIGNED sum of flagged line_totals,
  not per-item abs: a mixed-sign flagged original (+10000/-2000) booked
  SLP on 8000 at registration and now reverses exactly that, not 12000.
  The expense-bucket per-item abs convention is untouched.
- kontantmetod bank-match preview appends the same generateSlpLines pair
  the POST books, so the approved lines equal the committed lines.
- MCP gnubok_create_supplier_invoice_from_inbox: line_overrides accepts
  apply_slp (optional boolean), plumbs it into the staged operation's
  items, and rejects non-741x resolved accounts at staging time with the
  bilingual SI_CREATE_SLP_INVALID_ACCOUNT texts.
- DECISIONS.md: five entries for today's decisions.

Every behavioral fix has a test verified to fail without it.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 20:52:47 +02:00
Mattsson c35b2547fb feat(webshop-orders): Orders page with per-store, per-payment-method booking (#1525)
* feat(webshop-orders): schema, types and error codes for the orders surface

webshop_orders (order/refund rows, financial-freeze trigger, member
select/update RLS, no DELETE) + webshop_store_settings (per-store payment
method -> account map), source_type 'webshop_order', multi-store index drop,
customer_country, and a one-time woo cursor reset so the switch-over
backfills and cross-marks existing feed rows. Tables classified in the
full-archive export; pg-real coverage for RLS, freeze and CHECK.

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

* feat(webshop-orders): core service (ingest, booking lines)

upsertWebshopOrders: two-phase order/refund upsert with FX enrichment,
legacy-feed cross-marking, frozen-row protection and field-wise jsonb
comparisons (Postgres does not preserve object key order). Booking-line
builder: per-rate VAT split with SIGNED buckets (discounts book as revenue
reductions), refund mirroring, 3740 residual, per-store account prefill,
and advisory export/EU + OSS warnings.

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

* feat(webshop-orders): API routes for list, booking, invoicing and mapping

Booking is draft -> atomic claim -> commit (conditional link-back closes the
concurrent double-book race; a lost claim cancels the voucher-free draft).
Legacy-feed guard honors transactions.is_ignored on both the book and
create-invoice paths. Invoice conversion reuses buildInvoiceWriteData for an
unnumbered draft with dominant-rate fallback and drift-safe unit prices.

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

* feat(webshop-orders): Orders page, booking/invoice dialogs and gated nav

/orders lists per-store orders with status tabs (server-side filters),
exception chips and one action per row. Booking dialog prefills from the
per-store payment-method mapping with an opt-in remember; invoice dialog
converts to a draft kundfaktura. The Order nav item renders only for
companies with an active WooCommerce connection or existing order rows
(Shopify deliberately excluded until its sync writes webshop_orders).

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

* feat(woocommerce): switch the order sync to webshop_orders, multi-store

The sync maps rich wc/v3 payloads (billing, line/shipping/fee taxes, refund
allocations with parent-prorated VAT fallback) and upserts order rows
instead of transactions-inbox rows; already-imported feed rows stay
bookable and get cross-marked. Multi-store: several active connections per
company, per-store panel cards with the account-mapping editor.

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

* docs(webshop-orders): decision log entries and ratchet baseline

Baseline moves DOWN only: naive-ore-round 638 -> 637 via roundOre adoption;
hand-rolled invariants stay at 115 (ACCOUNT_NUMBER_RE imported, not inlined).

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

* fix(webshop-orders): resolve PR #1525 review findings and CI failures

Review batch (Superagent, CodeRabbit, Swedish compliance review):
- Mutual-exclusion claims: booking guards invoice_id, invoice link-back
  guards journal_entry_id AND treats zero matched rows as the conflict it
  is (409 + rollback), closing both TOCTOU races.
- Freeze v2 migration (20260812124858): the link columns themselves are
  protected: invoice links immutable, journal links clearable only while
  the entry is still a draft (the booking rollback path).
- Scraped orgnr no longer auto-written to customers.org_number; rate
  fallback applies only on single-VAT-bucket orders; refunds get their own
  WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE code; VAT advisories outrank the
  invoice-mode hint in the booking dialog.
- Ingest compares every synced field (billing corrections no longer drop
  as unchanged); sync guards absent refunds arrays; /sync aggregates
  per-store results; panel disables all cards while a request runs; orders
  page separates load failure from empty; account field explains itself.

CI: regenerated skills/accounted-api; pg tests restructured for
transaction-abort/rollback semantics + freeze-link coverage; unresolvable-
expression ceiling 375 -> 378 with documented reason (partial-update
payloads in ingest, shapes covered by unit tests).

Declined: CodeRabbit docstring-coverage advisory (house style: comments
only where the code cannot say it).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 15:08:13 +02:00
Jakob Wennberg 576a34750a feat(payments): payment-batch API surface (betalfil 2/3) (#1504)
Preview/create/list/get/file/cancel routes under
/api/supplier-invoices/payment-batches, SI_BATCH_* structured errors,
and a fail-closed batch-membership pre-check in the supplier invoice
DELETE route (the FK RESTRICT is the backstop). File downloads stamp
file_generated_at + download_count but regenerate byte-identically.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 20:00:42 +02:00
Mattsson 39f4ecdad4 fix(providers): surface migration step errors; INK2 SRU 7104; non-modal invoice dialog (#1465)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export

New mileage_trips table (RLS, booked-delete trigger per BFL retention),
lib/mileage service reusing the payroll schablon rates, /api/mileage routes
(trips CRUD, period booking to 7331, salary-run push, körjournal CSV),
Körjournal dashboard page + nav, and three staged MCP tools (search-only
catalog). Trips book as one verifikat per period via the engine; salary
path inserts mileage_taxfree line items. mileage_trips classified in the
full-archive export.

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

* refactor(mileage): use shared roundOre helper per tightened ratchet baseline

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

* fix(mileage): pending_operations op-type migration + Swedish review findings

- New migration pair adds log_mileage_trip/book_mileage_period to the
  pending_operations operation_type CHECK (pg-real audit).
- bookMileagePeriod refuses a period spanning several employees and names
  the employee in the verifikationstext when scoped (BFL motpart).
- vehicle_registration required for förmånsbil trips (schema, service,
  MCP staging, UI surfaces the field).

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

* fix(mileage): claim-first booking, CSV injection guard and driver column

- bookMileagePeriod claims trips (draft to booked CAS) before creating the
  verifikat, so a concurrent second booking loses the race instead of
  double-booking; claim reverts if verifikat creation fails.
- Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a
  Förare column naming the employee per trip.

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

* fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening

- Copying a round trip no longer re-doubles the stored distance.
- pushMileageToSalaryRun claims trips before inserting line items (retry can
  no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races.
- Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration
  20260807113215): only claim/link/revert transitions and notes edits pass.
- Cross-year periods rejected (schablon rates are per calendar year); payroll
  config year read from the date string, not TZ-dependent getFullYear().
- MCP staged bookings freeze the previewed trip set (trip_ids in params) and
  the commit fails on drift; validation errors return 400, not 500.
- PATCH enforces the förmånsbil regnr rule on the effective row; export
  validates dates before they reach the Content-Disposition header; employee_id
  is verified company-scoped on trip creation; stale orphaned claims released.
- UI: fetch flags reset in finally; ICU plural for draft summary; distance
  stored at the column's 1-decimal precision.
- Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift,
  cross-year and update-trigger pg cases.

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

* fix(mileage): revert-to-draft must clear salary_run_id at the trigger level

New migration 20260807114924 replaces the booked-immutability function: a
booked -> draft revert now rejects rows keeping salary_run_id, closing the
DB-level double-pay path CodeRabbit flagged. pg test pins both directions;
the CLAIM_LOST unit test now asserts the revert.

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

* fix(mileage): company-scope employee_id on PATCH (Superagent P2)

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

* test(mileage): valid v4 uuid in cross-company employee PATCH test

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

* fix(providers): surface migration step errors instead of silent empty syncs

A Visma company without the API module activated (403 ErrorCode 4002,
"No access to module: api_standard") failed every provider call during
migration, yet the wizard reported success with zero rows and mapped the
403 to "reconnect", which loops forever since OAuth succeeds against
Visma's shared identity server. A real user burned time re-syncing and
reconnecting, then filed the config issue as a bug.

- New PROVIDER_API_MODULE_INACTIVE code; classifyProviderError reads the
  error body and recognizes the module error before the 403 to
  AUTH_EXPIRED mapping. Registry entry carries the remediation in
  Swedish and English (activate the API under Appar och tillagg, paid
  add-on on smaller plans, clear standardforetag, SIE fallback).
- Orchestrator: connection-level failures (auth expired, license
  missing, module inactive) rethrow and abort the doomed run so /migrate
  answers with the typed code; other step failures stay non-fatal but
  land on results.stepErrors instead of only in server logs.
- /preview fails fast on the two subscription codes so the user reads
  the remediation at connect time, before any sync.
- Wizard: preview treats the new code like the Fortnox license case
  (CTA + SIE fallback); the result step renders error cards per cause
  and says "Migrering delvis genomford" instead of "Allt ar uppdaterat";
  the completion toast is honest on partial failure.

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

* fix(ink2): SRU field 1.1 is 7104, not 7113 (Skatteverket rejects 7113)

The INK2 huvudblankett code for 1.1 Overskott av naringsverksamhet is
7104 per Skatteverket's official 2025P4 faltkoder (INK2_SKV2002-33-01-24-04).
We emitted 7113, which does not exist on INK2, so filoverforing rejected
every profitable company's BLANKETTER.SRU with 'UPPGIFT 7113 ar inte ett
giltigt postnamn' (reported by a user for FY 2024-10-07..2025-12-31).
Underskott (7114) was already correct.

The wrong code originated in the swedish-sru-filing skill reference;
fixed there too and regenerated the atom seed. All other emitted
INK2/INK2R/INK2S codes verified against the official 2025P4 lists.

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

* fix(invoices): keep the AI chat usable over the new-invoice dialog

The new-invoice dialog was a modal Radix dialog: modal mode sets body
pointer-events: none, aria-hidden on body siblings, and a focus trap, so
the agent sheet (z-60, painted above the dialog) was visible but dead:
clicks swallowed, input unfocusable, and all three dismiss paths
preventDefaulted, leaving no way out except the header X.

Now non-modal: page modality is restored by hand instead. A new
DialogVeil primitive supplies the backdrop (Radix renders no overlay in
non-modal mode) at z-40, under dialog content (z-50) and the agent sheet
(z-60), and inert on #dash-shell blocks pointer, keyboard, and AT access
to the page behind while the sheet (a body-level sibling) stays live.
The lazy-load fallback dialog on /invoices gets the same treatment so a
hung or 404'd chunk cannot dead-lock the route.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 16:04:58 +02:00
Jakob Wennberg 8f38baca05 fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults (#1422)
* fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults

K2 companies (BFNAR 2016:10 punkt 10.4) may not capitalize internally
developed intangibles, but the asset register defaulted the immaterial
category onto 1010/1019 (Utvecklingsutgifter) for everyone and had no
framework gate beyond K3_REQUIRED_FOR_COMPONENTS.

- New K2_EXCLUDED_ACCOUNT gate (422) in POST /api/assets and PATCH
  /api/assets/[id]: when accounting_framework is not k3, reject any asset
  whose resolved asset or accumulated account is flagged k2_excluded in
  the BAS reference. Resolution mirrors the service defaults so category
  defaults cannot sneak onto 1010/1019; patches that leave category and
  accounts untouched skip the gate so legacy assets stay editable.
- Shared guard helper in lib/bokslut/assets/k2-account-guard.ts; code
  registered in structured-errors.ts with Swedish and English messages.
- CreateAssetDialog: non K3 companies now book immaterial assets on the
  purchased pair 1090/1099 with a quiet hint that egenupparbetad
  utveckling requires K3; K3 companies picking immaterial see a note
  about fond for utvecklingsutgifter (2089) per ARL 4 kap. 2 par.
- Route tests: K2 rejected on 1010 defaults and explicit overrides, K2
  accepted on purchased accounts, K3 accepted on 1010, PATCH equivalents
  and a gate skip regression test.

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

* fix(assets): cite punkt 10.4 only when the intangible group triggered the K2 gate

The K2 gate fires on ANY account the BAS chart flags k2_excluded, but the
rejection hardcoded an egenupparbetade immateriella / BFNAR 2016:10 punkt 10.4
citation. The flag also covers accounts excluded from K2 for unrelated reasons
(1370/2240/8940 uppskjuten skatt, 1518, 2089, 2092, 2096, 2448, 3940, 7940,
8290 to 8480), so those users got a factually wrong legal citation in a
compliance product. PATCH can reach them today: UpdateAssetSchema has no BAS
range refinement, so an explicit bas_asset_account override outside the
category range hits the gate before updateAsset() raises its range error.

- k2ExcludedAccountMessages() now picks the wording from what actually
  triggered the gate. The boundary is derived from the chart itself
  (k2_excluded + account_class 1 + kontogrupp 10), which is exactly the
  egenupparbetade set 1010, 1011, 1012, 1018, 1019, 1081; no magic list, so a
  flag change in bas-data moves the boundary with it. Other Ej K2 accounts get
  a generic message: the chart marks it Ej K2 and it requires K3, with no
  invented paragraph reference.
- Both messages are bilingual (message_sv / message_en, registry shape) and
  the routes now return message_en alongside message.
- The static K2_EXCLUDED_ACCOUNT registry entry drops the intangible citation
  too: it is the code level fallback for every k2_excluded account.
- Tests: route level distinction pinned in id.test.ts (1010/1081 cite 10.4,
  1370 must not), plus a guard unit test asserting the derived group and that
  no non group 10 Ej K2 account ever cites 10.4.

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

* fix(assets): let K2 companies register acquired intangibles, server side

The K2 gate blocked a lawful case. K2 forbids only EGENUPPARBETADE
immateriella tillgangar; acquired ones may be recognized (k2-vs-k3.md:24,
"Only acquired intangibles may be recognized"). But asset-service still
resolved category 'immaterial' to 1010/1019 for everyone, and only
CreateAssetDialog compensated with an explicit 1090/1099 override.
EditAssetDialog sends just the changed fields and has no account inputs, so a
K2 aktiebolag recategorizing a bought licence to "Immateriell tillgang" hit
the defaults, got a 422, and was told to switch the company to K3, which
would pull in komponentavskrivning and uppskjuten skatt and rewrite the whole
arsredovisning. The asset stayed on 1220/1229 and kept being presented as a
tangible asset.

- defaultAccountsForCategory(category, framework) is the single resolution
  point: immaterial resolves to the acquired pair 1090/1099 unless the
  framework is k3, every other category is unchanged. Both createAsset() and
  updateAsset()'s category realign go through resolveDefaultAccounts(), which
  reads companies.accounting_framework only for the intangible category and
  throws rather than guessing when that read fails. Explicit overrides and the
  realign-skip semantics are untouched.
- Both routes resolve gate accounts through the same function, so the check
  mirrors what the service will persist. A K2 company on the defaults now
  passes; a deliberate override onto 1010/1011/1012/1018/1019/1081 still 422s.
- CreateAssetDialog drops its now redundant client override so the two
  surfaces cannot drift; the hint text stays.
- The 422 no longer asserts the company's framework (the companies read
  behind it discards its error, so a transient failure would assert it against
  a K3 company) and no longer proposes a regelverk change. It states that the
  account is reserved for egenupparbetade utvecklingsutgifter, which require
  K3, and points at 1090 for an acquired intangible. Punkt 10.4 stays scoped
  to the kontogrupp 10 group, derived from the chart as before. sv and en.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:04:58 +02:00
Jakob Wennberg 17d48ebb84 fix(invoices): guard cash-method partial payments across every payment path (#1413)
* fix(invoices): guard cash-method partial payments across every payment path

A never-booked kontantmetoden invoice can only be settled by the generated
cash entry (createInvoiceCashEntry / createSupplierInvoiceCashEntry), and
that entry always books the FULL invoice: it takes no payment amount. Three
payment surfaces still let partial payments through to it, corrupting books:

- settleInvoicePayment dropped the fully-paid term entirely, so a partial
  payment (Stripe sync, mark-paid) booked the entire invoice: over-recognized
  revenue, over-declared output VAT, and a bank debit that did not match the
  money received.
- The dashboard and agent match-transaction paths fell back to an
  accrual-style clearing entry against an EMPTY 1510: negative receivable,
  no revenue, no moms (ML 13 kap 8 § puts each installment's moms in its own
  receipt period). The comment claimed the credit "gets resolved on final
  payment", but the cash builder never touches 1510 and books the full
  total, so the final payment double-debited the bank instead.
- The supplier routes had no full-settlement term at all, so a partial
  payment booked the full expense + input VAT.

Fix: one shared predicate (cashPartialBlockReason in booking-mode.ts)
rejects generated cash entries unless the payment settles the invoice in
full from a fully unpaid state, wired into all six POST surfaces, the agent
commit paths, and the three preview routes (so dialogs cannot propose a
verifikat the POST refuses). New bilingual error codes
INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED / SI_CASH_PARTIAL_UNSUPPORTED.
Invoices booked at issue are unaffected: their partial payments keep the
normal 1510/2440 clearing path.

The v1 match-invoice route already had this guard (VALIDATION_ERROR); its
behavior is unchanged. Proper per-installment recognition (proportional
revenue + moms per receipt) is the follow-up feature that would lift this
restriction.

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

* docs(review): drop stale ML 13 kap 8 § cites for kontantmetoden VAT timing

Compliance-review finding: the section is the old ML 1994:200 numbering; in
ML 2023:200, 13 kap covers input-VAT deduction, not redovisningstidpunkt.
The substantive rule (bokslutsmetoden reports moms at payment, per
installment, except at year-end) is unchanged and stated without a section
cite until the current-law section is verified. Comments and cookbook prose
only; no behavior change. Also fixes the two pre-existing occurrences.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:05:11 +02:00
Mattsson cb3ef45f14 feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse) (#1391)
* feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse)

Disposal books depreciation to the disposal date, clears cost and
accumulated depreciation, books gain (3973) or loss (7973), applies
output VAT on third-party sales, honors the ML 5 kap. 38 §
verksamhetsoverlatelse exemption, and recalculates ML 15 kap. jamkning
server-side from tax years and original input VAT. The voucher, the
disposal-date depreciation schedule and the immutable register state
commit in one dedicated commit_asset_disposal RPC transaction that
delegates voucher numbering to commit_journal_entry.

Fixes #325

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

* fix(assets): harden disposal per review and pg-real findings

- commit_asset_disposal now uses the NULL-safe caller_is_company_member()
  guard (tenant-guard ratchet) and passes the allowed 'user_accept'
  commit_method instead of the unlisted 'asset_disposal' value
- disposal metadata invariants validated in the RPC (non-negative
  proceeds/VAT, VAT requires a treatment, VAT <= gross, scrap carries no
  proceeds) since the RPC is independently callable
- new FK and CHECK constraints added NOT VALID + VALIDATE CONSTRAINT so
  the migration never blocks writes on the hot journal_entries table
- disposeAsset paginates fiscal periods and depreciation schedules with
  fetchAllRows; jamkning_remaining_years keeps a valid 0 (?? not ||)
- engine imports shared AssetDisposalType/AssetJamkningDirection/
  VatTreatment unions; post-commit reload retries once and logs before
  surfacing, so a transient read cannot masquerade as a failed disposal
- dispose page parses Swedish-formatted amounts (125 000,50) and blocks
  submission on unparseable proceeds
- assets pg tests write disposal attributes in the disposal transition
  itself and gain a regression test that the register is frozen after

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 09:59:52 +02:00
Jakob Wennberg 0f1c7c9365 fix(import): refuse a Bokio connection that opens a different company (#1315)
A Bokio integration token is scoped to one Bokio company and the company id is typed in by hand, so credentials for the user's other company imported that company's customers, suppliers and invoices with no error at all. Probe /companies/{id} before storing, mirroring the Bjorn Lunden /details probe, and refuse on a confident org-number mismatch.

Also surface the inbox mail body when nothing was attached: it was captured in email_body_text and never read back, which made Gmail's forwarding-confirmation mail unreadable and the forward impossible to complete.
2026-07-30 19:07:40 +02:00
Jakob Wennberg f3bf50d862 fix(invoices): roll back the header row when a recurring-schedule item replace fails (#1312)
* fix(invoices): roll back the header row when a recurring-schedule item replace fails

PATCH /api/invoices/recurring/[id] and the update_recurring_schedule commit
executor wrote the schedule header first, then replaced the items. An item
insert failure restored the items snapshot but left the header update
committed, so a combined edit half-applied: a new day_of_month or
default_dimensions stayed while the line edit was undone.

Both write paths now go through one shared helper,
lib/invoices/apply-recurring-schedule-update.ts, which snapshots the header
before writing it (only for a combined edit, the only case with something to
undo) and compensates it on any items failure. The rollback update is filtered
on the updated_at stamp our own write produced, so a concurrent writer (the
hourly cron, a second edit) wins instead of being clobbered from a stale
snapshot: audit finding C2 in lib/invoices/voucher-matching.ts.

A compensation that itself fails is no longer swallowed. The helper reports
itemsRestored / headerRestored, logs the unrecoverable rows and the intended
restore payload, and both call sites then return the new
INVOICE_RECURRING_UPDATE_PARTIAL registry entry, which tells the user in
Swedish that the schedule may be half-saved and to check fields and items
before retrying. A clean rollback keeps the PG-mapped error so a CHECK
violation still surfaces its specific message.

Also in the rewritten block:
- the items DELETE error is checked, so a failed delete no longer proceeds to
  an insert that would duplicate every line;
- the 404 existence check moved above every write, so a PATCH with items for a
  missing or cross-tenant id writes nothing;
- the items snapshot uses select('*') with id/created_at stripped on restore
  (same idiom as replaceInvoiceItems), so a column added later is carried
  through instead of silently dropped;
- NewRecurringScheduleDialog unwraps the nested { error: { message } } envelope
  the route returns, which otherwise reached the toast as "[object Object]".

The cron's no-empty-items invariant holds on every failure path: the items are
either untouched, restored, or the failure is reported explicitly.

Fixes #1275

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

* fix(invoices): never write when the compensating snapshot is unavailable

Follow-up on the recurring-schedule rollback: the helper still performed two
writes it already knew it could not compensate.

- The header snapshot read now checks its error and a missing row, and the
  header UPDATE is skipped entirely when either holds, so no header change is
  committed that we already know can never be rolled back.
- An unreadable item snapshot now aborts BEFORE the delete (rolling the header
  back) instead of deleting first and reporting itemsRestored: false, so the
  cron invariant "a schedule always has items" holds on every failure path.
- That header read now runs whenever items are replaced and is scoped by
  company_id, so it doubles as the ownership proof the schedule_id-only item
  delete/insert lacks (the commit executor runs with RLS off). Stated in the
  JSDoc as well.
- The item snapshot is paginated via fetchAllRows: a schedule with more than
  1000 lines could otherwise restore partially while reporting a clean
  rollback.
- The executor now returns errorCode INVOICE_RECURRING_UPDATE_PARTIAL,
  surfaced as CommitResult.code and persisted as result_data.error_code, so a
  staged-op caller can detect the partial state without substring-matching the
  Swedish sentence.
- Route: details keys are camelCase throughout, and an item failure is logged
  once, with the repair context kept on the partial path only.

Tests: the unreadable-snapshot branches are exercised (including the
previously unused itemsSnapshotError harness hook), and the test that pinned
"header written with no possibility of rollback" now asserts that nothing is
written at all.

Fixes #1275

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:50:56 +02:00
Mattsson 65c6d4c178 Fix/07 27 (#1271)
* fix(enable-banking): keep bank account mappings across reconnects and surface dead sessions

A PSD2 reconnect silently moved the user's ledger mapping. Account identity
came from the provider's account uid, which does not survive a
re-authorization at every ASPSP, and a fresh connect to an already-connected
bank mints a new bank_connections row regardless. Both paths looked like "an
account we have never seen", so the allocator handed out the next free 19xx
slot and a 1930/1940/1941 mapping came back as 1942-1946 on every consent
renewal, roughly quarterly per connection.

Match on the IBAN instead. resolvePsd2LedgerAccount() finds the existing
cash_accounts row by normalized IBAN before allocating, and upsertFromPsd2
promotes that row in place rather than inserting a second one, so it keeps its
id and its linked transactions and is re-pointed at the connection that just
authorized. The previous holder's connection status is deliberately ignored:
one IBAN is one physical account, and the old row often still reads 'active'
because the bank killed the session without telling us.

The allocator also stopped treating a 19xx number as free just because no
cash_accounts row holds it. A chart imported from SIE carries the company's
real bank accounts by name with no PSD2 row behind them, which is how a SEK
company account got proposed as an unrelated brokerage account. Overflow now
skips chart-occupied numbers, falling back only when nothing unnamed is left.

Dead connections kept rendering as "Aktiv": status only ever changed when a
transaction fetch failed, so a session killed bank-side stayed healthy-looking
with a stale last_synced_at while the user read old balances as current. Add
probeSessionHealth() and run it in the daily cron over every connection that
run did not prove alive, including the ones the loop skips silently
(capability gate, all accounts deselected) and the ones parked in
pending_selection that the cron never looked at. It acts only on a definite
dead answer; anything ambiguous leaves the row alone, since a wrong flip costs
a full BankID re-authorization. The all-accounts-deselected branch is
reclassified 'synced' to 'skipped' for the same reason: it never contacts the
bank, so it must not count as proof of life. The settings row warns when an
active connection has not synced in three days or has never synced.

Which company a connection belongs to was invisible. Everything was already
scoped to ctx.companyId, so there was no cross-tenant leak, but a bank
authorized while the wrong company was active looked identical to the right
one. Name the company on the connect surface and in the account picker, and
say where the connection went when the callback lands under a different active
company. Warn (bypassably) before authorizing a bank where the same user
already holds live connections in other companies: several ASPSPs allow one
active AIS session per login, so the new authorization can kill the others.

The history start date already defaulted to the fiscal-year start; the card
above it recommended a mid-year date and contradicted the selected option. It
now states the fact and offers the shortcut without presenting it as advice.

Not addressed: sharing one PSD2 session across companies. company_id is the
tenancy anchor on bank_connections and cash_accounts hangs off
(company_id, bank_connection_id), so that needs the session to become its own
entity. See DECISIONS.md.

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

* fix(supplier-invoices): show the posted line description in the voucher preview

The "Verifikation som bokförs" preview built its expense debit lines with
description set to the raw account number, so the BESKRIVNING column showed
"5615" or "6990" where the posted verifikat actually says "Leverantörsfaktura
123, ACME AB". A hardcoded 11-entry ACCOUNT_LABELS map masked this for
2440/2641/26xx, which is why the column read as a mix of friendly labels and
bare account numbers, neither of which was the posted text.

The preview now renders exactly the line_description the engine writes: the
shared invoice-level text on expense lines and 2440, "Ingående moms {rate}%
{desc}" on 2641, and the reverse-charge pair taken straight from
generateReverseChargeLines instead of being re-derived locally.
buildSupplierDescription moves into its own dependency-free module so the
client-side preview can call it without pulling the journal engine (and its
Supabase server client) into the browser bundle. The account name stays
reachable on the AccountNumber hover card.

Picked option A from the issue, keeping the fixed invoice-level description
rather than propagating each item's own text: the customer-invoice side
already writes invoice-level descriptions, so per-item text would create an
inconsistency between the two invoice sides rather than remove one, and it
would need an aggregation-collision policy in the journal engine. Rationale
recorded in DECISIONS.md.

Refs #1258

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

* fix(bookkeeping): restore the copy icon on verifikat rows

The row-language rewrite in #1123 reused the copy icon's slot for the new
expand toggle, removing the zero-click copy affordance from the bookkeeping
list without mentioning it. The leftover orphaned copy_voucher_tooltip key
in both message files is what identifies it as collateral rather than a
product decision.

Restore a copy icon in the row's right-edge action cell, reusing that key
for aria-label and title. stopPropagation keeps the click off the row's
expand toggle. The icon is hover-revealed on md+ and always visible below
it: #1123 collapsed the desktop table and the mobile card into one
responsive table, so hover-only would leave touch users with nothing.

Copy is no longer gated on posted. The copy_from handler and the GET
journal-entries route never looked at status, so copying a draft already
worked end-to-end and only the detail-page button hid it; the two list
surfaces were already ungated. Both list affordances now respect canWrite,
which previously dropped read-only users into a dialog they could not
submit.

The repo does not render components in tests, which is why #1123 removed
this silently. Pin the source shape instead, the same way the copy-invoice
query is pinned.

Closes #1266

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

* fix(transactions): revalidate stale invoice match pointers before offering a match

potential_invoice_id / potential_supplier_invoice_id are written once, at bank
import, and never revisited. When one of several identical recurring invoices
was settled by a different transaction, every other transaction kept pointing
at the now fully paid invoice. The match dialog then measured the bank amount
against a 0 kr remaining balance and reported a "Beloppen skiljer sig ...
fakturan blir delbetald" partial payment, and the worklist offered the same
dead suggestion as a one-click confirm row.

Worse, the manual escape hatch was hidden exactly when it was needed:
TransactionInboxCard only shows "Matcha mot leverantörsfaktura" when no
suggestion exists, so a stale pointer left the user with no way at all to
reach the correct invoice.

Fixed by revalidating at read time rather than by clearing sibling pointers on
settle. Invoices are settled through many paths (both match routes, mark-paid,
MCP, bank reconciliation, SIE import), so write-time cleanup leaks the moment
one is missed, while the candidate lookup covers every route into the list.
The shared accept-lists in lib/invoices/matchable-statuses.ts mirror the CAS
guards the match routes already enforce.

  - listSuggestedMatches and the transactions page candidate fetch filter on
    status + remaining_amount, so a settled candidate yields no suggestion and
    the manual picker reappears on its own.
  - InvoiceMatchDialog blocks a settled target with a distinct message and a
    disabled confirm. Not advisory: both routes reject it outright with
    MATCH_INVOICE_ALREADY_PAID / MATCH_SI_ALREADY_PAID, so no override could
    succeed.
  - The supplier detail card now shows remaining_amount like the customer
    branch, instead of total. On a partially paid invoice it used to print
    "1 250 kr" directly beside "Differens: 1 250 kr".
  - match-supplier-invoice clears potential_supplier_invoice_id on the
    transaction it just matched, mirroring the customer route.

No bookkeeping was ever at risk: both routes already refused a settled target
before creating a voucher. The damage was confined to a misleading dialog and
a dead end.

createQueuedMockSupabase gains passive call recording (calls / findCall /
findCalls) because the proxy swallowed filter and update arguments, which made
the new assertions inexpressible.

Refs #1259, #1260

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

* feat(webhooks): dispatch on emit instead of waiting for the next cron tick (#1256)

* feat(webhooks): dispatch on emit instead of waiting for the next cron tick

The webhook dispatcher ran only on a per-minute cron, so the floor on
delivery latency was up to 60 seconds plus the request. An external consumer
that wanted to react as a transaction landed had only one alternative:
polling /api/events, which the 100 rpm per-key limit makes expensive and
which still cannot beat the tick interval.

Schedules one dispatch cycle as soon as deliveries are enqueued. The cron is
unchanged and remains the retry and sweep path; this only moves the first
attempt forward. Wired into the event-bus fanout plus the two routes that
enqueue a delivery directly: the :test verb, whose entire purpose is telling
someone whether their receiver works, and the manual delivery retry.

Three properties are load-bearing and covered by tests. The kick is never
awaited, because eventBus.emit is awaited at ~99 call sites including
journal_entry.committed and each delivery can burn a 10 s receiver timeout.
It coalesces per function instance, so a bulk booking that emits once per row
does not schedule one claim round trip per row. It claims 5 rows rather than
the cron's 50, because it runs on the tail of a user-facing request.

Double delivery is not a risk: claim_due_webhook_deliveries already claims
FOR UPDATE SKIP LOCKED and flips rows to in_flight in the same statement, so
a kick racing the cron sees disjoint rows.

Does not close #1201, which asks for a realtime stream for API consumers.
This is the cheap half.

Refs #1201

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

* docs(webhooks): stop claiming the kick makes double delivery impossible

Adversarial review of the previous commit caught an overstatement in its own
comments. SKIP LOCKED keeps a kick and the cron from claiming the same row at
the same moment, but claim_due_webhook_deliveries autocommits before any POST
is issued, so from then on ownership is only status='in_flight' and a later
cycle's recoverStuckInFlight sweep can re-arm a row still queued behind an
earlier cycle's serial loop.

Delivery is at-least-once, which is what the public docs already tell
receivers ("the same delivery id may arrive more than once ... idempotency is
on you"). The comments contradicted that.

No behaviour change. The kick does not create this window: the cron claims 50
rows serially against the same 20 s stuck threshold, which is wider than what
a batch of 5 can open.

Refs #1201

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base (#1253)

* fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base

sumPostedYearEndDispositions reconstructs resultat fore skatt for the tax
calculation, because generateIncomeStatement excludes every
source_type='year_end' entry. It summed class 88 and 7533 but not 78xx, so
planenlig avskrivning posted by the bokslut flow
(lib/bokslut/assets/depreciation-engine.ts) was dropped from the income
statement and never added back. The bolagsskatt base and the
periodiseringsfond 25 % cap were therefore computed on an overstated result:
tax too high by roughly 20.6 % of the depreciation.

Also exclude the period's final bokslutsverifikation from the fetch. It
carries source_type='year_end' as well and reverses every P&L account,
78xx/88xx/7533 included (verified against production closing entries), so
once the year is closed it would cancel the add-back this function exists to
produce. That hazard already applied to 88xx and 7533; the fix closes it for
all three rather than widening it.

Scope is deliberately the tax base only. Making the standalone
resultatrakning show bokslut entries is a separate, larger change: the same
exclusion is duplicated in the kpi_report_aggregates RPC, it moves displayed
profit for every company that ran the bokslut flow, and it means removing
the add-back at four call sites.

Refs #1051

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

* fix(bokslut): scope the closing-entry lookup to the company and fail loudly

Review (CodeRabbit + the compliance swarm, ASVS V8.2.1) flagged the new
fiscal_periods read in sumPostedYearEndDispositions on two counts, both fair.

It filtered only on the period id while every sibling query in the same
function carries the tenant scope. Primary key or not, service-role paths
have no RLS to fall back on and the repo's rule is to filter company_id
explicitly, so it now does.

It also discarded the query error. That mattered more than it looks: a failed
read fell through to closingEntryId = null, which silently re-admits the
closing verifikat's 78xx/88xx reversals and understates the tax base, i.e.
exactly the failure this lookup was added to prevent. It now throws, and the
surrounding catch turns it into the existing 'Failed to read posted
dispositions' error. A wrong bolagsskatt is worse than a loud failure.

Two regression tests: the lookup carries both eq filters, and a lookup
failure propagates instead of degrading to a wrong number.

Refs #1051

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(storage): drop the client-side DELETE policy on the documents bucket (#1254)

* fix(storage): drop the client-side DELETE policy on the documents bucket

20240101000024 documents this bucket as WORM: "No UPDATE or DELETE policies".
That described the repo, not production. Production carries a
users_delete_own_documents policy that exists in no migration file:

  FOR DELETE TO authenticated
  USING (bucket_id = 'documents'
         AND (storage.foldername(name))[2] = auth.uid()::text)

Under it, the uploading user can delete the storage bytes of any document
they uploaded under the legacy documents/{userId}/... layout, using nothing
but their normal browser token. That includes documents linked to a posted
verifikat, which are rakenskapsinformation under the BFL 7 kap 2 § seven-year
retention duty. deleteDocument()'s linked-check and the
block_document_deletion() trigger both guard the document_attachments ROW,
not the object: the row survives, still pointing at a file that is gone.

Reproduced against a local replay of the full migration stream: with the
policy present the uploader's own DELETE removes the object; with it dropped
the same statement matches zero rows. Company-scoped keys were never exposed
(their second path segment is the company id, not auth.uid()), so this only
ever reached the legacy layout, which is where most documents still live.

Safe because every in-app remove() on this bucket already runs on the service
role, covered by service_role_all_documents.

Deliberately narrow: users_read_own_documents and users_upload_own_documents
stay. The Phase B backfill from 20260726092000 has not run, so dropping the
legacy SELECT policy now would make existing documents unreadable. That is
Phase C.

The pg-real test asserts no DELETE and no UPDATE policy over the bucket under
ANY name: the hole arrived under a name this repo never used, so pinning a
name would not have caught it.

Refs #1208

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

* test(storage): make the WORM ratchet see FOR ALL and WITH CHECK policies

Review caught two blind spots in the ratchet, both fair. It matched only
polcmd 'd' and 'w', but polcmd '*' (FOR ALL) grants DELETE and UPDATE just as
effectively, and FOR ALL is the shape the one legitimate policy on this table
already uses, so a hostile one would look unremarkable in the catalogue. It
also read only polqual, so an UPDATE policy carrying its bucket restriction in
WITH CHECK was invisible.

Both assertions now run through one helper that covers d/w/*, concatenates
USING and WITH CHECK, and filters by grantee so service_role_all_documents
(how the application does its authorized deletes) is excluded while every
client-reachable role is not. A policy granted to PUBLIC has an empty
polroles, which is the most permissive case there is, so it is treated as
client-reachable rather than as "no roles".

Matching on the substring rather than the exact `bucket_id = 'documents'`
shape pg_get_expr emits today: a policy written as bucket_id::text or with the
comparison reversed would slip past a stricter match, and for a WORM ratchet a
false alarm is cheap while a silent hole is not.

Adds a probe case that creates a FOR ALL policy and asserts the helper sees
it, so the main assertion cannot pass vacuously. That case earned its keep
immediately: it caught that node-postgres hands back a raw string for a name[]
column, so the role filter needed rolname::text to work at all.

Verified against a local replay of the full migration stream: red with the
original prod FOR DELETE policy present, red with a FOR ALL probe, green
without either. Full pg-real suite 933 passed.

Refs #1208

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

* test(storage): catch a destructive policy that names no bucket at all

Adversarial review of the previous commit found the ratchet still failed
open, and reproduced it: a policy with no bucket_id predicate covers EVERY
bucket, documents included, so gating on the bucket name discarded exactly
the widest hole. The concrete shape is Supabase's own stock "Enable delete for
users based on user_id" template, USING (auth.uid() = owner), which is the
single most likely form of a future dashboard edit.

A destructive policy is now in scope unless it provably cannot reach this
bucket, i.e. only a bucket_id predicate naming some other bucket exempts it.

The behavioural assertions had the matching blind spot: fixtures were seeded
without an owner, so an owner-based policy matched NULL and the DELETE
reported 0 rows for the wrong reason. Objects now carry an owner the way
storage-api stamps them in production, so those tests fail loudly instead of
passing by accident.

Two probes pin both directions: a bucketless policy must be reported (and is
shown to really permit the delete), and a policy scoped to another bucket must
not be, so the ratchet cannot start crying wolf on receipts or sie-files and
get switched off.

Verified against a local replay of the full migration stream: red with the
stock bucketless template installed, green without it. Full pg-real suite 935
passed.

Refs #1208

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(kontoplan): make a deactivated account reachable again (#1262)

is_active=false read as "does not exist" on every read path but as "exists"
on the (company_id, account_number) unique constraint, so a deactivated
account vanished from the kontoplan with no way back and re-creating it
answered "Kontonummer X finns redan i din kontoplan."

The write side was already correct: POST /accounts/activate has a
toReactivate branch and PUT /accounts/[number] accepts is_active:true.
Both were simply unreachable, so this opens routes to them rather than
relaxing the read filters, which are load-bearing for
AccountsNotInChartError.

- Kontoplan gets a "Visa inaktiva" filter; inactive rows carry an "Inaktiv"
  chip and the existing per-row switch reactivates them in one click.
- Deactivating an account that has posted lines now warns first, using the
  usage count already loaded for the Verifikat column.
- POST /accounts distinguishes the two collisions and returns the new
  ACCOUNT_EXISTS_INACTIVE code; AddAccountDialog offers "Aktivera kontot
  istallet" rather than a dead-end 409. The stored account is left exactly
  as it was; values typed into the failed create form are not applied.
- bas-lookup consults the company's own chart before the static BAS
  reference, so a deactivated custom account reads as known and
  "Aktivera och bokfor" is no longer disabled for it. New in_chart /
  is_active fields let callers tell "will be added" from "will be revived".
- BAS-katalog stops showing "Aktiverat" for an account the company holds
  but has deactivated; it falls through to a relabelled Aktivera button,
  and the per-class counts follow.

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

* fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off (#1255)

* fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off

A foreign supplier charging no Swedish VAT is normally omvand
skattskyldighet. With the reverse-charge switch off,
createSupplierInvoiceRegistrationEntry emits neither the 26x4 output leg nor
the 44xx/45xx basis lines, so ruta 20-24, 30-32 and 48 all stay empty and the
momsdeklaration takes a shape Skatteverket rejects. For a fully deductible
purchase the net moms att betala is unchanged, which is exactly why this goes
unnoticed. The form already auto-ticks reverse charge for eu_business but not
for non_eu_business, so that path slips through silently.

Adds a pure helper plus a non-blocking banner cloned from the existing
rc_account_warning block. Deliberately silent for swedish_business, where 0 %
is a genuine exemption that belongs in no ruta at all, and phrased as a
question rather than an assertion: a non-EU goods purchase cleared at customs
is legitimately 0 % without reverse charge, and pushing that user into
ticking the switch would manufacture a new wrong verifikat.

Does not add the exempt/import/other picker the issue proposes:
supplier_invoices.vat_treatment is metadata that no booking or ruta mapping
reads, and the codebase cannot book import VAT at all, so an import option
would imply ruta 50/60 were handled when they are not.

Refs #1042

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

* fix(supplier-invoices): name the local-VAT case in the foreign 0 % hint

Review flagged that the most common foreign document a Swedish small company
sees is an invoice carrying the supplier's OWN local VAT, booked at 0 %
Swedish VAT with reverse charge correctly off. The banner fires there, and
the previous copy only offered "momsfri av annat skal, till exempel en
varuimport" as the way out, which does not describe that invoice at all: it
is not VAT-free, it carries foreign VAT.

Names both legitimate cases explicitly and says 0 % is correct in them, so
the hint cannot read as an instruction to tick reverse charge on a purchase
where that would produce a wrong verifikat. Title also narrowed to "utan
svensk moms" for the same reason.

Refs #1042

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(sandbox): call the sandbox assistant Assistenten, not Anna (#1244)

A named persona earns its name once someone has been through onboarding and
chosen it: it is their assistant and they named it. Nobody in the sandbox chose
anything, so a first name reads as a character the product invented and implies
a relationship the visitor never opted into.

Both halves move together, which is the point. profile_summary is the agent's
own self-description inside the system prompt, so leaving it as "Du är Anna"
would have the header say one thing while the assistant introduces itself as
another in its first sentence. Nothing else in the stack checks that pairing,
so a test now does.

Scope: this changes the seed, so new sandbox companies get the new name. The
483 sandbox profiles already seeded keep 'Anna' (the seeder returns early once a
profile exists, and its caller only runs while verified_at is null). Backfilling
those is a production write on demo data and is being raised separately rather
than smuggled into a code change.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(reports): show the last posted voucher per series in report headers

Adds a "Senaste bokforda verifikat: A 214, B 37" line to the balans- and
resultatrapport, so a printed or exported report answers which vouchers are
actually in it rather than only which dates it spans (#1267).

Reads MAX(voucher_number) over posted entries, never
voucher_sequences.last_number. The sequence counter is an allocation
high-water mark that drifts from the books in both directions:
next_voucher_number burns a number when the follow-up insert fails,
delete_last_voucher decrements by one instead of resetting to the new MAX,
and pre-RPC SIE imports left it behind. Since the point of the line is
avstamning, an allocated number would send a reconciler chasing a gap that
does not exist, so the label says plainly that the number is the posted one.

Scoped to the report own date range, so a Q1 report printed in November says
something true about Q1. The balansrapport keeps the fiscal-year start as its
lower bound because it accumulates. Skipped on a dimension-filtered
resultatrapport: that report already discloses it is partial, and an
unfiltered voucher range beside a filtered result invites the wrong
conclusion.

Populated in both engines, so the JSON, PDF and XLSX routes all inherit it
without signature changes. Best-effort: a header nicety never breaks a
report. The pure formatter lives in its own module so the client view does
not pull the Supabase query path into the browser bundle. No new i18n keys;
both report views and the PDF template are hard-coded Swedish per the
"stays Swedish" report surfaces in .claude/rules/i18n.md.

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

* fix(customers): stop rendering personnummer ciphertext, make unreadable rows editable, add a reveal path (#1263)

customers.personal_number holds AES-256-GCM ciphertext (20260726110000).
Three defects compounded into one broken surface for private customers.

The list queried Supabase from the browser with select('*') and rendered the
raw value, 76-82 chars of hex, into the nowrap identifier cell. It now reads
GET /api/customers, which already masks every row, so the ciphertext never
leaves the server. Searching by personnummer works again: the client filter
had been matching against ciphertext and could never hit.

A row whose value cannot be decrypted renders as the placeholder
'********-????'. None of the three mask checks recognised it, each having its
own '-1234'-only copy, so such a customer could not be edited in ANY field:
name and address edits 400'd on a personnummer the user had no way to
correct. All three now share one pattern from the new crypto-free
lib/customers/mask-personal-number.ts, which the client form can import.
Typing a fresh personnummer overwrites the unreadable value, which is the
only repair possible: the rejected writes failed whole INSERTs, so there is
nothing to backfill.

The value was write-only by construction. GET
/api/customers/{id}/personal-number is the deliberate drill-in, mirroring the
employee convention, gated on the write role because .compliance/ropa.yaml
listed no_full_value_read_endpoint as a safeguard for this column; that entry
is rewritten rather than left stale, and reveals log actor and customer id
but never the value.

Also: arcim-migration wrote the identity number as plaintext, which aborts
any import containing a Privatperson with 23514 since the constraint flip;
and the customer embeds on /api/invoices shipped ciphertext to the browser on
every invoice read.

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

* feat: enhance ruta 05 handling for dynamic revenue accounts

- Introduced `fetchDynamicRuta05Accounts` to fetch company-specific revenue accounts marked with a VAT rate, addressing issue #1261.
- Updated VAT declaration logic to include these dynamic accounts in ruta 05 calculations, ensuring accurate reporting for user-added accounts.
- Modified `ACCOUNT_RUTA` to include account 3000 for completeness in ruta 05.
- Enhanced tests to validate the inclusion of user-added revenue accounts in ruta 05 and ensure correct VAT calculations.
- Seeded default VAT rates for BAS revenue accounts to ensure proper classification in the VAT declaration.

* fix: enhance data handling and masking in customer and invoice APIs

* fix(vat): resolve the 3000 gruppkonto's rate for the ruta 05 base split

3000 "Forsaljning inom Sverige" is mapped to ruta05 by ACCOUNT_RUTA, so a
balance on it is filed in the right box already. What was missing is the
rate split: unlike 3001/3002/3003 the account number carries no sats, and
fetchDynamicRuta05Accounts skipped it because it is in ACCOUNT_TO_BOX. A
company posting to the gruppkonto therefore got a ruta 05 total that
breakdown.invoices.base25/12/6 did not add up to.

Surface those rates separately as staticRateByAccount: rate-only on
purpose, because the static map already sums the account and adding it to
the dynamic account list would double the filed figure. A test pins that
single-count property.

Also add 3000 to the MCP server's RUTA_05_ACCOUNTS, which is the display
list behind report.rutor.ruta05: without it a 3000 balance appeared in the
filed projection but not in the report the agent reads back.

The comment claiming SALES_OUTPUT_VAT_SHORTFALL reads base25/12/6 was
wrong and is corrected. That check derives its expected base from the
output-VAT rutor (ruta10/0.25 + ruta11/0.12 + ruta12/0.06); nothing reads
the per-rate bases, which are reporting metadata. So the incomplete split
never affected a filed return or a warning, only the breakdown.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com>
2026-07-28 19:50:16 +02:00
Jakob Wennberg 7c44cef66d fix(supplier-invoices): freeze verifikat-critical fields once the registration entry is posted (#1249)
* fix(supplier-invoices): freeze verifikat-critical fields once the registration entry is posted

invoice_date becomes the registration verifikat's entry_date and
supplier_invoice_number goes into its description, but both stayed freely
writable through the shared UpdateSupplierInvoiceSchema. Editing either on a
booked invoice moved the invoice row while the posted entry kept its original
values: the two disagreed silently, nothing landed in
journal_entry_rattelse_log, and the change bypassed both sanctioned rättelse
paths (BFL 5 kap 5-7 §).

Adds findLockedVerifikatFields() next to the other supplier-invoice lifecycle
predicates and calls it from both writers (dashboard PUT and v1 PATCH, which
also covers the API-key/MCP path). Only a differing value is refused, so a
full-form resend of the stored value still succeeds, and due_date,
payment_reference and notes stay editable for the aged-invoice flow (#1206).

Fixes #1230

* fix(supplier-invoices): make the verifikat-field lock atomic with the write

Review follow-up on #1230: the lock check read the row a moment before the
update ran, so a registration entry posted in between let exactly the drift
the guard exists to prevent slip through.

When an update moves a verifikat-critical field on a row that read as
unbooked, the write is now pinned with `registration_journal_entry_id is
null`. A concurrent posting therefore matches zero rows: the dashboard route
returns its existing SI_EDIT_CONFLICT ("reload and try again", and the retry
hits the lock with the right message), and the v1 route re-reads to answer
with SI_EDIT_VERIFIKAT_LOCKED plus reason=race rather than a guess.

The pin is conditional on the update actually moving one of those fields, so
metadata-only edits and full-form resends of unchanged values on a booked
invoice keep working (#1206).
2026-07-27 19:45:26 +02:00
Jakob Wennberg df29817826 fix(supplier-invoices): make the 'overdue' label two-way and stop it locking an invoice (#1227)
* fix(supplier-invoices): make the 'overdue' label two-way and stop it locking an invoice

The daily cron flips unbooked payables past their due date to 'overdue' but
nothing ever flipped them back, so aging alone pushed an invoice out of every
workflow that gated on 'registered': it could not be edited (not even to extend
the due date that made it overdue) and it could not be attested. Deletion was
already unblocked in #1204; this closes the rest of #1206.

- update_overdue_supplier_invoices() gains the inverse branch: a payable whose
  due date is no longer in the past returns to its resting status. Because the
  flip collapses 'registered' and 'approved', the un-flip needs a separate
  attest marker: new supplier_invoices.approved_at, backfilled from updated_at
  for rows currently sitting in 'approved'.
- PUT /api/supplier-invoices/[id] accepts every unsettled status and recomputes
  the label from the due date it writes, in both directions, instead of leaving
  it up to a day stale. The update body carries metadata only (numbers, dates,
  reference, notes), never amounts or accounts, so a posted registration
  verifikat cannot be desynced by money.
- Approve (web route, v1 API, MCP staging tool, staged commit executor) keys off
  approved_at instead of status === 'registered', so an aged invoice can still
  be attested. A still-late invoice keeps the 'overdue' label after attest:
  approving is not a reason to hide that the money is late.
- One shared predicate in lib/supplier-invoices/lifecycle.ts for all five call
  sites, mirroring the SQL; new SI_EDIT_INVALID_STATUS replaces the raw Swedish
  string the edit gate used to return.

Tests: 12 pg-real cases on the cron (5 new, covering both directions and the
credit-note/fully-paid boundaries), plus route tests asserting the exact written
payload for PUT and approve, and unit tests pinning the shared predicate against
the SQL. npm test (11385), lint, check:guards clean.

Closes #1206

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

* docs(migration): mark backfilled approved_at values as derived, not audit facts

Compliance review on #1227 flagged that approved_at = updated_at could later be
mistaken for an observed attestation moment (BFNAR 2013:2 kap 8
behandlingshistorik). The column comment and the migration now state plainly
that pre-migration values are derived and that audit_log, written by the
audit_supplier_invoices trigger, remains the record of what happened.

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

* fix(supplier-invoices): guard the derived status writes with compare-and-swap

Review findings on #1227. The status these paths write is derived from facts
read a moment earlier, so an unconditional write could overwrite a concurrent
cron flip, edit or approval with a label computed from what those changed.

- PUT pins status, due_date and approved_at when (and only when) it derives a
  new status; zero matched rows is now a retryable 409 SI_EDIT_CONFLICT instead
  of a silently stale label. Metadata-only updates keep writing unconditionally:
  they never touch status, so they cannot clobber it.
- The web approve route and the staged-commit executor gain the same
  pre-approval guard the v1 route already had (status in registered/overdue,
  approved_at IS NULL) plus a !data race check, so two concurrent approvals can
  no longer both stamp approved_at and both emit supplier_invoice.approved.
- The v1 guard additionally pins due_date, since nextStatus is derived from it.
- The list page no longer invents status/approved_at when the approve response
  is incomplete: it re-reads instead. An operator about to pay must not be shown
  a fabricated lifecycle state.
- route.overdue.test.ts clears the module-level event bus like its sibling.

Tests: new conflict cases for both paths (409 on PUT, refusal without an event
emission on approve). npm test 11387 passed, lint 0 errors, check:guards clean,
12 pg-real cases green.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:49:22 +02:00
Mattsson f24b26a139 fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)
* fix(security): gate replace_sie_import behind owner/admin membership

The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no
company_members lookup, no auth.uid() reference and no unauthorized raise,
while setting gnubok.allow_delete to disarm the BFL immutability and
retention triggers. Any caller holding a company_id and an import id could
hard delete another tenant's verifikationer. Confirmed live in production.

Applies the same fail closed owner/admin guard that undo_sie_import already
carries (migration 20260624120000), resolving the actor from
COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then
revokes EXECUTE from PUBLIC and anon. search_path and the raised
statement_timeout are restated, since CREATE OR REPLACE drops settings that
are not repeated.

userId is a required parameter on replaceSIEImport: the service client has a
NULL auth.uid(), so a caller without an explicit actor now fails to compile
rather than hitting the closed gate at runtime.

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

* fix(security): validate arcim OAuth callback state server side

The callback route is skipAuth and decoded the state parameter as plain
base64url JSON, trusting consentId and provider from it. A one time code was
minted at flow start and never read. An unauthenticated attacker who learned
a consent id could run an OAuth flow on their own provider account and post
the callback with a forged state, landing their tokens on another tenant's
consent, so the victim's next migration imported the attacker's ledger.

State is now an opaque randomBytes(32) pointer to a provider_otc row,
consumed by a single atomic UPDATE guarded on used_at IS NULL and
expires_at, so a replay loses the row lock race and updates nothing.
provider is read from provider_consents rather than trusted from the client.
provider_otc already existed for exactly this purpose and was never wired up.

Also scopes getConsent to an owning company, closing a cross tenant status
oracle where the preview and migrate paths echoed a consent's status before
the scoped check ran.

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

* fix(security): scope documents storage to company_id (phase A)

The documents bucket policies matched on auth.uid(), and upload keys were
documents/{userId}/..., so company membership was never consulted. Removing a
member revoked nothing: their session still authenticated and they kept
direct Storage read access to every receipt, supplier invoice and bank
statement they had uploaded. The same bug was fixed for sie-files in
20260416120000; this bucket was left behind.

Phase A is additive. Company scoped policies are added alongside the
uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and
reads accept either layout so nothing breaks mid migration. Phase C, which
drops the old policies, is gated on the backfill reporting zero remaining
legacy prefix objects.

The policy compares the company segment as text rather than casting to uuid
the way sie-files does: this bucket holds keys whose second segment is not a
uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix
qual runs before the cast, so a planner reordering would raise 22P02 and fail
the whole query instead of filtering the row out.

deleteDocument now removes both candidate keys. Removing only the stored
pointer would leave a readable orphan copy of a document the user asked to
erase.

The backfill script is included but has never been run. It defaults to dry
run, refuses .env.local by name, and verifies each copy is readable and
SHA-256 identical before repointing the row.

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

* fix(security): enforce events:read scope and membership on /api/events

This was the only one of the three validateApiKey call sites with no
downstream guard: v1 and the MCP server both check scope and re-verify
company membership, this route did neither. An events:read scope existed and
was documented as gating the endpoint but was never called, so a legacy key
falling back to DEFAULT_SCOPES read the full log. The bound company id went
straight from the api_keys row into a service role query, so a key whose user
had been removed from the company kept reading.

Adds the scope check before any database access, re-verifies company_members
with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead
of ignoring it, applies minimisePayload so the pull surface can never return
a wider payload than the push surface, and replaces the three flat error
strings with the canonical envelope.

Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is
gated on mutations in with-api-v1, so a read gets the same treatment as every
other v1 read endpoint.

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

* perf(bookkeeping): sweep remaining journal_entries!inner embeds

A previous refactor removed this pattern from lib/reports and introduced
fetchEntryLines, but the class was never swept. Seventeen sites remained and
had become the top application consumer of production database time:
measured across the resulting query shapes, 32,694 calls and 25,848 seconds
of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at
7,962ms against the 8s statement_timeout, which surfaced to users as 500s on
the booking path.

PostgREST compiles an embed with filters on the embedded side into a
correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops
Postgres reordering the join, so each query walked the whole
journal_entry_lines table across all tenants. Driving from the entries side
instead turns that into two indexed round trips.

Converted sites keep their existing shape: the helper reattaches the parent
entry under the same key the embed produced. Several conversions also remove
a latent silent truncation where an unpaginated query was capped at
PostgREST's 1000 row ceiling.

Two deliberate exceptions. The free text ilike legs of the MCP display query
stay on the embed, because each is capped at legLimit and that cap drives the
truncation contract the tool reports, while the helper is unbounded. The
accounts route moves to the existing get_account_usage_counts RPC instead,
since its embed was a head count and the helper returns rows.

commitEntry's write path is untouched: the change there is confined to the
read query of the pre-commit dimension rule check.

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

* fix(api): anchor v1 list cursors on created_at

Page two returned page one, forever, while still advertising a fresh
next_cursor. The three routes sorted by and encoded a Postgres date column,
which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor
timestamp as full ISO-8601 and returned null, so the keyset filter was never
applied and has_more never went false. An integrator syncing verifikat looped
on the newest rows indefinitely.

The transactions route already solved this and its comment names the trap;
the fix was never ported. All three now order and encode on created_at with
an id tie break, matching the transactions keyset predicate exactly.
ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change
sort semantics on the route that currently works.

Default ordering therefore moves from business date to insert order. Every
business date is still on the row, and the invoices list gains date_from and
date_to filters so a date range is still reachable; the other two already had
them.

The tests use an in-memory PostgREST that actually evaluates the filters,
because the repo's pass-through mock cannot catch this class of bug: the bug
is that the filter is never sent. They walk to exhaustion with a hard
iteration cap, so an unterminated walk fails instead of hanging.

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

* fix(api): separate dry run from commit in the idempotency hash

The request hash was built from url.pathname, which excludes the query
string, so a dry run and its commit hashed identically. Following the flow
documented in dry-run.ts, re-issuing the request with the same
Idempotency-Key returned the cached preview with Idempotent-Replayed set and
wrote nothing, while reporting 200. An agent or integrator saw success for a
write that never happened.

dry_run is folded into the hash only when true, not as an unconditional
boolean. Including it as false would change the hash of every ordinary write,
and with a 24h idempotency TTL any key in flight across the deploy would fail
the request_hash comparison and 409 on a legitimate retry. Both hash call
sites now go through one shared helper so they cannot drift into a permanent
cache miss, and dry run responses are no longer stored at all.

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

* ci: install the Bedrock SDK out of tree in the compliance review

The Swedish accounting compliance gate had failed ten consecutive runs and so
was posting nothing. With --no-package-lock npm discarded the lockfile and
re-resolved the whole tree from package.json, floating @hookform/resolvers to
5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0.

Installing into the parent of the checkout resolves only that one package, so
an unrelated peer conflict can never take the gate down again. Node still
finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH
would not have worked, as it is CommonJS only. --legacy-peer-deps was
rejected because it masks future genuine peer conflicts and still reifies the
full tree.

The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that
package.json and check:guards enforce after the streaming outage. That drift
went unnoticed because the pin guard only inspects package.json and the
lockfile, never workflow files.

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

* build(docker): generate crontabs from vercel.json

vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were
byte identical to each other. Self hosted deployments therefore never sent
recurring invoices, never dispatched webhooks and never cleaned up
idempotency keys. tax-deadlines also ran once a year on 2 January instead of
daily, and documents/verify weekly instead of daily.

Extension crons are included rather than excluded. The Dockerfile copies the
whole tree before building, so every extension cron route is compiled into
the image regardless of the enabled preset, and each returns 200 when its
extension is unconfigured, so curl -sf logs no failure. Two such entries were
already present in the crontab for extensions absent from the preset, which
settles the intent.

documents/verify is treated as drift rather than a self hosted concession:
the weekly cadence was present in the hosted crontab too, and the run is
capped at 200 documents walking a nulls-first queue, so weekly drains the
integrity queue seven times slower on a check that exists for BFL retention.

webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day
on self hosted. A gentler tick would silently stretch the first retry, since
the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line
place to change that.

A parity test asserts the path sets match minus a documented exclusion list,
and ratchets three cron routes that are currently scheduled nowhere so they
are named rather than silently rotting.

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

* chore(observability): add a provider agnostic error sink

There is no error tracking in this codebase: logs go to console and Vercel
retention and nowhere else, nothing alerts on the 16 cron jobs, and seven
code comments across lib, app, components and extensions asserted that Sentry
captures errors when Sentry is not a dependency. The two most recent bug
fixes on this repo were both discovered by customer email.

This adds the sink, not a vendor. No dependency is taken: the interface has a
no-op default and a registration point, so behaviour is unchanged until an
adapter is registered. Releases are tagged from the build id already inlined
by next.config.ts.

Redaction moved out of lib/logger.ts into a leaf module that both the logger
and the sink import, so there is one denylist and no path from application
data to a third party can skip the personnummer regex, including direct sink
calls that bypass the logger. That matters here because these logs carry
personnummer and financial data.

verifyCronSecret now reports its own 401s, which covers all 16 jobs without
touching a route file and catches the case where CRON_SECRET is rotated
without updating the scheduler and every job silently 401s forever. The
threshold is one failure rather than the backup alert's three: suppressing
the first occurrence is precisely how an outage stays invisible.

The seven misleading comments are corrected to describe what the code
actually does, including the two cases that still are not covered: the client
side one, since the sink is server side, and a warn level call that is not
forwarded.

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

* fix: remediate the 2026-07-26 similar-sweep findings across all surfaces

Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with
one agent per finding; every behavioural fix carries a regression test proven
to fail at HEAD. Full status, corrections to the sweep, refusals and open
decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md.

Structural roots closed:
- resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking
  1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING
- ledger-line-amount.ts: journal_entry_lines.currency labels the document,
  not the amount; SQL pre-filter decoy proven and fixed
- sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the
  exploitable salary payslip-line PATCH and KPI preferences sinks fixed
- tests/schema: migration-replay phantom-column guard (13k+ refs, closed
  CHECK sets, onConflict targets); found 28 real defects, all fixed, all
  four baselines now empty
- three new ratchet guards: sek-labelled-amount, cross-extension-import,
  ungated-extension-route

Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap),
RC input VAT mismatch wired on web + both MCP callers, missing-underlag
resource delegates to the shared RPC predicate, push-notifications consent
polarity fail-closed, deadlines undo honours requested state, silent-failure
and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/
Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites
with isSwedishUserMessage extended.

Also includes the parallel session's MCP invoice tools (update_invoice,
recurring schedules, invoice deliveries) which share files with the sweep
work and are verified green together.

13 new migrations are NOT applied anywhere; they apply via branch merge.
20260726120000 backfills 1247 supplier-invoice rows. pg tests for new
DDL are written but unrun (no local Postgres).

Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0
errors, check:guards passing, MCP payload 57475/57500.

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

* fix(migrations): rename replace_sie_import migration off main's 20260726090000 version

origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping
our replace_sie_import migration on the same version would abort the Supabase
apply with a schema_migrations_pkey duplicate at merge time.

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

* fix(review): remediate pre-publish deep-review findings across all slices

A 13-agent review of the full branch diff surfaced 1 critical, 5 high and
~45 further findings; this commit resolves them in one pass:

- replace_sie_import / undo_sie_import: p_user_id honored only for
  service_role callers; any other caller is pinned to auth.uid()
  (impersonation gate bypass), authz raise errcode 42501 mapped to a
  Swedish 403 in the route, new caller-guard migration for undo
- bulk_book_transactions refuses homogeneous non-SEK batches instead of
  writing foreign magnitudes into SEK ledger columns
- credit-note cap trigger: company-match on credited_invoice_id, no
  cross-tenant figures in exception text
- link_voucher RPCs resolve NULL invoice currency as SEK end to end
- personal-number ciphertext CHECK split into NOT VALID + VALIDATE
- same-currency foreign settlements clear 1510 at booking rate and book
  realized diff to 3960/7960; rate-less foreign write paths refuse
- receivables revaluation covers partially_paid and outstanding amounts
- period lock guard paginates candidates past the PostgREST 1000 cap
- documents: service-client storage removals after authz, dual-layout
  reads in integrity cron and archive export, backfill delete-source
  sweep actually deletes with hash verification and shared-key grouping
- invoice matching normalizes NULL/lowercase currencies (regression),
  duplicate candidates stop claiming amount matches they never ran
- match-invoice aborts on any booking failure (no paid-without-verifikat)
- refresh-exchange-rate reverts on concurrent booking (TOCTOU window)
- KPI preferences upsert arbiter aligned to the company-scoped constraint
- personnummer_last4 stripped from all salary responses incl. MCP tools
- worked-hours batch restores destroyed rows on conflict and error paths
- MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit
  on tag_journal_lines overflow, auto_send schedules stage as high risk
- observability sink redacts emails/IBANs/API keys and keeps redacted
  stacks in prod; assorted small guards (safe-return-to /@, dry_run=True,
  cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings
  call removed)

Full dispositions, deferred items and hand-verified accounting numbers
are documented in the PR body and DECISIONS.md.

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

* feat(personnummer): implement masking and encryption for personal numbers with tests

* fix(review): address CI and compliance-bot findings for PR #1215

pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role
GUC, so both service-role simulations (runAsServiceRole and the
invoice-delivery test's local helper) never satisfied auth.role() =
'service_role' and every legitimate p_user_id path failed closed; the
shared helper now sets both GUC shapes plus SET LOCAL ROLE with a
fail-loud sanity check, and the delivery test reuses it. The link-voucher
migration had recreated both RPCs from pre-rewrite file text,
reintroducing the NULL-unsafe membership pattern the
null-safe-tenant-guards ratchet bans; both guards now use
public.caller_is_company_member() with all currency changes preserved.

Compliance bots: the customers export now emits the standard masked form
instead of raw AES-256-GCM ciphertext in the Org-/personnummer column,
and maskCustomerRow returns a non-round-trippable placeholder on decrypt
failure instead of 500ing the list. MCP parity: gnubok_lock_period's
staging pre-check now runs the exact countUnbookedInPeriod the commit
path enforces (exported from period-service; local mirror deleted), and
gnubok_agi_status resolves AGI state run-scoped so a correction run no
longer renders as already filed.

Declined with evidence: PR-Agent's opening-balances null-zeroing concern
(all mergeable columns are NOT NULL with defaults per 20260713101000).

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

* fix(review): address codex review findings on PR #1215

- restore 20260726140000 to its preview-recorded content and restate the
  NULL-safe tenant guard under 20260727130000: a recorded migration version
  never re-runs, so the in-place edit could not reach the preview branch
- replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap
  warning texts and update the pinned test expectations
- drop the em dash in the fiscal-periods route comment
- strip trailing whitespace in import-existing.test.ts

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

* test(reports): raise timeout on real PDF render tests

renderToBuffer does real @react-pdf layout work and exceeds the 5s
default when the full suite saturates the CPU; tests pass in isolation.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:34:56 +02:00
Jakob Wennberg 1270b6daeb fix(bookkeeping): let a rättelseverifikation be stornoed; unblock aged supplier-invoice deletion (#1204)
* fix(bookkeeping): let a rättelseverifikation be stornoed; unblock aged supplier-invoice deletion

A user who corrected a booking (storno + rättelse) and then discovered the
affärshändelse was already booked by another verifikat had no sanctioned way
out: reverseEntry refused source_type 'correction' alongside 'storno', and
correctEntry rightly rejects a zeroing rättelse (BFL 5 kap 5 §). The same
guard also broke uncategorize-after-rättelse, since bank transactions are
relinked to the correction entry.

- reverseEntry now blocks only 'storno' (storno-of-a-storno keeps the chain
  ambiguity problem); a correction entry is a regular live verifikat and can
  be stornoed, with correction_of_id keeping the chain traceable.
- CANNOT_REVERSE_STORNO copy narrowed to stornos + remediation hint.
- Supplier-invoice DELETE now allows unbooked, unpaid invoices in
  registered/approved/overdue: the daily overdue cron flipped unbooked
  invoices past due_date into a state where deletion was blocked forever.
  Orphan-safety checks (registration JE, payments, accrual schedule) are what
  actually protect the books. UI shows the delete button accordingly.
- LinkVoucherPicker showed customer-side copy (kundfordran/1510) in
  supplier-invoice mode; supplier mode now explains the 2440-debit
  requirement, including why a direct-cost verifikat cannot be linked.

Support case 2026-07-26 (marcus@).

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

* fix(supplier-invoices): review fixes: fail-closed orphan lookups, hide delete when payments loaded

- The payment and accrual-schedule lookups in DELETE now fail closed: a
  lookup error returns 500 instead of reading as "nothing linked" and
  letting the delete proceed unverified.
- The delete button also requires the loaded payment list to be empty,
  matching the server predicate.

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

* docs: authorize 'approved' in supplier-invoice delete allow-list (compliance-swarm V2.3)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 12:49:10 +02:00
Mattsson d54b43f80f Bug/resend and invoices (#1192)
* fix(invoices): anchor the PDF logo to the top-left of its header cell

The logo box is always the full 240x80pt reserved area (any larger logo is
clamped to exactly that), so objectFit: 'contain' placed the image inside it
with the default 50% 50% centering. A wide banner logo fills the width and
lands on the left margin, but a near-square logo scaled down to the 80pt
height cap is only ~117pt wide and got pushed ~60pt in from the margin, which
reads as a misaligned logo and forced companies to reshape their artwork.

Anchor the image top-left so every aspect ratio starts at the margin.

Covered by a test that renders the real PDF and reads the image placement
matrix out of the content stream, for both a wide and a near-square logo.

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

* feat(invoices): show the real delivery outcome in the send history

"Skickad" only meant the email provider accepted the message, so a bounced
invoice looked identical to one that arrived. Resend reports the outcome
asynchronously; that report now lands on the delivery row and drives the
history: green is reserved for a confirmed delivery, bounce/blocked reads
red, delayed and spam-marked read amber, and an accepted-but-unconfirmed
send is neutral instead of falsely green.

The report arrives on a signed webhook and may only touch the three new
provider status columns of an already sent, unredacted row: the WORM trigger
proves nothing else changed, and a lower ranked or older report can never
downgrade an observed failure. The provider reason text can quote the failing
address, so it is masked on read and cleared by the daily PII redaction job.

Timestamps also formatted in Europe/Stockholm instead of falling back to the
runtime zone, which rendered a 14:05 send as 12:05 on Vercel.

Delivery reports are per message, never per recipient: Resend sends one event
for the whole message, so splitting a send per recipient would be the only way
to get finer granularity, at the cost of CC.

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

* feat(stripe): make the integration feed-only

Stripe sync now only imports balance transactions into the transactions
inbox, like any bank feed; nothing auto-books. The event/settlement sync
(lib/sync.ts, lib/payouts.ts) stays in the repo but is no longer wired to
any route or cron: the 15-min sync cron is removed from vercel.json.
Payment links on invoice send are unchanged; their payments arrive as
feed rows and are matched manually.

- /sync runs only syncStripeBalanceTransactions; response is { success,
  transactions }
- connecting via OAuth enables the nightly feed by default (toggle stays
  as opt-out)
- panel: needs-review section and plumbing removed, copy rewritten to
  transactions-first (sv + en), toast reports fetched/imported/linked
  and calls out an empty result instead of silent all-zeros

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

* fix(api): return the article currency from the v1 article list

The dashboard, importer, export and MCP article surfaces all learned to
carry a non-SEK article price (#1166, #1183, #1184), but the v1
projection still omitted currency. An API or agent caller therefore read
price_excl_vat with nothing marking it as EUR and would copy the number
straight onto a SEK invoice line, at a nine-to-one error.

Adds currency to the projection, the response shape and the example, plus
a pitfall stating the price is not always SEK and that this endpoint does
no FX conversion.

Additive field only; no migration (articles.currency already exists).

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

* feat(settings): replace the settings modal with a routed panel sheet

Settings now renders as a sheet that fills the main panel, sliding up over the
page the user came from and back down on close, with the sidebar and frame left
visible and usable. Behind it sits one shared master-detail surface: underline
search across every section and subsection, the grouped section rail, and the
active section as a direct-editing accordion. All 11 sections are decomposed
into subsections, and the legacy *SettingsContent components compose the same
pieces so the stacked and accordion layouts cannot drift.

The sheet is the only presentation, on every entry path. The intercepting route
handles in-app navigation and closes by popping the history entry, landing back
on the page underneath. @settingsModal/default.tsx handles cold loads (refresh,
deep link, new tab), where interception never fires; nothing is mounted
underneath there, so it closes to the dashboard. Both branch on one shared
predicate, isSheetSection, together with the settings layout, which must render
nothing for those sections or the surface would stack twice behind the sheet
and run every section's fetches twice.

Closing is deliberate rather than incidental: the X, Esc, or navigating away.
The dialog is non-modal so the sidebar's account popover and company switcher
keep working with settings up, and an outside click no longer dismisses it.
Sections land fully collapsed, and the scroll position of the page behind
survives opening and closing the sheet.

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

* feat: enhance article management and settings UI

- Add PATCH test for toggling article active state without other fields.
- Remove unused MessageCircle icon from DashboardContent.
- Refactor AccountingFrameworkForm to use SettingsFieldRow for better help text display.
- Update CompanyInfoForm, DimensionsToggle, and various settings forms to replace description with help text.
- Remove redundant headings and intros in several settings components to streamline UI.
- Improve help text for various settings in English and Swedish translations.
- Update structured error messages for better clarity on article deletion.

* refactor(ArticleDetailPage): remove unused imports and duplicate state variable

* fix(settings): own deep-linked settings routes by route list, not nav visibility

Review fixes from the settings panel sheet work:
* isSheetSection reads the full settings route list so a hidden-but-deep-linked
  section (assistant before BankID, banking in sandbox, api without MCP) is
  claimed by the sheet instead of rendering the legacy shell around an empty panel
* keep 503 on the Resend delivery webhook when the signing secret is unset, with
  a test pinning the behaviour
* stripe callback route test coverage

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

* refactor: update salary, tax, and templates settings components

- Refactored SalarySettingsContent to use a form wrapper and improved payment settings UI.
- Enhanced TaxSettingsContent with new signals for EU sales, KU obligations, and ROT/RUT deductions.
- Updated TemplatesSettingsContent to remove legacy comments and improve readability.
- Simplified navigation items by removing unnecessary constants and directly using hrefs.
- Cleaned up translation files by removing deprecated keys and adding new descriptions for clarity.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:56:17 +02:00
Mattsson 43f7ccab9e feat(invoices): allow BAS class 1-3 posting-account overrides and complete the aktiekapital note (#1121)
- invoice/article posting-account overrides accept active class 1-3 accounts;
  class 1-2 (balance-sheet) accounts are rejected on VAT-bearing lines so the
  ruta 05 tax base always books to a 3xxx account
- shared posting-account regex across server schemas, pending-operation
  re-validation, and client forms
- share-capital settings (aktiekapital/antal_aktier) feed the annual-report
  note; kvotvarde derived per ABL 1 kap 6 $; all-or-nothing pair constraint
- signed per-rate VAT breakdown on credit-note PDFs; U+2212 to ASCII hyphen

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:16:00 +02:00
Mattsson 466e55a015 Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
2026-07-23 09:54:02 +02:00
Mattsson 321e684523 Fix/usr fdbck ch (#1105)
* fix(privacy): mask voucher amounts in session replays

* fix: persist transaction source filter

* fix: clarify invoice filenames and booking previews

* fix: truncate long uploaded filenames

* feat: add invoice delivery history

* fix: harden invoice delivery history

* fix: include invoice deliveries in full archive
2026-07-22 18:49:57 +02:00
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00
Mattsson 87f0d5af48 fix: GH issues batch: deadlines opt-ins, SKV reconnect, narrative edit, payment-link gating (#1076)
* fix(errors): close remaining raw-message leaks after #1048 (#337)

Follow-up to PR #1048. No user-visible toast or response field can now
carry a raw engine or DB message; everything maps through getErrorMessage
or the structured-errors registry.

- get-error-message: only normalize a code-carrying Error instance into
  the structured path when the registry knows the code; unknown codes
  (Node system errors, stray third-party codes, Error-wrapped Postgres
  SQLSTATEs) fall through to pattern match, Swedish check, Postgres map
  and the status/context/generic fallbacks instead of returning the raw
  message. New Swedish-detection pattern for "ar last" phrases and a
  known-pattern row for "already has a journal entry".
- structured-errors: add CANNOT_EDIT_NON_DRAFT (409) and
  MANDATORY_DIMENSION_MISSING (400) rows, plus common Node network codes
  (ECONNREFUSED, ECONNRESET, ETIMEDOUT, ENOTFOUND, EAI_AGAIN, EPIPE) as
  retryable 503 transients with a Swedish message.
- pending-operations commit + bulk-commit routes: map executor error
  strings through getErrorMessage before responding (raw stays in logs);
  Swedish passes through, English falls to status-appropriate Swedish.
- pending page: toast via getErrorMessage, fixing raw English toasts and
  "[object Object]" for structured envelopes on commit/bulk/reject.
- transactions book + journal-entries routes: untyped catch and DB list
  errors no longer return err.message; mapped or static Swedish instead.
- invoice send + issue-credit-note: partial_failures reasons are now
  Swedish (raw provider/DB text logged, never returned).
- Tests: new unknown-code/Error-instance suite, registry rows asserted,
  route tests updated off the pinned raw-English expectations.

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

* fix(skatteverket): target the räkenskapsår for yearly VAT redovisningsperiod

A yearly filer with a broken fiscal year has a Skatteverket period ending
in its FY-end month, not December, and the panel's year state is never
maintained in yearly mode (the year picker is replaced by the
räkenskapsår selector), so calls targeted the wrong period even for
calendar-FY companies filing after year end. The selected fiscal period
now rides through the whole chain: panel query strings, draft/validate/
submit bodies, buildMomsuppgift (which resolves the FY bounds so the
period id and the figures describe the same räkenskapsår), and the
staged-commit path. MCP callers without a fiscal period keep the
calendar fallback.

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

* feat(deadlines): group same-day skattekonto deadlines into one card

Moms, AGI and preliminärskatt legally share the skattekonto date (den
12:e), so a small monthly-moms employer saw 2-3 near-identical rows per
month. Two or more pending system rows of the skattekonto family on the
same due date now render as one grouped card with the date block once
and each obligation as a sub-row keeping its own confirm-to-complete
flow. Presentation only: rows, statuses, ICS feed unchanged.

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

* feat(deadlines): KU + ROT/RUT + long-tail opt-in deadlines, rolling horizon

Follow-ups from the #1028 audit left out of the #1057-#1060 fix stack,
each with its own condition modeling:

- kontrolluppgifter (KU10/KU20/KU31), due 31 Jan (SFL 24 kap. 1 §):
  opt-in flag suggested from ledger signals (2898 utdelning, 2393/2893
  ägarlån; deliberately not 2091, see DECISIONS.md), AB only, mirroring
  the #1059 EU-sales suggest-and-confirm pattern.
- rot_rut_begaran, due 31 Jan after the payment year (Lag 2009:194
  8 §): rows generated only for years with actually PAID ROT/RUT
  invoices, resolved inside the generator; invoice-derived suggestion.
- Long tail, explicit opt-in ('Fler deadlines'): OSS quarterly and IOSS
  monthly with a skipBankingDayAdjustment config flag (EU-law dates
  stand on weekends), Intrastat (10th banking day of the following
  month), punktskatt (ordinary skattedeklaration schedule), and
  fyllnadsinbetalning (12th of 2nd month over 30k / 3rd of 5th month,
  SFL 62:8 + 65 kap.). Kvarskatt deferred: needs a slutskattebesked
  date the app does not hold.
- Rolling generation horizon: recurring types ~6 months ahead, annual
  12 months, mirrored in the backfill expectation keys so the nightly
  cron never thrashes; regeneration now preserves manual in_progress
  status; one-time cleanup migration removes existing far-future rows.

Migrations also applied to the staging branch, together with the
previously missing 20260717xxxxxx deadline migrations (staging had
drifted and lacked dismissed_at).

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

* fix(arsredovisning): keep narrative editable after year-end close

The narrative save endpoint refused writes whenever the fiscal period was
closed/locked, but Verkstall bokslut closes the period before the
arsredovisning text is ever written, so every legitimate save failed with
PERIOD_LOCKED and the PDF fell back to placeholder text.

The narrative is arsredovisning document text (ARL 6 kap.), not journal
rakenskapsinformation, so the bookkeeping period lock does not apply.
Saves are now refused only once a Bolagsverket submission for the period
is registrerad (ARSREDOVISNING_REGISTERED, 409); the filed artifact was
already frozen separately by the submissions immutability trigger.

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

* fix(skatteverket): surface dead SKV connections and nudge reconnect

Prod has ~70 companies that connected Skatteverket before the post-connect
sync fix (#1010) and silently never synced skattekonto: the only reconnect
prompt lived in the settings panel nobody revisits.

- transactions-page banner when the connection is needs_reconsent or
  expired without refresh, linking to /settings/tax
- pre-connect note in the connect panel: approve ALL behorigheter on
  Skatteverket's consent page (previously only shown after a failure)
- wire the inert skattekonto.connection.expired event to an email nudge
  to the token owner; one send per consent episode via claim-first dedup
  in notification_log (type skv_connection_expired, partial unique index
  in migration 20260720090000, applied to staging)

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

* fix(archive): per-year behandlingshistorik covers late-booked vouchers + Drive backup disclaimer

The per-fiscal-year archive filtered audit rows by created_at within the
period, dropping treatment history for bokslut entries, stornos and SIE
imports booked after year end (BFNAR 2013:2 kap 8). The year archive now
unions the date window with every audit row touching the period's journal
entries and lines, deduped by audit id; line rows (company_id NULL by
trigger design) are admitted via a scoped OR and reachable on the
service-role backup path. ARCHIVE_FORMAT_VERSION 2->3 forces a one-time
Drive re-upload so existing archives pick up the complete history. The
Drive card on /import Exportera and the LASMIG texts now state the Drive
copy is a convenience backup, not the BFL 7 kap legal archive.

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

* fix(decisions): clarify Arsredovisning narrative save behavior on submission status

* feat(invoices): gate payment links behind invoice settings opt-in

The payment-link section (manual URL field + Stripe auto-create toggle)
was visible on every invoice and auto-created Stripe links on send for
any connected company. It is now opt-in per company:

- new company_settings.invoice_payment_links_enabled, default false for
  everyone (no grandfathering of Stripe-connected companies)
- invoice editor hides the whole section unless enabled; a draft that
  already carries a link still shows it so old links stay clearable
- enforced server-side in maybeCreatePaymentLinkForInvoice (after the
  provider lookup, so the extension-free core build never queries), so
  dashboard, v1, MCP and recurring sends all obey it
- new toggle on Settings -> Invoicing, saves instantly; sv/en strings

Migration applied to the staging branch; prod gets it on merge.

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

* fix(tests): add invoice_payment_links_enabled to company settings fixture

The makeCompanySettings fixture missed the new required boolean, failing
the core-only build's type check of tests/helpers.ts. Default false,
matching the migration default.

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

* fix(review): address CodeRabbit, compliance and Swedish review findings

Round 2 of PR #1076 review feedback, one change per accepted finding:

- pending page: res.json() safe fallback in both commit paths so a
  non-JSON proxy response cannot surface a raw parser error
- bulk-commit: map operation status enums to Swedish display labels in
  the 'Redan hanterad' skip message
- payment-link settings: disable the toggle while a save is in flight
  to prevent out-of-order PUT responses
- deadlines group card: route all UI strings through next-intl
  (deadlines namespace, sv + en)
- archive export: scope the period audit entry lookup to
  posted/reversed, matching the rest of the export
- error tests: assert the exact registry English message for
  ECONNREFUSED to lock the no-leakage contract
- signal routes: log.warn when best-effort lookups swallow a Supabase
  error (forensics), keep fail-closed behavior
- narrative route: document that 'avslutad' submissions deliberately
  stay editable (never registered at Bolagsverket)
- VAT: yearly declarations without an explicit fiscalPeriodId now
  resolve the räkenskapsår ending in the target year from
  fiscal_periods instead of assuming a calendar FY (SFL 26 kap
  10-11 §§); calendar fallback only when no fiscal period exists
- deadlines: IOSS deadline no longer requires vat_registered
  (Art. 369s has no Swedish VAT registration prerequisite)

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

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:38:14 +02:00
Jakob Wennberg 30771b1619 feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion (#1075)
* feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion

Close the last MCP-surface gaps for running payroll end-to-end via the
connector (the v1 REST API already had the full chain):

- gnubok_book_salary_run: stages a high-risk book operation; on approval
  the executor walks review -> approved -> paid -> booked via the new
  lib/salary/book-run.ts (extracted from the dashboard book route, which
  now calls the same core) and posts the immutable salary vouchers.
- gnubok_delete_absence: staged inverse of gnubok_register_absence,
  reusing deleteAbsenceRange with a dry-run day-count preview.
- Wire the missing payroll operation types into the Granskning label map
  (register_absence, update_payslip_line, employee ops, vacation_year_close
  had translations but fell back to humanized snake_case).
- Update stale 'booking happens in the web UI' prose in tool descriptions,
  the payroll-monthly skill, and the workflow hint; payload-size ceiling
  56K -> 57K per the documented bump protocol.

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

* fix(mcp): widen pending_operations op-type CHECK + roster typing for book_salary_run

The op-type audit (pg-real) caught the exact bug class it exists for:
book_salary_run and delete_absence were staged in code without the
constraint-expansion migration, so every real staging INSERT would have
failed with check_violation while dry_run previewed clean. Ships the
documented widen (NOT VALID) + validate migration pair. Also fixes the
strict-mode cast in book-run.ts that failed the production typecheck.

Verified locally against supabase/postgres 15.8.1.060 with all migrations
applied: op-type audit green, pg-real 692/693 (the one failure is the
pre-existing TZ-sensitive get_unlinked_1930_lines assertion, green under
TZ=UTC as in CI).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:00:53 +02:00
Mattsson 9c8e540338 fix(invoices): repair send dialog fiscal-period query + editable issu… (#1066)
* fix(invoices): repair send dialog fiscal-period query + editable issuance lines

The send/mark-sent dialog queried fiscal_periods with start_date/end_date
instead of period_start/period_end; the query always 400ed, and since PR
#1023 made that fatal the dialog closed instantly, blocking mark-as-sent
and email send for everyone.

Also lets accrual companies edit the proposed journal lines before booking
(both send and mark-sent), mirroring the mark-paid editor: untouched
proposals still book via the server generator; edited lines book verbatim
with balance validated at three layers. Credit notes and periodiserade
invoices keep the read-only preview. The dialog now also respects
defer_invoice_booking (#967).

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

* fix(invoices): harden custom issuance-line validation per review findings

Extract the custom-line parse + balance check into a shared validator so
the send and mark-sent routes cannot drift. Reject rows carrying both
debit and credit, and 29xx interim accounts (custom lines skip accrual
schedule creation, so a 29xx balance would never be dissolved). Validate
the payload only after the invoice ownership fetch, and emit structured
log events when user-edited lines are booked or deliberately ignored, so
manual overrides are visible in audit review.

Account existence needs no route-level check: the engine already resolves
every account against the company chart and throws AccountsNotInChartError.

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

* fix(invoices): address CodeRabbit findings on issuance line editing

Reject malformed JSON bodies with 400 instead of silently booking
generated lines; restrict line editing to SEK invoices (custom lines
cannot carry FX metadata); round each line before the client balance
check to match the server; stop claiming a voucher was created in the
mark-sent toast for deferred-booking companies; add programmatic labels
to the editor inputs and remove-row buttons.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:39:56 +02:00
Jakob Wennberg 1443235cec feat(invoices): registrera utan att bokföra + explicit Bokför-steg (#1040)
* feat(invoices): registrera utan att bokföra + explicit Bokför-steg

Companies where one person registers supplier invoices / sends customer
invoices while ekonomi does the actual bookkeeping had no way to split
the two: under faktureringsmetoden every registration/send booked the
journal entry inline.

- New company setting defer_invoice_booking (default off, accrual only):
  registering a supplier invoice or sending/marking-sent a customer
  invoice creates NO journal entry.
- New explicit booking routes POST /api/supplier-invoices/[id]/book and
  POST /api/invoices/[id]/book: create the registration/revenue entry
  afterwards, CAS-guarded against concurrent booking (a lost race
  cancels the just-posted voucher with a gap explanation), including
  periodisering schedules.
- Detail pages show "Ej bokförd ännu" + a Bokför button for unbooked
  accrual invoices; the settings toggle lives under Bokföringsmetod.
- mark-paid needs no changes: both payment flows already route on the
  journal-entry link, so an invoice still unbooked when paid gets the
  full cash-style entry.
- The mark-sent fail-closed rollback now keys on the same gate so
  deferred sends are not rolled back as booking failures.

Fixes #967

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

* fix(invoices): harden deferred booking after review

CodeRabbit round on #1040:
- CAS link guards also require a still-bookable status (and uncredited,
  customer side) so a concurrent mark-paid/credit cannot end up with a
  double-posting registration/revenue entry.
- Settings reads fail closed instead of defaulting to accrual rules.
- Detail pages surface the ACCRUAL_SCHEDULE_FAILED warning instead of
  showing plain success, and the customer page no longer stringifies
  structured errors into "[object Object]".
- The settings form normalizes defer_invoice_booking to false under
  kontantmetoden so a stale flag cannot re-activate on method switch.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:15:20 +02:00
Mattsson 072aedeaf9 Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow

* fix: persist and display customer personal numbers

* feat: configure automatic invoice reminder days

* fix: issue credit notes through send flow

* chore: add repository agent guidance

* feat(mcp): route tools across user companies

* fix(articles): delete unused register entries

* feat(invoices): improve issued invoice actions

* feat(supplier-invoices): retain uploaded source documents

* docs: record implementation decisions

* feat: enhance customer personal number handling and validation

- Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers.
- Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema.
- Implemented masking and encryption for personal numbers to enhance data protection.
- Introduced new utility functions for masking and encrypting personal numbers.
- Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries.
- Enhanced error handling and logging for credit note issuance and invoice processing.
- Updated tests to cover new credit note creation guards and personal number handling.

* test: enhance list companies test with supabase query mocks
2026-07-15 15:53:15 +02:00
Mattsson b6332e9ff4 Fix/skv connection flow (#1015)
* feat(salary): one-click AGI submission with filing state machine and success feedback

The AGI panel required users to know that "Ladda ner AGI-fil" was the
generate step, then click submit, signing link, and kvittens manually.
A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path
that does not exist.

- New primary button "Lamna in till Skatteverket" chains the existing
  endpoints client-side: generate XML if missing, POST underlag, poll
  kontrollresultat, create signing link, open Mina Sidor in a tab opened
  synchronously at click (popup-blocker safe). Inline stepper shows each
  step; the four old buttons become collapsed advanced/recovery actions,
  auto-expanded in stale-draft and rejected states. XML download stays
  visible and free for manual filing.
- deriveAgiFilingState() + useAgiSubmission() lift the per-period
  submission record to the run page: the progress rail and salary hero
  now render the real state machine (generated, underlag inskickat,
  vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of
  telling users to "lamna in" an already-submitted declaration.
- Success card with kvittensnummer and signature metadata once signed,
  plus a toast when a poll flips the state while the page is open.
- AGI kvittens cron every 15 min instead of every 2 h so filings signed
  on another device get stamped and emailed promptly.
- Advanced submit also auto-generates, and the stale "Lon -> AGI ->
  Generera" error text now points at the real buttons.

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

* fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup

The bank redirect landed on a blank page for the several seconds the
callback spent exchanging the PSD2 session and mirroring accounts, and
every failed connect attempt left a status='error' row that rendered
forever as an "Atgard kravs" card next to a successful retry, showing
duplicate connections to the same bank.

- Stream a branded "Slutfor bankanslutningen" progress page from the
  callback: the shell flushes before the session exchange starts and a
  script/meta redirect follows when the work completes, with a 30s
  slow-work escape hatch. Fast outcomes (denial, bad params, unknown
  state) keep their plain redirects.
- Delete never-activated connection rows (no session_id, no
  accounts_data) on denial or exchange failure, and sweep leftovers for
  the same bank on the next connect. Established connections keep their
  "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE
  SET NULL so deletion has no dependents.
- Show "Banken ar ansluten: hamtar dina konton" while the settings
  panel loads after the callback instead of an anonymous spinner.

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

* fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip

A direct POST to /api/invoices/[id]/send against an already-issued
invoice re-emailed the customer and posted a second revenue verifikat
(createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
and orphaning the first entry. Only the UI hid the button; the v1 route
and the MCP commit executor already rejected non-drafts.

- Non-draft invoices now return 409 INVOICE_ALREADY_SENT.
- The draft to sent status flip is an optimistic lock (status guard plus
  row-count check); journal entry, accrual schedules, PDF archival and
  the invoice.sent event only run for the request that won the flip.
- On a flip failure the journal entry is deferred: the row stays draft
  and a retry re-runs the pipeline, ending with exactly one verifikat.

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

* fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send

- sendInvoiceFromSchedule now auto-creates an online payment link via
  applyPaymentLinkToInvoice before rendering and passes the payment
  link QR to the PDF: parity with the dashboard and v1 send routes,
  which recurring invoices silently lacked.
- The recurring cron persists last_run_warning both when a claimed run
  throws (hourly retries stay visible on the schedule) and when a stale
  schedule is rolled forward, so a deterministic failure can no longer
  skip a month silently.
- Auto-send is blocked for sandbox companies at the email chokepoint
  (freeze-and-retain: the invoice is still generated as a draft),
  covering both the cron and the run-now route with one guard.

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

* feat(salary): close the Fortnox payroll API gaps (phases 1-4)

Payroll now runs end-to-end through the open API, including onboarding a
client from another payroll system, with every write staged for approval.

- v1: per-employee payslips (list/detail/PDF), payslip line writes,
  run roster attach/remove, absence ranges (per-day storage), jamkning
  fields, cutover opening balances (single + atomic bulk PUT), vacation
  balance + vacation-year-close. PUT added to the wrapper's idempotency/
  test-key set (test keys could otherwise write through PUT).
- MCP: 10 new tools (get_employee/get_payslip/list_absence/
  get_vacation_balance reads + staged update_payslip_line,
  register_absence, create_employee, update_employee,
  set_employee_opening_balances, close_vacation_year), executors, risk
  tiers, op-type CHECK expansions. create_employee encrypts personnummer
  at staging: pending_operations never holds plaintext.
- Scope-map audit retrofit: 11 formerly unmapped tools now scoped;
  BREAKING for keys that relied on the 4 default-allow writes.
- Cutover: employee_opening_balances (derived lock trigger, self-unlocks
  on run correction), engine YTD/karens/liability integration,
  Ingaende saldon section in the employee editor.
- Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the
  hourly/daily divisors; legacy 173/21 preserved exactly at defaults so
  existing pay math is byte-identical.
- Vacation ledger + semesterberedning/arsavslut: recomputed per-year day
  balances (synced on book/correct, non-fatal), year-close with the
  min-20 floor, 5-year sparade-dagar expiry to forced payout, and a
  2920/2940 drift adjustment via the bookkeeping engine; Semester
  dashboard card with preview-then-confirm dialog.
- Fix: Zod 4 defaults leak through .partial(), which made every sparse
  employee PATCH fail validation and reset defaulted columns.

Migrations 20260713100000/101000/110000/121000/122000 (applied to
staging with version rows; prod via merge). vacation_ledger renamed from
20260713120000 to avoid colliding with vat_declaration_totals_rpc.

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

* perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC)

The dominant cost was infrastructure: Vercel functions ran in iad1
(Washington D.C.) while Supabase (DB + auth) lives in eu-north-1
(Stockholm), so every request paid 4-5 transatlantic round trips of
auth + company resolution before doing any real work (measured
530-1900ms for single-query GETs in prod logs). Pin functions to arn1
and cut the redundant work on top:

- vercel.json: functions to arn1, same city as the database
- getActiveCompanyId: preference + first-membership queries run in
  parallel; the fallback result doubles as validation in the common
  single-company case (one round trip instead of two sequential)
- withRouteContext: Server-Timing header and authMs/companyMs/handlerMs
  in the op-completed log, so latency is attributable per phase
- dashboard layout: nav badge counts off the critical path; DashboardNav
  loads them client-side via the new use-worklist-badges SWR hook with
  debounced realtime revalidation
- swr (new dependency, approved): global provider; useCompanySettings
  shares one cache entry across consumers and renders from cache on
  back-navigation instead of re-showing skeletons
- /pending: realtime refetch debounced; bulk operations previously
  fired 4 requests per row-change event
- VAT declaration: new get_vat_declaration_totals RPC returns
  per-account totals, settlement-shape detection (#984) and
  source_type counts in ONE round trip instead of paging every
  entry+line through PostgREST. Account lists stay TS-side parameters
  so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion
  coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts;
  DDL already applied to staging.
- bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat
  dynamic-imports the markdown parser, @vercel/speed-insights (new
  dependency, approved) added for real-user timings

The /salary fetch-waterfall fix from the same effort already landed
inside 2084a756.

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

* fix(invoices): settle öre-rounded payments from the mark-paid flow

An invoice with öresavrundning shows a rounded "Att betala" on the PDF;
the customer pays that amount (up to 50 öre off the stored öre total) and
the invoice-page mark-paid flow rejected it with
MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction
match flow already absorbed the residual to 3740.

- PaymentBookingDialog now proposes the rounded bank leg plus the 3740
  residual line (credit when rounded up, debit when rounded down),
  resolved via getDisplayTotal from the per-invoice override and
  company_settings.ore_rounding.
- settleInvoicePayment and the v1 mark-paid route absorb the sub-krona
  residual, gated by planInvoicePaymentForLines: absorption applies ONLY
  when the caller lines carry the exact residual on 3740; otherwise the
  strict plan applies (sub-krona partials stay partial, no-3740
  overshoots keep the 400), so the GL can never diverge from the AR
  sub-ledger.
- planInvoicePayment absorb-band boundary tightened to >= 1 kr: an
  exactly-1-kr overshoot used to slip past both the guard and the absorb
  branch and silently over-record paid_amount (pre-existing on the
  bank-match path).

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

* fix(security): resolve all 7 PR compliance findings

- ASVS V3.3: per-request CSP nonce on the enable-banking finalize page
  (mirrors the mcp-oauth consent page); inline scripts are nonce-bound
- ASVS V16: decouple callback finalize work from the response stream
  (eager promise + next/server after()) so a client disconnect cannot
  drop session persistence or the consent_granted audit emit
- ISO 27001 A.8.15: failed audit-event emits log through the structured
  logger with a stable message for log-based alerting
- ASVS V2.3: recurring-invoice cron and run-now routes resolve
  isSandboxCompany themselves and pass an explicit suppressAutoSend flag
  (defence in depth around the email chokepoint, freeze-and-retain kept)
- ISO 27001 A.8.11: stagePendingOperation rejects plaintext
  personnummer-bearing keys in params/preview_data (key-based guard;
  EF org numbers make value-matching unsafe)
- ASVS V4.5: employee PATCH body is truly sparse; cleared number fields
  are omitted instead of resetting DB values to hardcoded fallbacks
- ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by
  convention, not 403) on the payslip PDF endpoint

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

* feat: implement vacation-year basis change validation and error handling

- Added tests to block vacation-year basis changes when open balances exist.
- Implemented error handling for open-balances guard query failures in the settings route.
- Enhanced absence route to reject reversed date ranges with a validation error.
- Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability.
- Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules.
- Improved error messaging for vacation year closure adjustments.
- Adjusted employee opening balances handling to preserve audit information during upserts.

* feat(settings): add validation to block vacation-year basis change with open balances

feat(absence): reject reversed date ranges in absence queries

fix(absence): update absence handling to use atomic upserts instead of delete+insert

fix(employee): improve validation for jamkning dates in employee updates

fix(opening-balances): ensure created_by field is preserved during upserts

test(absence): enhance tests for absence range and date validations

test(calculation): add tests for age-based avgifter rates and edge cases

test(semesterberedning): validate vacation year closure adjustments and error handling

test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema

* fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:54:33 +02:00
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
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