Commit Graph

21 Commits

Author SHA1 Message Date
Jakob Wennberg 4e1eb3d662 fix(cash-accounts): never propose or accept an orphaned twin ledger as counter-account; match and re-point across sibling ledgers (#1643) (#2010)
* fix(cash-accounts): never propose or accept an orphaned cash-account ledger as counter-account (#1643)

A broken bank reconnect leaves cash_accounts rows that share the live
account's IBAN (held by a revoked connection, or demoted to manual by the
#916 fix). Three consequences are fixed here:

- Problem 4 (silent mis-booking): the own-account transfer detector paired
  with such an orphan and proposed its ledger as the counter-account, and a
  counterparty template learned from that result replayed as 1940/1931 in
  the booking dialog. The detector now tolerates several rows on one IBAN,
  never pairs with the transaction's own row, a disabled row, or a revoked
  holder; the mapping engine drops a "transfer" whose counter equals the
  settlement account; suggest-categories withholds learned suggestions that
  reference an orphaned ledger; and both commit paths (POST
  /api/transactions/[id]/categorize, categorizeMatchedTransaction) reject
  with the new TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT (400). Orphans are
  only refused in the COUNTER position: a stranded row still settles on its
  own ledger, and a manual account without a live IBAN twin is never
  treated as orphaned, so transfers between two live accounts keep booking.
- Problem 1 (match dialog): the ranked unmatched-entries path also offers
  vouchers booked on sibling ledgers of the same IBAN, and manualLink
  accepts a voucher line on a sibling ledger. When it does, the same locked
  UPDATE re-points transactions.cash_account_id to the live sibling row
  (currency-gated, like PATCH /api/transactions/[id]/cash-account) so the
  account-keyed reconciliation does not count a cross-account link as an
  imbalance on both ledgers.
- Problem 3 (naming): allocatePsd2LedgerAccount names the chart account
  BAS-style (BAS reference name for a standard slot, else "Bankkonto
  <CUR>") instead of the ASPSP-reported holder name.

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

* fix(cash-accounts): address review findings on the orphaned-ledger guards (#1643)

One in-memory topology (cash_accounts rows + bank_connections status) now
defines "live", "orphaned" and "same physical account" for the transfer
detector, the match/link flows and every commit guard, so a proposal is
never made that a guard later rejects.

- Finding 1/4/6 (own IBAN as counterparty): findPairableCashAccountByIban
  treats the transaction's own IBAN as "not a transfer": every same-currency
  row on that IBAN is the same physical account, whichever is live, so
  interest stamped with the own IBAN never pairs with a twin (two active
  rows, a demoted-manual twin, or a live twin of a stranded row). Only a
  pocket in another currency on that IBAN can still pair. guardCounterLegs
  refuses a same-IBAN same-currency twin in the counter position on every
  commit path, even when both rows are active.
- Finding 3: with several surviving candidates (currency pockets with no
  discriminator, or two active twins) the finder returns null instead of
  picking the lowest ledger, which is what the pre-PR lookup did.
- Finding 5: the finder drops every row in the orphaned set, the same
  predicate the commit guards use (demoted-manual twins included).
- Finding 9: "live" means enabled + connection status 'active'; an
  expired/error twin of a live row is orphaned, a lone expired connection
  (re-auth window) is not.
- Finding 2: siblings are keyed on (normalized IBAN, currency) in
  describeCashAccountSiblings and the unmatched-entries route, so a SEK
  transaction can no longer link to a voucher whose only bank leg is on the
  EUR pocket of the same IBAN; manualLink rejects that as before.
- Finding 8: manualLink re-points a row only when the voucher sits on the
  LIVE sibling and the own row is not live; the reverse direction links
  without moving the row.
- Finding 7: the v1 REST categorize route runs the same guardCounterLegs
  check after account_override and returns
  TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT. MCP stages through
  categorizeMatchedTransaction, already covered.
- Finding 10: a learned template whose stale 19xx leg is a twin of the
  settlement row is rewritten to the settlement account (it is the bank
  leg, not the counter) instead of refused; suggest-categories exempts each
  transaction's own settlement ledger before withholding a suggestion. The
  error message now covers both the twin and the disconnected case.

Tests pin each behavior (service, detector, manualLink, unmatched-entries,
dashboard and v1 categorize routes, suggest-categories).

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

* fix(cash-accounts): address round-2 review findings (#1643)

1+3. Orphan derivation keyed on (IBAN, currency): loadCashAccountTopology
   now keys the live twin on normalized IBAN plus currency (the rule every
   other "same physical account" check in the PR already used), so a
   manual or deselected GBP/EUR pocket beside a live SEK pocket of a
   multi-currency account is never orphaned, still pairs in the transfer
   detector and is accepted as counter at commit. Twin computation is
   shared (twinLedgersOf).
2. suggest-categories mirrors guardCounterLegs: a learned 19xx leg that is
   a twin of the transaction's own row is rewritten to the settlement
   ledger in the offered suggestion instead of being withheld; only a true
   counter-position orphan (or a twin that would book the settlement
   ledger against itself) is withheld. One topology load per batch
   (loadCounterLegTopology).
4. The free-form dialog path (POST /api/transactions/[id]/book) gets a
   line-level guard (guardBookedCounterLines): a 19xx line that is a twin
   of the transaction's own row or an orphaned ledger, alongside the
   settlement leg, is refused with TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT.
   Only runs when the lines touch two distinct 19xx ledgers. The twin
   rewrite in suggest-categories (2) covers the both-active shape before
   the dialog is even opened.
5. manualLink re-points the row onto the sibling ledger the voucher was
   booked on whenever the sibling is live or the own row is not (both-live
   twins and both-dead rows included); only a live row whose voucher sits
   on a dead sibling links without moving. unmatched-entries now uses
   describeCashAccountSiblings and does not offer dead-sibling vouchers to
   a live row.

DECISIONS.md: the PR's existing review follow-up line amended.

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

* fix(cash-accounts): address round-3 review findings (#1643)

1. Revoked-held rows are no longer orphaned unconditionally. A row whose
   connection is revoked is orphaned only under the twin rule (not live
   AND a live row shares its normalized IBAN + currency), so a
   disconnected-but-real account (the company's only 1930, or two real
   accounts on one revoked connection) stays pairable by the transfer
   detector and bookable as counter on every guarded path. Tests cover
   the no-twin case for getOrphanedCounterLedgers,
   findPairableCashAccountByIban, detectOwnAccountTransfer,
   guardCounterLegs and guardBookedCounterLines; the existing revoked
   tests now use a twin shape.
2. manualLink / unmatched-entries decide the re-point on the destination:
   a new shouldRepointToSibling moves onto a live sibling, or onto a dead
   one only when the own row's holder is gone (released: bank_connection_id
   null or revoked) and no sibling is live. An expired/error/pending own
   row links without moving. SiblingCashAccount gains `released`. Tests:
   expired own row + demoted twin links without moving and the twin's
   vouchers are not offered.
3. loadCounterLegTopology is exercised directly: settlement ledger and
   twins, other-currency pocket, null/unknown ids, cache, orphan set
   equal to guardCounterLegs' refusals on the same fixture, lookup failure.
4. guardBookedCounterLines docstring and the /book route comment now state
   that only the two-cash-legs shape is inspected; a single hand-typed
   19xx line is not (covering it would cost a cash_accounts lookup on
   every ordinary booking). DECISIONS.md lines amended accordingly.

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

* fix(cash-accounts): address round-4 review findings (#1643)

1. Same-connection re-registration twins (the dominant prod shape): two
   enabled rows on one active connection sharing (IBAN, currency) are now
   told apart by balance_updated_at; only the most recently synced row is
   live, the other is a stale twin (orphaned as a counter, never a
   re-point destination, and the transfer detector pairs with the syncing
   row alone). Rows with no stamp or the same stamp both stay live.
2. POST /book: a single 19xx line that is a sibling ledger the row should
   move to (the live twin of a stranded row) re-points cash_account_id in
   the same locked UPDATE that links the voucher, mirroring manualLink.
   guardBookedCounterLines returns { refusedLedger, repointCashAccountId };
   an ordinary booking pays one PK read of the own row.
3. manualLink refuses the link (success:false, Swedish error) when the
   voucher sits only on a dead sibling instead of writing a cross-account
   link with a server-side warn; the REST and MCP link callers reach it
   without the unmatched-entries filter.
4. manualLink judges a voucher touching several sibling ledgers on the
   best of them (a live sibling, else the first the row may move to)
   instead of the first line PostgREST returns.

Tests pinned in lib/cash-accounts, lib/reconciliation and the /book route;
the two DECISIONS.md lines for #1643 amended in place.

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

* fix(cash-accounts): address round-5 review findings (#1643)

1/2/5. Same-connection twin liveness no longer ranks on
   cash_accounts.balance_updated_at (a connect-time snapshot the sync
   never refreshes, inverted on prod in 4 of 5 stamped groups). The live
   row is the one whose external_uid the bank still lists in
   bank_connections.accounts_data (rewritten on every sync); no listing,
   both listed or neither listed keeps both rows live (round-3 behavior).
   getConnectionStatuses selects accounts_data in the same query.
3. guardBookedCounterLines single-19xx-line shape: a twin the row may not
   move to (dead or disabled) is refused with
   TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT instead of posting the only
   bank leg on the dead ledger; an unrelated 19xx line still posts as
   typed. Route test added.
4. Disabled cash_accounts rows are never siblings, so neither manualLink
   nor /book re-points a transaction onto a deselected row; a voucher
   booked only there is refused as a cross-account link.
6. PR body rewritten to the final rules; DECISIONS.md round-4 line
   amended (signal correction, /book refusal, disabled siblings).

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

* fix(cash-accounts): never treat a null external_uid as listed by the bank (#1643)

CashAccount.external_uid is nullable in the shared type; the same-connection
twin rule now skips null uids instead of passing them to Set.has, which
failed the strict type check in CI.

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

* fix(cash-accounts): drop the same-connection twin liveness rule; both rows stay live (#1643)

Two enabled rows on one active bank connection sharing (IBAN, currency)
are no longer ranked. Round 4 ranked on cash_accounts.balance_updated_at
and round 5 on external_uid presence in bank_connections.accounts_data;
each was verified against prod and each was contradicted by it (ingest
routes by the accounts_data entry's ledger_account, which in two groups
points at the OLD row, so the "stale" row is the one still being fed).

Restores the round-3 behavior: neither twin is orphaned, the transfer
finder returns null when both survive, no guard refuses either, and
shouldRepointToSibling treats both as live siblings. No replacement
signal; how to model the shape is a founder decision (PR #2010 review).
getConnectionStatuses no longer selects accounts_data.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 00:26:47 +02:00
Jakob Wennberg 0ed26c2eca fix(mcp,bookkeeping): FX-safe mapping-rule VAT + honest nullable output schemas (#1846)
Follow-up sweep to #1842 for sibling bugs of both classes.

FX denomination (same defect as categorize's vat_amount):
- mapping-engine buildResult computed input VAT, reverse-charge fiktiv
  moms AND the rutor 20-24 basbelopp pair from the transaction-currency
  amount while journal lines are SEK. Now resolved through the same
  lenient SEK ladder buildTransactionEntryLines uses for the gross.
  Reachable at the ledger only via env-gated auto-book (dev/test) and
  the mapping-rules evaluate API, but one flag from production.
- categorize-core's header doc claimed transaction.amount already IS
  SEK; rewritten, it was license for exactly this bug class.

Output-schema nullability (same defect as matched_supplier_id):
- get_payslip.calculation_breakdown: returned as explicit null before
  calculation (the schema description even said so) but typed object.
- get_document_content.mime_type/size_bytes: verbatim nullable columns.
- export_sie: company_name nullable, and org_number was returned but
  never declared under additionalProperties:false, so strict clients
  failed EVERY successful export.
- currency (two transaction listings) and voucher_series (verifikat
  listing): nullable columns with defaults, loosened defensively.
- New declaration-pinning test in output-schema.test.ts.

Known and deliberately not fixed here: skatteverket bank-counterpart
hint compares a foreign amount to SEK (display-only, missed hint);
own-account-detector cross-currency magnitude tie-break (heuristic);
invoice-inbox still withholds rule proposals on foreign rows (guard
can be lifted separately now that the defect it guards is fixed).


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 15:14:21 +02:00
Jakob Wennberg 70845edf69 feat(transactions): structured transaction_method instead of channel-in-the-name (#1459)
* feat(transactions): structured transaction_method instead of channel-in-the-name

Swedish bank feeds embed the payment channel in the description string
("Vercel Jul Överföring via internet", "ANTHROPIC* ... Kortköp/uttag"):
the PSD2 remittance array is joined into one string and the ISO 20022
type codes were dropped at insert. This promotes the channel to data:

- transactions.transaction_method (text + CHECK closed vocabulary: card,
  transfer, bankgiro, plusgiro, swish, autogiro, e_invoice, international,
  deposit, withdrawal, salary, fee, interest, adjustment) plus verbatim
  bank_transaction_code / proprietary_bank_transaction_code evidence
  columns (data_quality_master Appendix B "Layer-A capture").
- classifyTransactionMethod() in lib/transactions/transaction-method.ts:
  explicit source method (Stripe txn.type) > trailing Swedish channel
  phrase > ISO 20022 family/subfamily > proprietary-code keywords > MCC.
  It also splits the clean display title off the description.
- Ingest stores the clean title as description and the full bank string
  as original_description; dedup is untouched (external_id is date+öre,
  the content bridge reads original_description and is prefix-based, and
  a trailing strip leaves a prefix). Enable Banking passes the codes
  through; the Stripe feed sets methods from its balance-txn types.
- Backfill migration classifies existing rows from the description text
  (+ MCC and Stripe prefixes) and strips unedited titles; user-edited
  titles are never rewritten.
- mapping-engine also matches original_description so user rules written
  against the full bank text keep firing.
- UI: the inbox row shows the clean name; clicking it now folds out
  "Betalsätt: Kortköp" etc. (sv/en), making every classified row
  expandable.

A card purchase implies a physical receipt, a Bankgiro/e-invoice payment
implies a supplier invoice: downstream automations can now branch on the
rail instead of regexing display strings.

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

* fix(transactions): anchor counterparty-template identity on original_description

Audit follow-up to the phrase-strip change: counterparty template lookup
AND learning derived their key from merchant_name || description. With
the working title now stripped ("SPOTIFY AB Kortköp" -> "SPOTIFY AB"),
templates learned from the full bank string would only re-match via the
occurrence-gated single-token tier, and single-token counterparties with
fewer than 3 bookings would silently stop matching.

Both sides now read merchant_name || original_description || description:
the immutable bank original is identical across eras (and across user
renames), so every stored key and alias keeps matching exactly. Same
anchoring rationale as buildMerchantHistory in category-suggestions.

Existing tests that relied on the fixture's default original_description
now state it explicitly; two new regression tests pin the era stability
(lookup via alias on the full string, learning key derivation).

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

* fix(transactions): review follow-ups on method classification

- methodFromCodes: two-pass subfamily-then-family scan so a SALA/XBCT
  refinement on the proprietary code beats a bare family match on the
  ISO code, matching the documented precedence; pinned by a test.
- mapping-engine: regression tests for merchant/description patterns
  that only match original_description, including the invalid-regex
  substring fallback and the no-match default.
- Stripe: regression test for the SDK-unmodeled 'tax' balance-txn type
  mapping to 'fee'.

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

* fix(transactions): scope method classification to feed rows + adjective guard

Multi-bank risk hardening before the backfill ships:

- Feed-row scope: classification and title stripping now require a real
  import feed (import_source present, not manual/mcp), both at the
  ingest boundary (USER_CREATED_IMPORT_SOURCES, now exported) and in
  every backfill statement. User-authored titles like "Egen insättning"
  on manual/MCP rows are never classified and never rewritten.
- Adjective guard (TS + SQL): a strip that would leave the title ending
  in a possessive/scope adjective (egen/eget/privat/intern/extern ...)
  is skipped, so "Egen insättning" stays whole even on bank-feed rows;
  the method column still classifies (deposit).
- Unknown bank phrasings remain untouched by construction: an unmatched
  phrase means no method and no rewrite, so the worst case for any bank
  whose vocabulary we have not seen is the status quo.

Pinned by new unit + pg-real cases (user-created exclusion for
NULL/manual/mcp, adjective guard, feed defaults in the pg fixture).

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

* chore(migrations): re-timestamp transaction_method migrations after rebase

Main gained migrations dated 20260729-20260730 (already applied to prod)
while this branch carried 20260728 versions, which would have applied
out-of-order on merge. The files have never reached prod, so renaming to
current timestamps is safe and removes any dependence on the integration's
out-of-order handling. All code/doc references updated; the pg test reads
the backfill by its new filename.

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

* chore(migrations): bump transaction_method versions past prod's max

Main's newest applied migration is 20260730090000 (future-leaning
timestamp), so the previous 202607300731xx rename still sorted before
prod's tail and risked a silent skip on merge-time apply. Versions are
now 20260730100000/20260730100100, strictly after everything applied to
prod. References updated; full migration stream replays clean.

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

* fix(transactions): final review round: keyboard guard + bank_connection_id feed marker

- TransactionInboxCard: row-level Enter/Space handling now ignores events
  bubbling from nested controls, so keyboard activation of Bokför / the
  overflow menu is no longer cancelled by the (now much more common)
  expandable row.
- Feed predicate parity with isImportedTransaction(): a live
  bank_connection_id marks a feed row even when import_source is unset
  (the oldest PSD2 rows predate that column), in both the ingest
  classifier and every backfill statement: those legacy rows now get
  classified instead of being skipped as user-created.
- pg fixture typing uses the TransactionMethod union.

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

* chore(migrations): re-timestamp transaction_method migrations past prod's 20260807 tail

Prod max applied is 20260807170000 (verified by name via list_migrations);
the 20260730-stamped pair would sort before it. References in code,
tests, and DECISIONS.md updated to the new versions.

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

* fix(migrations): enforce, not assume, original_description preservation in the title strip

The strip UPDATE now fills a NULL original_description from the
pre-strip description in the same statement. Prod has zero such rows
(0/25,566 feed-scope rows, verified read-only), and 20260605120000's
backfill plus ingest make the NULL case unreachable on any DB that
replayed history, but the migration should not depend on that history
to avoid losing the only copy of a bank string.

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

* docs: record the compliance-review triage of the backfill's booked-row title strip

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

---------

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-08-08 11:58:51 +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 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

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

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

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

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00
Jakob Wennberg 953980c875 Per-account bank reconciliation + overdue/inbox/privacy fixes (#619)
* feat(reconciliation): scope bank reconciliation per cash account via transactions.cash_account_id

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses PR review (greptile + compliance swarm):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:26:13 +02:00
Mattsson 8a6ce7093e feat: implement skattekonto drift detection and alerting (#525)
* feat: implement skattekonto drift detection and alerting

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

feat: create own account transfer detection

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

feat: establish cash accounts as a first-class entity

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

feat: enhance GL line reconciliation functionality

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

feat: capture counterparty IBAN in transactions

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

* feat: Enhance cash account handling and reconciliation processes

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

* feat(migrations): add backfill for VAT account labels to correct bad seed data

* feat(migrations): add backfill for VAT account labels to correct bad seed data

* fix(ui): improve accessibility for company name position toggle in PDF settings

* fix(bookkeeping): align BAS 2026 reference data with official PDF

Reconciled lib/bookkeeping/bas-data/ against the BAS 2026 v1.1 official
chart (1286 accounts). All real discrepancies fixed:

- 2089 Fond för utvecklingsutgifter: k2_excluded → true
- 8417 Räntekostnader för dold räntekompensation: k2_excluded → true
- 1250, 1260 renamed to "(Fritt konto för Inventarier, verktyg och
  installationer)" — BAS 2026 freed these slots
- Periodiseringsfond 2120-2139: added year suffixes (2120 = "...2020"
  etc.) and added 8 missing accounts (2121-2127, 2129) for years
  2019, 2021-2027. Dropped phantom 2022/2024 prior-parser garbage.
- 4075-4078: EUland → EU-land
- 8411: förlagsoch → förlags- och

Verified: 1282 of 1286 PDF accounts match exactly after edits (remaining
4 are PDF-parser artifacts, not real data). Build clean.

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

* feat(migrations): backfill BAS 2026 account labels in chart_of_accounts

Companion to the TS reference fix. Updates existing companies' rows where
they still carry seed-data typos or generic names that don't match BAS 2026:

- 4075-4078: EUland → EU-land (hyphenation)
- 8411: förlagsoch → förlags- och (hyphenation)
- 2120, 2130-2137, 2139: rename "Periodiseringsfond" (generic, no year) to
  the BAS 2026 canonical name with year suffix

Defensive: every WHERE clause matches an EXACT current value. Rows that
have been manually renamed by users — including those with a wrong year
that may reference legacy fonds from an earlier BAS numbering cycle — are
left untouched. No row is deleted.

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

* feat(migrations): fix wrong-year labels on Periodiseringsfond accounts

Follow-up to 20260513140000. The first backfill only renamed accounts whose
name was the generic "Periodiseringsfond" (no year). Many customers were
seeded from an older BAS numbering cycle where 2126 = "2016", 2127 = "2017",
etc. — BAS 2026 reuses those account numbers for years 2026/2027.

This migration aligns the year tag with the BAS 2026 meaning of each
account number across 2120-2127, 2129, 2130-2137, 2139. Only rows whose
name still starts with "Periodiseringsfond" are touched — customers who
renamed the account to something custom keep their name.

Verified on staging: all 18 accounts now carry BAS 2026 canonical names.

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

* fix(data): update account names and descriptions for clarity and consistency

* fix(migrations): backfill account names for BAS 2026 freed accounts and refine Periodiseringsfond name matching

* feat: enhance VAT handling with reverse charge logic and supplier type support

* feat: implement VAT declaration validation rules and enhance moms box mapping

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:17:32 +02:00
Mattsson ade4ad5971 Fiscal period and multi bank (#228)
* feat: add fiscal period backward chaining and entry date validation

Support creating fiscal periods before the earliest existing period
(backward chaining) for backfill scenarios, alongside the existing
forward chaining. The engine now validates that entry dates fall within
the selected fiscal period, with a Swedish error message. The journal
entry form auto-selects the matching period and shows a warning with
a CreatePeriodDialog when no period covers the entry date.


* feat: support multi-bank-account for imports and reconciliation

Plumb a configurable settlement account through the entire bank import
pipeline — mapping engine, transaction entries, ingest, and
reconciliation — so secondary bank accounts (e.g. 1931, 1932) work
correctly instead of hardcoding 1930. Adds a get_unlinked_bank_lines
RPC that generalizes the existing get_unlinked_1930_lines with a
fallback for backwards compatibility. The bank file import UI now shows
a bank account selector when multiple 19xx accounts exist. Also adds
default_vat_code/sru_code to account creation and fixes uploadDocument
argument order in enable-banking sync.
2026-04-13 11:13:02 +02:00
Mattsson 0dd1f5ebc1 feat: multi-tenant company refactor (GNU-19) (#153)
* feat: multi-tenant company refactor (GNU-19)

Introduce companies table, company_members, and user_preferences to
support multiple companies per user. All data scoping changes from
user_id to company_id across the entire codebase.

Key changes:
- Database migration: new tables, company_id on 40+ tables, backfill,
  RLS rewrite from user_id to company-member-based, updated RPCs
- Types: Company, CompanyMember, CompanyRole, UserPreferences types;
  company_id added to all entity interfaces; companyId on all events
- Engine: all 7 core functions take companyId; storno, period, year-end
  services updated; 16 report generators updated
- Middleware: company context resolution (cookie → prefs → first company)
- API routes: ~120 routes updated with requireCompanyId()
- Frontend: CompanyProvider context, layout/dashboard/onboarding updated
- Extensions: context factory, 9 extensions, all lib files updated
- Tests: 1880 tests passing, all helpers updated with company_id defaults

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

* feat: add database migrations for multi-tenant company and team system (GNU-19)

Adds company_invitations, company creation RPC, team_members, account
deletion RPC, and teams table refactor migrations. Updates base
multi-tenant migration with cascading FKs and onboarding_step column.

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

* feat: add team types and update core infrastructure for multi-tenancy (GNU-19)

Adds TeamRole, MemberSource, and Team types. Refactors Supabase service
client to be stateless, updates middleware for team-aware routing, extends
CompanyContext with team/role fields, and updates extension service types
to accept companyId.

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

* refactor: thread company_id through business logic functions (GNU-19)

Replaces user_id scoping with company_id across all lib modules:
bookkeeping, documents, transactions, invoices, reconciliation, tax,
deadlines, and import. Updates corresponding tests.

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

* refactor: thread company_id through API routes and extensions (GNU-19)

Updates all existing API routes to extract and pass companyId. Updates
enable-banking and arcim-migration extensions for company-scoped
transaction ingestion and sync.

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

* feat: add company and team management API routes (GNU-19)

Adds CRUD endpoints for company members, company invitations, team
members, and team invitations. Includes invite token utilities, email
templates, and company switch server action.

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

* feat: add team/company UI components, pages, and dashboard updates (GNU-19)

Adds CompanySwitcher, ConsultantEmptyState, Step0RoleChoice, company
members and team management panels. Updates dashboard layout for
team-aware routing, onboarding for multi-step role choice, and auth
callback for team invite acceptance. Ignores supabase/.branches/.

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

* fix: add null guards for company in import page (GNU-19)

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

* fix: move appUrl declaration to outer scope in invite route (GNU-19)

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

* fix: add optional chaining for company.name in members section (GNU-19)

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

* fix: add optional chaining for second company.name in members section (GNU-19)

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

* fix: add null guards for company in extension components (GNU-19)

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

* fix: pass companyId to executeSIEImport in arcim-migration extension (GNU-19)

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

* fix: update tests to use companyId instead of userId and improve type handling

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:41:52 +02:00
Jakob Wennberg d8e0a22495 feat: counterparty templates, Skatteverket extension, complete VAT form (#117)
* feat: separate AR/AP/accounting into distinct nav groups (#92)

Split the flat "Finans" sidebar group into three visually distinct
sections — Försäljning (AR), Inköp (AP), and Redovisning — so users
coming from Fortnox immediately find customer invoicing and supplier
invoices as top-level concepts.

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

* feat: journal entry detail view, correction chain, and account name display

- Add journal entry detail page at /bookkeeping/[id] with full entry view
- Add correction chain API and component showing storno relationships
- Add JournalEntryStatusBadge component for entry status display
- Show debit/credit account names in template picker and review dialogs
- Expand client-side BAS account name mapping with additional accounts
- Show account codes on transaction inbox suggestion buttons

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

* fix: address review feedback — N+1 query, duplicate name, nav dedup

- Batch reverse-lookup into single query per BFS iteration (was N+1)
- Differentiate account 2393 from 2893 in display names
- Extract shared loop for desktop/mobile nav group rendering

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

* feat: counterparty templates, Skatteverket extension, VAT form completeness, and UI cleanup

- Add counterparty-based categorization templates (learned from user approvals
  and auto-ingestion) with fuzzy matching in the mapping engine
- Add Skatteverket extension for direct VAT declaration submission via API
- Complete VAT declaration form with all 30 SKV 4700 boxes (ruta 08, 35-42, 50, 60-62)
- Fix ruta 49 formula to include import VAT (ruta 60+61+62)
- Simplify dashboard UI: remove redundant icons from stat cards, customer cards,
  invoice list, supplier invoices; use Badge variants consistently
- Add SkatteverketPanel component to reports page
- Add categorization_templates and skatteverket_tokens migrations
- Update tests and helpers for new types

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

* fix: address PR review feedback — VAT detection, migration timestamps, dedup

- Fix detectVatTreatment to derive actual rate (12%/6%) from VAT line
  description instead of hardcoding standard_25
- Rename skatteverket_tokens migration to 20260324120001 to avoid
  duplicate timestamp with categorization_templates (fixes Supabase
  deployment failure)
- Make refreshAccessToken accept previousRefreshCount param to enforce
  refresh limit contract at the type level
- Fix rate limiter TOCTOU by claiming slot before await
- Extract formatRedovisare/formatRedovisningsperiod to shared
  lib/skatteverket/format.ts — eliminates duplication between
  mappers.ts and SkatteverketPanel.tsx

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:41:12 +01:00
Jakob Wennberg 091d043c85 feat: UI polish, lint fixes, onboarding redesign, help page expansion, and test improvements
Broad update across dashboard pages, components, extensions, and lib code. Includes ESLint config additions, onboarding flow redesign, settings page refactor, help page content expansion, dead code removal, and test mock fixes. Adds dev docs and public assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:05:49 +01:00
Jakob Wennberg 6f4573f380 feat: add foreign currency support, refactor bookkeeping engine, and improve invoice inbox document classification
- Add currency-utils module for SEK conversion with exchange rates
- Refactor createJournalEntry to use draft+commit flow preventing voucher number gaps (BFL 5 kap. 7§)
- Add foreign currency support to invoice entries with per-line SEK conversion
- Centralize category-to-account mapping into single source of truth
- Refactor invoice inbox to use shared document analyzer with document type classification (receipt, supplier invoice, government letter)
- Update mapping engine, supplier invoice entries, and transaction entries
- Fix report component rendering issues
- Add new validation schemas and tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:50:06 +01:00
Jakob Wennberg 03b569d708 refactor: consolidate extension system to general-only with manifest-driven architecture
- Remove all sector-specific extensions (construction, ecommerce, export,
  hotel, restaurant, tech) — only general-purpose extensions remain
- Move NE-bilaga and SRU export from extensions to core reports (lib/reports/)
- Move moms-box-mapping from extensions/export/shared to lib/vat/
- Replace per-extension API routes with catch-all dispatcher
  (app/api/extensions/ext/[...path]/route.ts)
- Add manifest.json for each extension with metadata, env vars, and deps
- Add api-routes.ts pattern for extension-defined API endpoints
- Add code generation scripts (generate-extension-registry, create-extension)
- Add extensions.config.json for opt-in extension loading
- Add extensions.schema.json for config validation
- Add email service interface with noop default (lib/email/service.ts)
- Add CI workflow (core-build.yml) to verify core builds with zero extensions
- Add migration 045: expand account_type CHECK for untaxed_reserves
- Update CLAUDE.md with comprehensive extension system documentation
- Update all report engines and bookkeeping services for new imports
- Clean up extensions.schema.json to only list existing extensions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:32:56 +01:00
Jakob Wennberg 3e7fa45ed6 feat: transaction categorization UX improvements and description matching
Add journal entry preview, human-readable account names, auto-apply VAT,
fallback template suggestions, example prompts, invoice match comparison,
and batch result feedback. Also includes user-description-match extension,
describe/batch-describe API routes, improved AI categorization with multi-
suggestion support, and template embedding search.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:02:06 +01:00
Emil 941fe3a416 Merge remote-tracking branch 'origin/main' into logger 2026-02-24 10:02:36 +01:00
Emil 9b92883d30 Added logger and tests 2026-02-24 10:02:13 +01:00
Jakob Wennberg bbb82866ee feat: add 4 new Swedish bank CSV parsers and improve transaction categorization
Add auto-detecting CSV parsers for Länsförsäkringar, ICA Banken, Skandia,
and Lunar. Refine SEB detection to avoid false matches. Update bank file
upload UI with new bank options and export instructions. Include booking
templates, improved AI categorization, and transaction review enhancements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:38:44 +01:00
Emil 2f953c1031 fix: validate account mappings and handle reverse charge in categorization
Add validation for non-empty debit/credit accounts before creating journal
entries. Generate fiktiv moms lines (2645/2614) for EU reverse charge expenses.
Change default unmapped expense account from 6900 to 6991. Add tests for
reverse charge handling and exhaustive category mapping coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:58:50 +01:00
Jakob Wennberg 91e2c1705a feat: per-line VAT, invoice document types, ledger-based VAT declaration, bank reconciliation, and pagination
Per-line VAT rates:
- Add generatePerRateLines() to group invoice items by vat_rate with separate
  revenue + VAT lines per rate group (invoice-entries.ts)
- Add getAvailableVatRates() and getVatTreatmentForRate() (vat-rules.ts)
- PDF template shows per-line VAT column and per-rate totals for mixed-rate invoices
- Invoice create/review UI supports per-line rate selection
- Types: add vat_rate/vat_amount to InvoiceItem, vat_rate to CreateInvoiceItemInput

Invoice document types (proforma, delivery note):
- Add InvoiceDocumentType, document_type and converted_from_id to Invoice type
- PDF hides prices for delivery notes, adds proforma notice
- Email templates support all document types
- mark-paid skips journal entries for non-invoice document types
- Migration 031: invoice_document_type

Accounting method support:
- Add AccountingMethod type (accrual/cash)
- Migration 032: add_accounting_method column to company_settings

VAT declaration rewrite:
- Rewrite to read directly from general ledger (26xx/3xxx account lines)
  instead of aggregating invoices/transactions/receipts
- ACCOUNT_RUTA mapping drives momsdeklaration boxes from GL balances

Bank reconciliation:
- Transaction ingest now pre-fetches unlinked GL lines and attempts
  auto-reconciliation during import
- Add transaction.reconciled event type
- Add ReconciliationMethod type and reconciliation_method on Transaction
- Migration 030: bank_reconciliation
- New reconciliation engine, API routes, and BankReconciliationView component

Pagination (fetchAllRows):
- New lib/supabase/fetch-all.ts overcomes PostgREST 1000-row limit
- Adopted in all report generators, SIE/SRU export, account list APIs

Fiscal period validation:
- New validate-period-duration.ts enforces max 18 months per BFL 3 kap.
- Applied in period-service.ts and fiscal-periods API

Account mapper simplification:
- Remove Levenshtein/fuzzy matching, use exact account number match only

Swedbank parser improvements:
- Support abbreviated headers (Clnr, Bokfdag, Radnr)
- Use Referens column as counterparty

Chart of accounts management:
- Add DELETE endpoint with system account and usage protection
- PUT uses partial updates
- New AccountCombobox, AddAccountDialog, EditAccountDialog, ChartOfAccountsManager

Tax deadline corrections:
- Rewrite inkomstdeklaration_ab using Skatteverket lookup table
- Rewrite arsredovisning deadline to 7 months after FY end per ÅRL 8:3

Onboarding first fiscal year:
- Add first fiscal year toggle with date pickers and 18-month validation

UI terminology:
- Change "okategoriserad/kategorisera" to "obokförd/bokföra" throughout

Report column fix:
- Fix start_date/end_date to period_start/period_end in report queries

Supplier invoice input:
- CreateSupplierInvoiceItemInput uses amount field (legacy quantity/unit_price kept)

Misc:
- SIE import uses upsert for idempotent account creation
- account-descriptions.ts falls back to BAS reference data
- Add invoice_default_notes to CompanySettings
- Update CLAUDE.md to reflect current project state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:57:15 +01:00
Jakob Wennberg a25d10a528 Initial copy from influencer-biz 2026-02-13 14:00:46 +01:00