* 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>
This commit is contained in:
Mattsson
2026-07-28 19:50:16 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent 151ef51cc5
commit 65c6d4c178
83 changed files with 4546 additions and 295 deletions
@@ -193,6 +193,58 @@ describe('POST /api/bookkeeping/accounts', () => {
expect(body.error).toContain('5010')
})
it('keeps the plain 409 when the colliding account is active', async () => {
const { supabase } = createCapturingSupabase([
{ error: { code: '23505', message: 'dup' } },
{ data: { is_active: true } },
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts', {
method: 'POST',
body: {
account_number: '5010',
account_name: 'Lokalhyra',
account_type: 'expense',
normal_balance: 'debit',
},
})
const { status, body } = await parseJsonResponse<{ error: string }>(
await createPOST(req, routeParams)
)
expect(status).toBe(409)
expect(typeof body.error).toBe('string')
})
it('returns ACCOUNT_EXISTS_INACTIVE when the colliding account is deactivated', async () => {
const { supabase, calls } = createCapturingSupabase([
{ error: { code: '23505', message: 'dup' } },
{ data: { is_active: false } },
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts', {
method: 'POST',
body: {
account_number: '3910',
account_name: 'Hyresintäkter egen',
account_type: 'revenue',
normal_balance: 'credit',
},
})
const { status, body } = await parseJsonResponse<{
error: { code: string; message: string; details?: { account_number?: string } }
}>(await createPOST(req, routeParams))
expect(status).toBe(409)
expect(body.error.code).toBe('ACCOUNT_EXISTS_INACTIVE')
expect(body.error.message).toContain('3910')
expect(body.error.details?.account_number).toBe('3910')
// The is_active lookup must be company-scoped, not a bare account_number
// match: the same number exists under every other company too.
const eqArgs = calls.filter((c) => c.method === 'eq').map((c) => c.args)
expect(eqArgs).toContainEqual(['company_id', 'company-1'])
expect(eqArgs).toContainEqual(['account_number', '3910'])
})
it('forwards default_vat_rate into the insert', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: { account_number: '3740', default_vat_rate: 0 } },
@@ -420,4 +472,54 @@ describe('POST /api/bookkeeping/accounts/activate', () => {
expect(body.activated).toBe(1)
expect(body.unknown).toEqual(['0000'])
})
// The only route back for a deactivated account: it is in the chart, so the
// insert path would hit the unique constraint. It must be flipped back on
// instead, including for custom numbers the BAS reference has never heard of.
it('reactivates an existing inactive account instead of inserting it', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: [{ account_number: '3910', is_active: false }] }, // existing lookup
{ data: [{ account_number: '3910' }] }, // update result
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/activate', {
method: 'POST',
body: { account_numbers: ['3910'] },
})
const { status, body } = await parseJsonResponse<{
activated: number
reactivated: number
skipped: number
unknown: string[]
}>(await activatePOST(req, routeParams))
expect(status).toBe(200)
expect(body.reactivated).toBe(1)
expect(body.activated).toBe(0)
expect(body.unknown).toEqual([])
expect(calls.find((c) => c.method === 'update')?.args[0]).toEqual({ is_active: true })
expect(calls.some((c) => c.method === 'insert')).toBe(false)
})
it('skips an account that is already active', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: [{ account_number: '1930', is_active: true }] },
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/activate', {
method: 'POST',
body: { account_numbers: ['1930'] },
})
const { status, body } = await parseJsonResponse<{
activated: number
reactivated: number
skipped: number
}>(await activatePOST(req, routeParams))
expect(status).toBe(200)
expect(body.skipped).toBe(1)
expect(body.reactivated).toBe(0)
expect(calls.some((c) => c.method === 'update')).toBe(false)
expect(calls.some((c) => c.method === 'insert')).toBe(false)
})
})
@@ -31,7 +31,7 @@ function createCapturingSupabase(results: { data?: unknown; error?: unknown }[])
const result = results[idx++] ?? { data: null, error: null }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const b: any = {}
for (const m of ['select', 'eq', 'order', 'range', 'maybeSingle', 'single']) {
for (const m of ['select', 'eq', 'in', 'order', 'range', 'maybeSingle', 'single']) {
b[m] = (...args: unknown[]) => {
calls.push({ method: m, args })
return b
@@ -85,9 +85,21 @@ describe('GET /api/bookkeeping/accounts/reference', () => {
})
})
/**
* bas-lookup answers "can this number be activated at all?", which is what
* gates ActivateAccountsDialog's confirm button. It consults the company's own
* chart first, so a custom (non-BAS) account the company deactivated still
* comes back known — before that it read as unknown and the button stayed dead.
*/
describe('GET /api/bookkeeping/accounts/bas-lookup', () => {
function authWith(chartRows: unknown[]) {
const { supabase, calls } = createCapturingSupabase([{ data: chartRows }])
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
return calls
}
beforeEach(() => {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
authWith([])
})
it('returns 401 when not authenticated', async () => {
@@ -96,7 +108,10 @@ describe('GET /api/bookkeeping/accounts/bas-lookup', () => {
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await basLookupGET(createMockRequest('/api/bookkeeping/accounts/bas-lookup'))
const res = await basLookupGET(
createMockRequest('/api/bookkeeping/accounts/bas-lookup'),
routeParams
)
expect(res.status).toBe(401)
})
@@ -105,20 +120,82 @@ describe('GET /api/bookkeeping/accounts/bas-lookup', () => {
searchParams: { numbers: '1930,0000' },
})
const { status, body } = await parseJsonResponse<{
data: Array<{ account_number: string; known: boolean }>
}>(await basLookupGET(req))
data: Array<{ account_number: string; known: boolean; in_chart: boolean }>
}>(await basLookupGET(req, routeParams))
expect(status).toBe(200)
expect(body.data.find((a) => a.account_number === '1930')?.known).toBe(true)
const bas = body.data.find((a) => a.account_number === '1930')
expect(bas?.known).toBe(true)
// Known from the static catalog, not held by the company: activating it
// inserts a new row rather than reviving one.
expect(bas?.in_chart).toBe(false)
expect(body.data.find((a) => a.account_number === '0000')?.known).toBe(false)
})
it('reports a deactivated custom account as known and in the chart', async () => {
const calls = authWith([
{
account_number: '3910',
account_name: 'Hyresintäkter egen',
account_class: 3,
account_type: 'revenue',
is_active: false,
},
])
const req = createMockRequest('/api/bookkeeping/accounts/bas-lookup', {
searchParams: { numbers: '3910' },
})
const { status, body } = await parseJsonResponse<{
data: Array<{
account_number: string
account_name: string | null
known: boolean
in_chart: boolean
is_active: boolean
}>
}>(await basLookupGET(req, routeParams))
expect(status).toBe(200)
// 3910 is not in the BAS catalog: without the chart read this was known:false.
const row = body.data[0]
expect(row.known).toBe(true)
expect(row.in_chart).toBe(true)
expect(row.is_active).toBe(false)
expect(row.account_name).toBe('Hyresintäkter egen')
// Defense in depth alongside RLS: another company's chart must not answer.
expect(calls.filter((c) => c.method === 'eq').map((c) => c.args)).toContainEqual([
'company_id',
'company-1',
])
})
it("prefers the company's own account name over the BAS catalog name", async () => {
authWith([
{
account_number: '1930',
account_name: 'Företagskonto SEB',
account_class: 1,
account_type: 'asset',
is_active: true,
},
])
const req = createMockRequest('/api/bookkeeping/accounts/bas-lookup', {
searchParams: { numbers: '1930' },
})
const { body } = await parseJsonResponse<{
data: Array<{ account_name: string | null; in_chart: boolean }>
}>(await basLookupGET(req, routeParams))
expect(body.data[0].account_name).toBe('Företagskonto SEB')
expect(body.data[0].in_chart).toBe(true)
})
it('rejects an oversized numbers list with 400', async () => {
const many = Array.from({ length: 2001 }, (_, i) => String(10000 + i)).join(',')
const req = createMockRequest('/api/bookkeeping/accounts/bas-lookup', {
searchParams: { numbers: many },
})
const { status } = await parseJsonResponse(await basLookupGET(req))
const { status } = await parseJsonResponse(await basLookupGET(req, routeParams))
expect(status).toBe(400)
})
})
@@ -1,21 +1,40 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface ChartRow {
account_number: string
account_name: string
account_class: number | null
account_type: string | null
is_active: boolean | null
}
/**
* GET /api/bookkeeping/accounts/bas-lookup?numbers=5010,2641
*
* Returns BAS reference metadata (name, class, type) for a list of account
* numbers. Used by ActivateAccountsDialog to render human-readable labels
* before the user confirms activation. Unknown numbers are returned with
* account_name=null so the UI can flag them as non-BAS.
* Resolves account numbers to a human-readable label plus whether they can be
* activated at all. Used by ActivateAccountsDialog to render the list before
* the user confirms.
*
* Pure in-memory reference lookup — no tenant data, so no company context is
* resolved; requireAuth() keeps it behind auth (MFA on hosted).
* Two sources, company first:
* 1. The company's own chart_of_accounts. A row here is activatable even if
* it isn't a standard BAS account (custom accounts) and even if it is
* currently deactivated: POST /accounts/activate reactivates it. The
* company's own account_name wins, since it may have been renamed.
* 2. The static BAS reference, for accounts not yet added to the chart.
*
* Numbers in neither source are returned with account_name=null and
* known=false so the UI can flag them as non-BAS and offer "create".
*
* `in_chart` / `is_active` let callers tell "will be added" from "will be
* reactivated". Consulting the company chart is why this route resolves a
* company context (it was a pure in-memory reference lookup before).
*/
export async function GET(request: Request) {
const auth = await requireAuth()
if (auth.error) return auth.error
export const GET = withRouteContext('bookkeeping.accounts.bas-lookup', async (request, ctx) => {
const { supabase, companyId } = ctx
const { searchParams } = new URL(request.url)
const raw = searchParams.get('numbers') || ''
@@ -28,10 +47,47 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Too many account numbers' }, { status: 400 })
}
// Paginated: `numbers` can hold up to 2000 entries and PostgREST silently
// caps an unranged select at 1000 rows. A truncated chart read would report
// accounts that ARE in the chart as known:false / in_chart:false, so the
// dialog would offer "create" for an account that already exists.
let chartRows: ChartRow[]
try {
chartRows = await fetchAllRows<ChartRow>(
({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class, account_type, is_active')
.eq('company_id', companyId)
.in('account_number', numbers)
// Paging is only stable under a unique total order.
.order('account_number', { ascending: true })
.range(from, to),
{ dedupeBy: (row) => row.account_number },
)
} catch (error) {
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const inChart = new Map(chartRows.map((row) => [row.account_number, row]))
const data = numbers.map((num) => {
const own = inChart.get(num)
if (own) {
return {
account_number: own.account_number,
account_name: own.account_name,
account_class: own.account_class,
account_type: own.account_type,
known: true,
in_chart: true,
is_active: own.is_active,
}
}
const ref = getBASReference(num)
if (!ref) {
return { account_number: num, account_name: null, known: false }
return { account_number: num, account_name: null, known: false, in_chart: false, is_active: false }
}
return {
account_number: ref.account_number,
@@ -39,8 +95,10 @@ export async function GET(request: Request) {
account_class: ref.account_class,
account_type: ref.account_type,
known: true,
in_chart: false,
is_active: false,
}
})
return NextResponse.json({ data })
}
})
+22
View File
@@ -5,6 +5,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { CreateAccountSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
// Response shapes are legacy `{ data }` / `{ error: string }` — several pages
// (import, supplier-invoices, article form) consume the list directly.
@@ -111,6 +112,27 @@ export const POST = withRouteContext(
if (error) {
if (error.code === '23505') {
// The unique constraint counts deactivated rows, so "already exists"
// covers two very different situations. Only look up which one it is
// on the failing path: the happy path stays a single insert.
const { data: existing } = await supabase
.from('chart_of_accounts')
.select('is_active')
.eq('company_id', companyId)
.eq('account_number', body.account_number)
.maybeSingle()
if (existing && existing.is_active === false) {
// Re-creating can never succeed here; the caller must reactivate
// instead. The distinct code is what AddAccountDialog keys on to
// offer that as a one-click action rather than a dead end.
return errorResponseFromCode('ACCOUNT_EXISTS_INACTIVE', log, {
status: 409,
messageSv: `Kontonummer ${body.account_number} finns redan i din kontoplan men är inaktiverat.`,
details: { account_number: body.account_number },
})
}
return NextResponse.json(
{ error: `Kontonummer ${body.account_number} finns redan i din kontoplan.` },
{ status: 409 },
@@ -0,0 +1,82 @@
/**
* GET /api/customers/{id}/personal-number
*
* The deliberate drill-in behind the mask: returns the full personnummer for
* one individual customer.
*
* Every other customer read surface (list, detail, export) returns
* '********-1234'. Without this endpoint the value was write-only by
* construction: a user could store a personnummer and then never verify what
* had actually been stored, which is the failure this exists to close. It
* mirrors the employee convention, where the list masks and the master GET
* returns all 12 digits (app/api/v1/companies/[companyId]/employees/[id]).
*
* Gated on the write role even though it only reads. .compliance/ropa.yaml
* listed `no_full_value_read_endpoint` among the safeguards for
* customers.personal_number; this endpoint retires that measure, so it keeps
* the exposure as narrow as the purpose allows. The person who needs to verify
* a stored personnummer is the one who typed it and can correct it, which is
* exactly the non-viewer role. A viewer (typically an external consultant with
* read-only access) keeps seeing the mask.
*
* The read is logged with the actor, never with the value, so a reveal is
* attributable. audit_log is written by DB triggers only, so this is a
* structured application log rather than an audit row.
*/
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { revealStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'customer.personal_number.reveal',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, user, log, requestId } = ctx
const opLog = log.child({ customerId: id })
const { data, error } = await supabase
.from('customers')
.select('id, personal_number')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (error) {
if (error.code === 'PGRST116') {
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
}
opLog.error('customer fetch before personal number reveal failed', error)
return errorResponseFromCode('INTERNAL_ERROR', opLog, {
requestId,
details: { reason: getUserErrorMessage(error) },
})
}
if (!data.personal_number) {
return errorResponseFromCode('CUSTOMER_NO_PERSONAL_NUMBER', opLog, { requestId })
}
let personalNumber: string | null
try {
personalNumber = revealStoredCustomerPersonalNumber(data.personal_number)
} catch (err) {
// Same row state the mask renders as '********-????'. Answer with the
// specific code so the UI can tell the user to retype it, rather than
// with a 500 that reads as "try again later".
opLog.error('customer personal_number decrypt failed on reveal', {
reason: err instanceof Error ? err.message : String(err),
})
return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_UNREADABLE', opLog, { requestId })
}
// Attributable without being a leak: who revealed which customer, never
// the number itself.
opLog.info('customer personal number revealed', { userId: user.id })
return NextResponse.json({ data: { personal_number: personalNumber } })
},
{ requireWrite: true },
)
+15 -14
View File
@@ -5,19 +5,9 @@ import { validateVatNumber } from '@/lib/vat/vies-client'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
import { isMaskedPersonalNumber } from '@/lib/customers/mask-personal-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* Shape produced by maskCustomerPersonalNumber: no read path ever returns the
* stored personnummer, only '********-1234'. A client that PATCHes back a
* customer it just read therefore submits the mask, which must mean "leave the
* stored value alone", never "store this literally" and never "clear it".
* components/customers/CustomerForm.tsx strips it before sending, but that
* guard belongs here too: any other client (script, agent, future UI) that
* skips it would otherwise destroy the value.
*/
const MASKED_PERSONAL_NUMBER = /^\*{8}-\d{4}$/
export const GET = withRouteContext(
'customer.get',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
@@ -83,11 +73,22 @@ export const PATCH = withRouteContext(
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { requestId })
}
// The masked sentinel counts as "field not supplied": it carries no new
// No ordinary read returns the stored personnummer, only '********-1234',
// or '********-????' when the stored value could not be decrypted. A
// client that PATCHes back a customer it just read therefore submits one
// of those, and it counts as "field not supplied": it carries no new
// value, so it must not be validated, stored or treated as a clear.
// CustomerForm strips it before sending, but the guard belongs here too:
// any other client (script, agent, future UI) that skips it would
// otherwise destroy the value.
//
// Both forms are recognized via lib/customers/mask-personal-number.ts so
// this route, UpdateCustomerSchema and the form cannot disagree about what
// counts as a mask. They previously each carried their own '-1234'-only
// copy, which made an undecryptable row uneditable in every field, not
// just this one.
const personalNumberSubmitted =
body.personal_number !== undefined &&
!(typeof body.personal_number === 'string' && MASKED_PERSONAL_NUMBER.test(body.personal_number))
body.personal_number !== undefined && !isMaskedPersonalNumber(body.personal_number)
const effectiveType = body.customer_type ?? existing.customer_type
if (personalNumberSubmitted && body.personal_number && effectiveType !== 'individual') {
@@ -54,6 +54,8 @@ type CustomerWrite = { personal_number?: string | null }
// Synthetic personnummer, never a real one.
const PERSONAL_NUMBER = '19900101-1234'
const MASKED = '********-1234'
// What a row whose stored ciphertext cannot be decrypted reads back as.
const UNDECRYPTABLE_MASK = '********-????'
/**
* The shape customers_personal_number_check accepts as of 20260726110000:
@@ -211,6 +213,88 @@ describe('personal_number on customer routes', () => {
expect(body.data.personal_number).toBe(MASKED)
})
it('keeps the stored value when the undecryptable placeholder is sent back', async () => {
// A row whose ciphertext cannot be decrypted reads back as
// '********-????'. That is still a mask, so PATCHing it must leave the
// column alone. When only '********-1234' was recognized, this 400'd and
// took the whole edit with it: the customer's name and address could not
// be saved either, over a field the user had no way to correct.
queryResult = {
data: {
id: 'customer-1',
customer_type: 'individual',
name: 'Anna Andersson',
personal_number: 'ab'.repeat(40),
},
error: null,
}
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: {
name: 'Anna Andersson',
city: 'Göteborg',
personal_number: UNDECRYPTABLE_MASK,
},
}),
routeParams,
)
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
expect(status).toBe(200)
// The rest of the edit went through...
expect(captured.update[0]).toMatchObject({ name: 'Anna Andersson', city: 'Göteborg' })
// ...and the unreadable ciphertext was neither stored over nor cleared.
expect(captured.update[0]).not.toHaveProperty('personal_number')
expect(body.data.personal_number).toBe(UNDECRYPTABLE_MASK)
})
it('replaces an undecryptable value when the user types a real personnummer', async () => {
// The repair path, and the only "backfill" that can exist: nothing can
// recover the unreadable ciphertext, but the user can overwrite it.
queryResult = {
data: {
id: 'customer-1',
customer_type: 'individual',
personal_number: encryptPersonnummer(PERSONAL_NUMBER),
},
error: null,
}
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { personal_number: PERSONAL_NUMBER },
}),
routeParams,
)
expect(response.status).toBe(200)
const written = (captured.update[0] as CustomerWrite).personal_number as string
expect(written).toMatch(CIPHERTEXT_SHAPE)
expect(decryptPersonnummer(written)).toBe(PERSONAL_NUMBER)
})
it('rejects the undecryptable placeholder on create', async () => {
// Same rule as the '-1234' mask: on create there is no stored value to
// preserve, so a mask is a client error.
const response = await POST(
createMockRequest('/api/customers', {
method: 'POST',
body: {
name: 'Anna Andersson',
customer_type: 'individual',
personal_number: UNDECRYPTABLE_MASK,
},
}),
{ params: Promise.resolve({}) },
)
expect(response.status).toBe(400)
expect(captured.insert).toHaveLength(0)
})
it('does not treat a masked value as a personal number on a corporate customer', async () => {
queryResult = {
data: { id: 'customer-1', customer_type: 'individual', name: 'Anna A' },
@@ -0,0 +1,171 @@
/**
* GET /api/customers/[id]/personal-number: the drill-in behind the mask.
*
* Every other customer read surface returns '********-1234'. Without this
* endpoint the field was write-only by construction: a user could store a
* personnummer and had no way to check what had actually been stored, which is
* what made an unreadable value indistinguishable from a rendering fault.
*
* What these tests pin:
* - auth and tenancy run before anything is decrypted
* - a stored ciphertext comes back as the full personnummer
* - an undecryptable row answers with its own code, not a 500, because the
* user can fix it in one step by typing the number in again
*/
import { NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
let queryResult: { data: unknown; error: unknown } = { data: null, error: null }
const buildChain = (): unknown =>
new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (value: unknown) => void) => resolve(queryResult)
}
return () => buildChain()
},
},
)
const supabase = { from: vi.fn(() => buildChain()) }
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { GET } from '../[id]/personal-number/route'
// Synthetic personnummer, never a real one.
const PERSONAL_NUMBER = '19900101-1234'
// Hex of the shape customers_personal_number_check accepts that is NOT valid
// ciphertext: the GCM auth tag can never verify.
const GARBAGE_HEX = 'ab'.repeat(40)
describe('GET /api/customers/[id]/personal-number', () => {
const routeParams = { params: Promise.resolve({ id: 'customer-1' }) }
const request = () => createMockRequest('/api/customers/customer-1/personal-number')
beforeEach(() => {
vi.clearAllMocks()
queryResult = { data: null, error: null }
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 403 for a viewer, who keeps seeing the mask', async () => {
// The drill-in retires the `no_full_value_read_endpoint` safeguard in
// .compliance/ropa.yaml, so it stays as narrow as the purpose allows: the
// person who needs to verify a personnummer is the one who can correct it.
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json(
{ error: 'Du har endast läsbehörighet i detta företag.' },
{ status: 403 },
),
})
queryResult = {
data: { id: 'customer-1', personal_number: encryptPersonnummer(PERSONAL_NUMBER) },
error: null,
}
const response = await GET(request(), routeParams)
expect(response.status).toBe(403)
expect(await response.text()).not.toContain(PERSONAL_NUMBER)
})
it('returns 401 when unauthenticated, before touching the row', async () => {
requireAuthMock.mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(request(), routeParams)
expect(response.status).toBe(401)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns the full personnummer for a stored ciphertext', async () => {
queryResult = {
data: { id: 'customer-1', personal_number: encryptPersonnummer(PERSONAL_NUMBER) },
error: null,
}
const response = await GET(request(), routeParams)
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
expect(status).toBe(200)
expect(body.data.personal_number).toBe(PERSONAL_NUMBER)
})
it('returns a legacy plaintext value unchanged', async () => {
queryResult = { data: { id: 'customer-1', personal_number: '900101-1234' }, error: null }
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(
await GET(request(), routeParams),
)
expect(status).toBe(200)
expect(body.data.personal_number).toBe('900101-1234')
})
it('returns 404 when the customer does not exist in the active company', async () => {
queryResult = { data: null, error: { code: 'PGRST116', message: 'No rows returned' } }
const response = await GET(request(), { params: Promise.resolve({ id: 'missing' }) })
expect(response.status).toBe(404)
})
it('returns 404 when the customer has no stored personnummer', async () => {
queryResult = { data: { id: 'customer-1', personal_number: null }, error: null }
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await GET(request(), routeParams),
)
expect(status).toBe(404)
expect(body.error.code).toBe('CUSTOMER_NO_PERSONAL_NUMBER')
})
it('answers with a specific code, not a 500, when the value cannot be decrypted', async () => {
// The row that renders as '********-????'. Retrying never helps, so this
// must not look transient: the UI turns this code into "type it in again".
queryResult = { data: { id: 'customer-1', personal_number: GARBAGE_HEX }, error: null }
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await GET(request(), routeParams),
)
expect(status).toBe(422)
expect(body.error.code).toBe('CUSTOMER_PERSONAL_NUMBER_UNREADABLE')
})
it('never leaks the stored ciphertext, whatever the outcome', async () => {
const stored = encryptPersonnummer(PERSONAL_NUMBER)
queryResult = { data: { id: 'customer-1', personal_number: stored }, error: null }
const raw = await (await GET(request(), routeParams)).text()
expect(raw).not.toContain(stored)
})
})
+22 -9
View File
@@ -8,6 +8,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Customer } from '@/types'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -17,18 +18,30 @@ export const GET = withRouteContext(
async (_request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { data, error } = await supabase
.from('customers')
.select('*')
.eq('company_id', companyId)
.order('name', { ascending: true })
if (error) {
log.error('customer list failed', error)
// Paginated: PostgREST caps an unranged select at 1000 rows, which would
// hand the roster page a silently truncated customer list. Ordered on the
// PK because paging is only stable under a unique total order; the
// name sort callers expect is re-applied below.
let rows: Customer[]
try {
rows = await fetchAllRows<Customer>(
({ from, to }) =>
supabase
.from('customers')
.select('*')
.eq('company_id', companyId)
.order('id', { ascending: true })
.range(from, to),
{ dedupeBy: (row) => row.id },
)
} catch (error) {
log.error('customer list failed', error as Error)
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ data: (data ?? []).map(maskCustomerRow) })
rows.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '', 'sv'))
return NextResponse.json({ data: rows.map(maskCustomerRow) })
},
)
@@ -31,7 +31,18 @@ const CURRENCY_DEFAULTS: Record<string, string> = {
vi.mock('@/lib/cash-accounts/service', () => ({
upsertFromPsd2: (...args: unknown[]) => mockUpsertFromPsd2(...args),
allocatePsd2LedgerAccount: (...args: unknown[]) => mockAllocate(...args),
// The route resolves ledgers through resolvePsd2LedgerAccount (IBAN match
// first, allocation second). mockAllocate remains the allocation stand-in;
// the wrapper puts its answer in the resolver's envelope so the existing
// "did we allocate?" assertions keep their meaning. Tests that exercise the
// IBAN path override resolvePsd2LedgerAccount's outcome via mockAllocate's
// own implementation.
resolvePsd2LedgerAccount: async (...args: unknown[]) => {
const ledgerAccount = await mockAllocate(...args)
if (!ledgerAccount) return null
if (typeof ledgerAccount === 'object') return ledgerAccount
return { ledgerAccount, reuseCashAccountId: null, source: 'allocated' }
},
defaultLedgerForCurrency: (currency: string) =>
CURRENCY_DEFAULTS[currency.toUpperCase()] ?? '1930',
}))
@@ -251,6 +262,70 @@ describe('GET /api/extensions/enable-banking/callback', () => {
).toBe('1935')
})
it('reuses the mapping of a known IBAN when the bank returns a new account uid', async () => {
// The reconnect case behind the reported bug: the ASPSP minted a fresh
// account uid, so the (connection, uid) lookup finds nothing and the old
// behavior allocated an overflow slot, silently moving the user's 1930
// mapping. Matching on IBAN has to bring both the ledger and the existing
// row along.
mockFrom.mockImplementation((table: string) => {
if (table === 'cash_accounts') {
// Nothing mirrored under the NEW uid.
return mockChain({ data: [], error: null })
}
const chain: Record<string, unknown> = {}
chain.update = vi.fn(() => chain)
chain.eq = vi.fn().mockReturnValue(chain)
chain.select = vi.fn().mockReturnValue(chain)
chain.in = vi.fn().mockReturnValue(chain)
chain.single = vi.fn().mockResolvedValue({
data: {
id: 'conn-1',
bank_name: 'TestBank',
company_id: 'company-1',
user_id: 'user-1',
status: 'expired',
},
error: null,
})
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
return chain
})
mockAllocate.mockResolvedValue({
ledgerAccount: '1930',
reuseCashAccountId: 'cash-row-1',
source: 'iban',
})
mockCreateSession.mockResolvedValue({
session_id: 'sess-2',
accounts: [
{ uid: 'acc-new', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' },
],
access: { valid_until: '2024-12-31T00:00:00Z' },
aspsp: { name: 'TestBank', country: 'SE' },
})
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(200)
// Reading the body drives the stream, which is what awaits the finalize
// work the assertions below inspect.
await response.text()
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
const mirrored = mockUpsertFromPsd2.mock.calls[0][2] as {
ledger_account: string
reuse_cash_account_id: string | null
external_uid: string
}
expect(mirrored.ledger_account).toBe('1930')
// The existing row is promoted, not duplicated: it keeps its linked
// transactions and picks up the new uid.
expect(mirrored.reuse_cash_account_id).toBe('cash-row-1')
expect(mirrored.external_uid).toBe('acc-new')
})
it('deletes the fresh row and streams an error redirect when the session exchange fails', async () => {
const deleteCalls: unknown[] = []
const updateCalls: unknown[] = []
@@ -8,7 +8,7 @@ import type { StoredAccount } from '@/extensions/general/enable-banking/types'
import { eventBus } from '@/lib/events/bus'
import {
upsertFromPsd2,
allocatePsd2LedgerAccount,
resolvePsd2LedgerAccount,
defaultLedgerForCurrency,
} from '@/lib/cash-accounts/service'
import { renderFinalizeShell, renderFinalizeRedirect } from './finalize-page'
@@ -340,11 +340,13 @@ async function finalizeConnection(
}
// Mirror each PSD2 account into cash_accounts so routing decisions read
// from the canonical entity table. Accounts already mirrored (reconnect)
// keep their ledger_account — re-deriving it here would clobber the
// user's remaps. New accounts each get a free BAS class-19 slot: a bank
// returning N same-currency accounts must not collide on the UNIQUE
// (company_id, ledger_account) constraint by all defaulting to 1930.
// from the canonical entity table. Accounts already mirrored under the same
// (connection, uid) keep their ledger_account — re-deriving it here would
// clobber the user's remaps. Everything else goes through
// resolvePsd2LedgerAccount, which matches on IBAN before allocating: a
// re-authorization that mints new account uids, and a fresh connect that
// mints a whole new connection row, both have to land back on the mapping
// the user already chose instead of overflowing into the next free slots.
const { data: mirroredRows } = await supabase
.from('cash_accounts')
.select('external_uid, ledger_account')
@@ -360,13 +362,28 @@ async function finalizeConnection(
for (const account of accountsMetadata) {
let targetLedger = existingLedgerByUid.get(account.uid)
let reuseCashAccountId: string | null = null
if (!targetLedger) {
targetLedger =
(await allocatePsd2LedgerAccount(supabase, updatedConnection.company_id, updatedConnection.user_id, {
const resolved = await resolvePsd2LedgerAccount(
supabase,
updatedConnection.company_id,
updatedConnection.user_id,
{
iban: account.iban,
currency: account.currency,
accountName: account.name,
exclude: assignedLedgers,
})) ?? defaultLedgerForCurrency(account.currency)
},
)
targetLedger = resolved?.ledgerAccount ?? defaultLedgerForCurrency(account.currency)
reuseCashAccountId = resolved?.reuseCashAccountId ?? null
if (resolved?.source === 'iban') {
console.log('[enable-banking] Reused existing ledger mapping for known IBAN', {
connectionId: updatedConnection.id,
uid: account.uid,
ledgerAccount: targetLedger,
})
}
}
assignedLedgers.add(targetLedger)
if (account.ledger_account !== targetLedger) {
@@ -382,6 +399,7 @@ async function finalizeConnection(
iban: account.iban ?? null,
name: account.name ?? null,
enabled: account.enabled ?? true,
reuse_cash_account_id: reuseCashAccountId,
})
} catch (cashErr) {
const reason = cashErr instanceof Error ? cashErr.message : String(cashErr)
@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Covers the session health probe added to the daily bank sync.
*
* Before it, a connection only ever left 'active' by failing a transaction
* fetch, so a session killed bank-side (several ASPSPs drop the previous AIS
* session when the same PSU authorizes again) kept rendering as healthy with a
* stale last_synced_at. Connections the sync loop skips (capability gate, every
* account deselected) and connections parked in 'pending_selection' were never
* checked at all.
*/
interface ClientState {
active: Record<string, unknown>[]
probeCandidates: Record<string, unknown>[]
updates: { id: unknown; payload: Record<string, unknown> }[]
}
const mocks = vi.hoisted(() => ({
createClient: vi.fn(),
probeSessionHealth: vi.fn(),
syncAccountTransactions: vi.fn(),
hasCapability: vi.fn(),
runReconciliation: vi.fn(),
}))
vi.mock('@supabase/supabase-js', () => ({
createClient: mocks.createClient,
}))
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: vi.fn(() => null),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/extensions/general/enable-banking/lib/sync', () => ({
syncAccountTransactions: (...args: unknown[]) => mocks.syncAccountTransactions(...args),
}))
vi.mock('@/lib/entitlements/has-capability', () => ({
hasCapability: (...args: unknown[]) => mocks.hasCapability(...args),
}))
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
runReconciliation: (...args: unknown[]) => mocks.runReconciliation(...args),
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD: 0.9,
}))
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ isConfigured: () => false, sendEmail: vi.fn() }),
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appName: 'Accounted' }),
}))
// Partial mock: the route also imports the real message constants and the
// consent-expiry helpers, and only the probe needs stubbing.
vi.mock('@/extensions/general/enable-banking/lib/api-client', async () => {
const actual = await vi.importActual<
typeof import('@/extensions/general/enable-banking/lib/api-client')
>('@/extensions/general/enable-banking/lib/api-client')
return {
...actual,
probeSessionHealth: (...args: unknown[]) => mocks.probeSessionHealth(...args),
}
})
import { REAUTH_REQUIRED_MESSAGE } from '@/extensions/general/enable-banking/lib/api-client'
import { GET } from '../route'
function makeClient(state: ClientState) {
return {
from: () => {
const filters: Record<string, unknown> = {}
let isDelete = false
let updatePayload: Record<string, unknown> | null = null
function result() {
if (isDelete) return { data: [], error: null }
if (updatePayload) {
state.updates.push({ id: filters.id, payload: updatePayload })
return { data: null, error: null }
}
// The sync loop asks for status = 'active'; the probe pass asks for
// status IN ('active','pending_selection').
if (filters['in:status']) return { data: state.probeCandidates, error: null }
if (filters.status === 'active') return { data: state.active, error: null }
return { data: null, error: null }
}
const chain: Record<string, unknown> = {}
const passthrough = ['select', 'not', 'lt', 'gte', 'order', 'limit']
for (const method of passthrough) chain[method] = vi.fn(() => chain)
chain.eq = vi.fn((col: string, value: unknown) => {
filters[col] = value
return chain
})
chain.in = vi.fn((col: string, values: unknown) => {
filters[`in:${col}`] = values
return chain
})
chain.delete = vi.fn(() => {
isDelete = true
return chain
})
chain.update = vi.fn((payload: Record<string, unknown>) => {
updatePayload = payload
return chain
})
chain.maybeSingle = vi.fn(() => Promise.resolve({ data: null, error: null }))
chain.then = (onFulfilled: (value: unknown) => unknown) =>
Promise.resolve(result()).then(onFulfilled)
return chain
},
auth: { admin: { getUserById: vi.fn().mockResolvedValue({ data: { user: null } }) } },
}
}
function connection(overrides: Record<string, unknown> = {}) {
return {
id: 'conn-1',
company_id: 'company-1',
user_id: 'user-1',
bank_name: 'TestBank',
session_id: 'sess-1',
status: 'active',
consent_expires: '2099-01-01T00:00:00Z',
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }],
initial_sync_completed_at: '2026-01-01T00:00:00Z',
last_expiry_notification_at: null,
error_message: null,
...overrides,
}
}
let state: ClientState
const originalUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const originalServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://example.supabase.co'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
state = { active: [], probeCandidates: [], updates: [] }
mocks.createClient.mockImplementation(() => makeClient(state))
mocks.hasCapability.mockResolvedValue(true)
mocks.syncAccountTransactions.mockResolvedValue({ imported: 0, duplicates: 0, errors: 0 })
mocks.probeSessionHealth.mockResolvedValue('unknown')
})
afterEach(() => {
process.env.NEXT_PUBLIC_SUPABASE_URL = originalUrl
process.env.SUPABASE_SERVICE_ROLE_KEY = originalServiceKey
})
function cronRequest(): Request {
return new Request('http://localhost:3000/api/extensions/enable-banking/sync/cron')
}
describe('GET /api/extensions/enable-banking/sync/cron: session health probe', () => {
it('expires a connection whose session the bank has killed', async () => {
state.probeCandidates = [connection()]
mocks.probeSessionHealth.mockResolvedValue('dead')
const response = await GET(cronRequest())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({ probedDead: 1 })
expect(state.updates).toEqual([
{
id: 'conn-1',
payload: { status: 'expired', error_message: REAUTH_REQUIRED_MESSAGE },
},
])
})
it('probes a connection parked in pending_selection, which the sync loop never touches', async () => {
state.probeCandidates = [connection({ status: 'pending_selection', last_synced_at: null })]
mocks.probeSessionHealth.mockResolvedValue('dead')
await GET(cronRequest())
expect(mocks.probeSessionHealth).toHaveBeenCalledWith('sess-1')
expect(state.updates[0].payload).toMatchObject({ status: 'expired' })
})
it('leaves the connection alone when the probe is inconclusive', async () => {
// Flipping a live connection to expired costs the user a full BankID
// re-authorization, so only a definite 'dead' may act.
state.probeCandidates = [connection()]
mocks.probeSessionHealth.mockResolvedValue('unknown')
await GET(cronRequest())
expect(state.updates).toHaveLength(0)
})
it('leaves the connection alone when the session is alive', async () => {
state.probeCandidates = [connection()]
mocks.probeSessionHealth.mockResolvedValue('alive')
await GET(cronRequest())
expect(state.updates).toHaveLength(0)
})
it('does not probe a connection the sync loop just proved alive', async () => {
// A successful transaction fetch is stronger evidence than the probe, and
// the extra call would burn the ASPSP's per-consent request budget.
state.active = [connection()]
state.probeCandidates = [connection()]
await GET(cronRequest())
expect(mocks.syncAccountTransactions).toHaveBeenCalledTimes(1)
expect(mocks.probeSessionHealth).not.toHaveBeenCalled()
})
it('probes a connection the capability gate skipped instead of leaving it "Aktiv"', async () => {
// The silent skip that let a dead connection sit at 'active' for days.
state.active = [connection()]
state.probeCandidates = [connection()]
mocks.hasCapability.mockResolvedValue(false)
mocks.probeSessionHealth.mockResolvedValue('dead')
await GET(cronRequest())
expect(mocks.syncAccountTransactions).not.toHaveBeenCalled()
expect(mocks.probeSessionHealth).toHaveBeenCalledWith('sess-1')
expect(state.updates[0].payload).toMatchObject({ status: 'expired' })
})
it('probes a connection whose accounts are all deselected', async () => {
// This branch reports 'synced' without writing last_synced_at, so the row
// looks fresh forever.
state.active = [connection({ accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: false }] })]
state.probeCandidates = [connection()]
mocks.probeSessionHealth.mockResolvedValue('dead')
await GET(cronRequest())
expect(mocks.syncAccountTransactions).not.toHaveBeenCalled()
expect(state.updates[0].payload).toMatchObject({ status: 'expired' })
})
it('runs the probe even when there is nothing to sync', async () => {
state.active = []
state.probeCandidates = [connection({ status: 'pending_selection' })]
mocks.probeSessionHealth.mockResolvedValue('dead')
const response = await GET(cronRequest())
await expect(response.json()).resolves.toMatchObject({ processed: 0, probedDead: 1 })
})
})
@@ -8,6 +8,7 @@ import {
import {
isConsentExpiringSoon,
getDaysUntilExpiry,
probeSessionHealth,
SessionExpiredError,
REAUTH_REQUIRED_MESSAGE,
SYNC_FAILED_MESSAGE,
@@ -78,10 +79,9 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
return errorResponse(connError, ctx.log, { requestId: ctx.requestId })
}
if (!connections || connections.length === 0) {
return NextResponse.json({ message: 'No active connections to sync', processed: 0 })
}
// No early return on an empty set: the health probe below still has work to
// do (a company whose only connection is parked in 'pending_selection' has
// nothing to sync but can absolutely have a dead session).
const startTime = Date.now()
const TIME_BUDGET_MS = 50_000 // 50s: leave 10s margin for Vercel timeout
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
@@ -93,11 +93,14 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
imported: number
duplicates: number
errors: number
status: 'synced' | 'expired' | 'expiring_soon' | 'error'
// 'skipped' = nothing was fetched from the bank (every account deselected),
// so this run proves nothing about whether the session is still alive. Kept
// distinct from 'synced' because the health probe below keys on it.
status: 'synced' | 'skipped' | 'expired' | 'expiring_soon' | 'error'
daysUntilExpiry?: number | null
}[] = []
for (const connection of connections) {
for (const connection of connections ?? []) {
if (Date.now() - startTime > TIME_BUDGET_MS) {
ctx.log.info('time budget reached', { processedSoFar: results.length })
break
@@ -180,7 +183,7 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
imported: 0,
duplicates: 0,
errors: 0,
status: 'synced',
status: 'skipped',
daysUntilExpiry: daysLeft,
})
continue
@@ -321,6 +324,81 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
}
}
// Health probe for connections this run did NOT prove alive by syncing them.
//
// A sync failure is the only thing that used to move a connection off
// 'active', which leaves two silent holes: connections the loop skipped
// (capability not entitled, every account deselected, time budget reached)
// and connections that never sync at all because they are still parked in
// 'pending_selection'. Both kept rendering as healthy with a stale
// last_synced_at while their PSD2 session was already dead bank-side, so the
// user read old balances as current. Probing costs one cheap session call
// per connection and only ever acts on a definite 'dead'.
const probeResults: { connectionId: string; bankName: string }[] = []
const PROBE_BUDGET_MS = 100_000
const provenAlive = new Set(
results.filter(r => r.status === 'synced' || r.status === 'expiring_soon').map(r => r.connectionId)
)
const { data: unverified, error: unverifiedError } = await supabase
.from('bank_connections')
.select('id, company_id, user_id, bank_name, session_id, status, last_expiry_notification_at')
.in('status', ['active', 'pending_selection'])
.not('session_id', 'is', null)
.order('last_synced_at', { ascending: true, nullsFirst: true })
.limit(100)
if (unverifiedError) {
ctx.log.error('failed to fetch connections for health probe', unverifiedError, {
message: unverifiedError.message,
})
}
for (const connection of unverified ?? []) {
if (Date.now() - startTime > PROBE_BUDGET_MS) {
ctx.log.info('probe budget reached', { probedSoFar: probeResults.length })
break
}
if (provenAlive.has(connection.id)) continue
// Per-connection isolation, matching the sync loop above: without it a
// single network blip aborts probing for every remaining candidate in the
// batch and the coverage gap stays silent until tomorrow's run.
try {
const health = await probeSessionHealth(connection.session_id as string)
if (health !== 'dead') continue
const { error: updateError } = await supabase
.from('bank_connections')
.update({ status: 'expired', error_message: REAUTH_REQUIRED_MESSAGE })
.eq('id', connection.id)
// Only claim the connection was marked dead once the write landed.
// Notifying (and counting) on an unpersisted update would tell the user
// to re-authorize while the row still reads 'active'.
if (updateError) {
ctx.log.error('failed to mark probed-dead connection as expired', updateError, {
connectionId: connection.id,
})
continue
}
await sendConsentExpiryNotification(supabase, connection, 0, true, baseUrl)
ctx.log.info('health probe found a dead session', {
connectionId: connection.id,
bankName: connection.bank_name,
previousStatus: connection.status,
})
probeResults.push({ connectionId: connection.id, bankName: connection.bank_name })
} catch (err) {
ctx.log.error('health probe failed for connection', err as Error, {
connectionId: connection.id,
bankName: connection.bank_name,
})
}
}
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
const totalExpired = results.filter(r => r.status === 'expired').length
const totalExpiringSoon = results.filter(r => r.status === 'expiring_soon').length
@@ -332,6 +410,7 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
totalExpired,
totalExpiringSoon,
totalFailed,
probedDead: probeResults.length,
})
return NextResponse.json({
@@ -340,6 +419,8 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
totalExpired,
totalExpiringSoon,
totalFailed,
probedDead: probeResults.length,
probeResults,
results,
})
})
+37
View File
@@ -80,6 +80,43 @@ describe('GET /api/invoices', () => {
expect(body.count).toBe(2)
})
it('masks the embedded customer personnummer in the list', async () => {
// The customer:customers(*) join carries the stored personal_number out to
// the browser. A legacy plaintext value is used here so the assertion does
// not depend on PERSONNUMMER_ENCRYPTION_KEY being set in the test env; the
// ciphertext path lands on the same masked shape.
const invoices = [
{ ...makeInvoice(), customer: { id: 'cust-1', name: 'Test', personal_number: '19900101-1234' } },
]
enqueue({ data: invoices, error: null, count: 1 })
const request = createMockRequest('/api/invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{
data: { customer: { personal_number: string | null; name: string } }[]
}>(response)
expect(status).toBe(200)
expect(body.data[0].customer.personal_number).toBe('********-1234')
expect(JSON.stringify(body)).not.toContain('19900101-1234')
// Masking must not strip the rest of the embed.
expect(body.data[0].customer.name).toBe('Test')
})
it('leaves an invoice without an embedded customer untouched', async () => {
// PostgREST returns customer: null when the customer was removed; the mask
// must be null-safe rather than 500 the whole list.
const invoices = [{ ...makeInvoice(), customer: null }]
enqueue({ data: invoices, error: null, count: 1 })
const request = createMockRequest('/api/invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{ data: { customer: null }[] }>(response)
expect(status).toBe(200)
expect(body.data[0].customer).toBeNull()
})
it('applies status filter', async () => {
enqueue({ data: [], error: null, count: 0 })
+9 -6
View File
@@ -11,6 +11,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Logger } from '@/lib/logger'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { maskEmbeddedCustomer } from '@/lib/customers/protect-personal-number'
ensureInitialized()
@@ -42,7 +43,9 @@ export const GET = withRouteContext(
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ data, count })
// Mask the embedded customer's personnummer: the customers(*) join
// carries the stored ciphertext, which has no business reaching a client.
return NextResponse.json({ data: (data ?? []).map(maskEmbeddedCustomer), count })
},
)
@@ -240,7 +243,7 @@ export const POST = withRouteContext(
})
}
return NextResponse.json({ data: completeInvoice })
return NextResponse.json({ data: maskEmbeddedCustomer(completeInvoice) })
},
{ requireWrite: true },
)
@@ -346,9 +349,9 @@ async function createCreditNote(
requestId,
})
}
return NextResponse.json({ data: reopenedCreditNote })
return NextResponse.json({ data: maskEmbeddedCustomer(reopenedCreditNote) })
}
return NextResponse.json({ data: existingCreditNote })
return NextResponse.json({ data: maskEmbeddedCustomer(existingCreditNote) })
}
const creditNoteNumber = `KR-${originalInvoice.invoice_number}`
@@ -403,7 +406,7 @@ async function createCreditNote(
.eq('company_id', companyId)
.eq('creation_complete', true)
.maybeSingle()
if (racedCreditNote) return NextResponse.json({ data: racedCreditNote })
if (racedCreditNote) return NextResponse.json({ data: maskEmbeddedCustomer(racedCreditNote) })
}
log.error('credit note insert failed', creditNoteError)
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
@@ -469,5 +472,5 @@ async function createCreditNote(
// A credit note is only issued when the user sends it or marks it as sent.
// Until then it is a non-editable draft: no journal entry is created and
// the original invoice remains in its current state.
return NextResponse.json({ data: completeCreditNote })
return NextResponse.json({ data: maskEmbeddedCustomer(completeCreditNote) })
}
@@ -8,6 +8,7 @@ import {
currencyColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
import { formatLatestVouchers, LATEST_VOUCHERS_LABEL } from '@/lib/reports/latest-vouchers-format'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface FlatRow {
@@ -83,6 +84,21 @@ export const GET = withRouteContext('report.balansrapport.xlsx', async (request,
ub: report.beraknat_resultat,
})
// Reconciliation aid (#1267). reportToWorkbook has no preamble concept, so
// this rides as a first body row, the same way the dimension disclosure
// does on the other report exports.
const vouchersLabel = formatLatestVouchers(report.latest_vouchers)
if (vouchersLabel) {
rows.unshift({
group: `${LATEST_VOUCHERS_LABEL}: ${vouchersLabel}`,
account_number: '',
account_name: '',
ib: null as unknown as number,
period_change: null as unknown as number,
ub: null as unknown as number,
})
}
const buffer = reportToWorkbook<FlatRow>([
{
name: 'Balansrapport',
@@ -9,6 +9,7 @@ import {
currencyColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
import { formatLatestVouchers, LATEST_VOUCHERS_LABEL } from '@/lib/reports/latest-vouchers-format'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface FlatRow {
@@ -88,6 +89,20 @@ export const GET = withRouteContext('report.resultatrapport.xlsx', async (reques
prior_period: report.net_result_prior,
})
// Reconciliation aid (#1267). reportToWorkbook has no preamble concept, so
// this rides as a first body row, the same way the disclosure below does.
// Unshifted first so the disclosure, when present, still ends up on top.
const vouchersLabel = formatLatestVouchers(report.latest_vouchers)
if (vouchersLabel) {
rows.unshift({
group: `${LATEST_VOUCHERS_LABEL}: ${vouchersLabel}`,
account_number: '',
account_name: '',
current_period: null as unknown as number,
prior_period: null as unknown as number,
})
}
// Partial-view disclosure survives the file boundary: a filtered export
// must never be mistakable for the authoritative report (BFNAR 2013:2).
const disclosure = dimensionFilterDisclosure(dimFilter.dimensions)
@@ -47,6 +47,20 @@ function makeRequest(query: string) {
return new Request(`http://localhost/api/reports/vat-declaration${query}`)
}
/**
* chart_of_accounts builder for fetchDynamicRuta05Accounts: which of the
* company's own class 3 accounts carry a "Standard moms" and therefore belong
* in ruta 05. Empty by default, i.e. a plain BAS chart.
*/
function chartBuilder(accounts: Array<{ account_number: string; default_vat_rate: number }> = []) {
const result = { data: accounts, error: null }
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'not', 'order']) b[m] = vi.fn().mockReturnValue(b)
b.range = vi.fn().mockResolvedValue(result)
b.then = (resolve: (v: unknown) => void) => resolve(result)
return b
}
describe('GET /api/reports/vat-declaration', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -56,6 +70,7 @@ describe('GET /api/reports/vat-declaration', () => {
error: null,
})
mockSupabase.rpc.mockResolvedValue({ data: rpcPayload(), error: null })
mockSupabase.from.mockImplementation(() => chartBuilder())
})
it('returns 401 when not authenticated', async () => {
@@ -131,9 +146,10 @@ describe('GET /api/reports/vat-declaration', () => {
expect(body.data.periodLabel).toBe('Kvartal 3 2026')
// Regression guard: the dead company_settings round trip is gone and
// resolvePeriodDates makes no DB call for calendar quarters, so the
// handler issues exactly one PostgREST call: the totals RPC.
expect(mockSupabase.from).not.toHaveBeenCalled()
// resolvePeriodDates makes no DB call for calendar quarters. The only
// table read left is chart_of_accounts, for the company's own ruta 05
// accounts (#1261).
expect(mockSupabase.from.mock.calls.map(([t]) => t)).toEqual(['chart_of_accounts'])
expect(mockSupabase.rpc).toHaveBeenCalledTimes(1)
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'get_vat_declaration_totals',
@@ -145,7 +161,7 @@ describe('GET /api/reports/vat-declaration', () => {
)
})
it('happy path monthly: no table queries, one RPC', async () => {
it('happy path monthly: no period lookup, one RPC', async () => {
const res = await GET(
makeRequest('?periodType=monthly&year=2026&period=7'),
{ params: Promise.resolve({}) },
@@ -155,7 +171,7 @@ describe('GET /api/reports/vat-declaration', () => {
expect(body.data.rutor.ruta49).toBe(21800)
expect(body.data.periodLabel).toBe('Juli 2026')
expect(mockSupabase.from).not.toHaveBeenCalled()
expect(mockSupabase.from).not.toHaveBeenCalledWith('fiscal_periods')
expect(mockSupabase.rpc).toHaveBeenCalledTimes(1)
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'get_vat_declaration_totals',
@@ -27,23 +27,40 @@ interface SupabaseShape {
*/
function buildSupabase(
linesResult: { data: unknown; error: unknown },
fiscalPeriodResult: { data: unknown; error: unknown } = { data: null, error: null }
fiscalPeriodResult: { data: unknown; error: unknown } = { data: null, error: null },
chartAccounts: Array<{ account_number: string; default_vat_rate: number }> = []
): SupabaseShape {
const chartResult = { data: chartAccounts, error: null }
return {
rpc: vi.fn().mockResolvedValue(linesResult),
from: vi.fn().mockImplementation(() => ({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
or: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue(fiscalPeriodResult),
range: vi.fn().mockResolvedValue(linesResult),
then: (resolve: (v: unknown) => void) => resolve(linesResult),
})),
from: vi.fn().mockImplementation((table: string) => {
// Ruta 05 also collects the company's own momspliktiga intäktskonton,
// read off chart_of_accounts rather than the fixed ACCOUNT_RUTA map.
if (table === 'chart_of_accounts') {
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
not: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
range: vi.fn().mockResolvedValue(chartResult),
then: (resolve: (v: unknown) => void) => resolve(chartResult),
}
}
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
or: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue(fiscalPeriodResult),
range: vi.fn().mockResolvedValue(linesResult),
then: (resolve: (v: unknown) => void) => resolve(linesResult),
}
}),
}
}
@@ -207,6 +224,50 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => {
})
})
describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: ruta 05 accounts', () => {
/** The p_accounts array the route handed to get_vat_ruta_source_lines. */
function rpcAccounts(supabase: SupabaseShape): string[] {
return (supabase.rpc.mock.calls[0][1] as { p_accounts: string[] }).p_accounts
}
function get(ruta: string) {
const req = createMockRequest(
`/api/reports/vat-declaration/ruta/${ruta}/sources`,
{ searchParams: { periodType: 'monthly', year: '2026', period: '5' } }
)
return GET(req, createMockRouteParams({ ruta }))
}
it('drills into the company own revenue accounts too (#1261)', async () => {
// Without this the drill-down would list a smaller sum than the ruta 05
// figure it drills into: the konto feeds the total but not the source list.
const supabase = buildSupabase({ data: [], error: null }, { data: null, error: null }, [
{ account_number: '3013', default_vat_rate: 0.06 },
])
authOk(supabase)
expect((await get('05')).status).toBe(200)
const accounts = rpcAccounts(supabase)
expect(accounts).toContain('3013')
expect(accounts).toContain('3001') // static mapping still there
})
it('leaves other rutor on the static mapping alone', async () => {
const supabase = buildSupabase({ data: [], error: null }, { data: null, error: null }, [
{ account_number: '3013', default_vat_rate: 0.06 },
])
authOk(supabase)
expect((await get('10')).status).toBe(200)
const accounts = rpcAccounts(supabase)
expect(accounts).toContain('2611')
expect(accounts).not.toContain('3013')
expect(supabase.from).not.toHaveBeenCalledWith('chart_of_accounts')
})
})
describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: period resolution', () => {
// A first räkenskapsår may run up to 18 months (BFL 3 kap 3 §), and
// helårsmoms is filed per räkenskapsår, not per calendar year
@@ -4,6 +4,7 @@ import {
ACCOUNT_RUTA,
resolvePeriodDates,
} from '@/lib/reports/vat-declaration'
import { fetchDynamicRuta05Accounts } from '@/lib/reports/vat-revenue-accounts'
import type { ReportSourceLine } from '@/lib/reports/source-lines'
import type { VatDeclarationRutor, VatPeriodType } from '@/types'
@@ -44,6 +45,14 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>(
.filter(([, m]) => m.box === rutaKey)
.map(([acc]) => acc)
// Ruta 05 also collects the company's own momspliktiga intäktskonton, which
// ACCOUNT_RUTA cannot know about (#1261). Without them the drill-down would
// list a smaller sum than the figure it drills into.
if (rutaKey === 'ruta05') {
const { accounts } = await fetchDynamicRuta05Accounts(supabase, companyId)
accountsForRuta.push(...accounts)
}
if (accountsForRuta.length === 0) {
return NextResponse.json(
{ error: `Ruta ${rutaParam} har inga underliggande konton` },
@@ -16,7 +16,7 @@ vi.mock('@/lib/logger', () => ({
}),
}))
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
@@ -454,6 +454,25 @@ describe('POST /api/transactions/[id]/match-supplier-invoice: non-FX paths', ()
expect(body.remaining_amount).toBe(0)
})
// The suggestion pointer must not survive the match that consumes it: this
// request marks the invoice paid, so a surviving hint would point at a
// settled invoice. The customer-invoice route has always cleared its own
// field; this one did not.
it('clears potential_supplier_invoice_id when it links the matched transaction', async () => {
enqueueHappyPath({
transaction: { amount: -1000, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const txUpdate = findCalls('transactions', 'update').at(-1)?.[0]
expect(txUpdate).toMatchObject({
supplier_invoice_id: SI_UUID,
potential_supplier_invoice_id: null,
is_business: true,
})
})
it('öresavrundning: a whole-krona Bankgiro payment settles an öre-bearing invoice in full via 3740', async () => {
// The reported bug: invoice 11 231,25, bank paid 11 231 → previously left
// 0,25 stranded as partially_paid. Now → paid, with 0,25 booked to 3740.
@@ -416,6 +416,11 @@ export const POST = withRouteContext(
.from('transactions')
.update({
supplier_invoice_id,
// Clear the suggestion now that it is a confirmed link, mirroring the
// customer-invoice route. Leaving it set kept a pointer at an invoice
// this very request just marked paid, i.e. the stale-pointer state
// every read path now has to defend against.
potential_supplier_invoice_id: null,
journal_entry_id: journalEntryId,
is_business: true,
})