* 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
parent 151ef51cc5
commit 65c6d4c178
83 changed files with 4546 additions and 295 deletions
+15 -2
View File
@@ -60,7 +60,12 @@ processing_activities:
purpose: >-
Identifiera en privatkund när företagets avtal, fakturering eller
kundadministration kräver en entydig identitet. Personnumret får endast
sparas på kundtypen privatkund och exponeras endast maskerat i API och UI.
sparas på kundtypen privatkund. Samtliga listnings- och exportytor i API
och UI exponerar det endast maskerat (fyra sista siffrorna). Undantaget är
en enskild uppslagsyta (GET /api/customers/{id}/personal-number) som
returnerar hela värdet för en kund i taget, kräver skrivroll och loggas
med aktör men aldrig med värdet, så att den som registrerat ett
personnummer kan kontrollera vad som faktiskt sparats.
lawful_basis: art_6_1_b
special_category_basis: null
controller: gnubok-tenant
@@ -88,7 +93,15 @@ processing_activities:
- masked_api_and_ui_output_last4_only
- write_role_required
- rls_company_scoped
- no_full_value_read_endpoint
# Replaces the previous `no_full_value_read_endpoint`. A single explicit
# drill-in now exists (GET /api/customers/{id}/personal-number): the
# value was otherwise write-only, so a user could store a personnummer
# and never verify what had been stored. Every listing surface still
# masks; the drill-in requires the write role, returns one customer at a
# time, and logs actor + customer id without the value.
- full_value_read_only_via_single_customer_drill_in
- full_value_read_requires_write_role
- full_value_read_logged_with_actor_never_value
- id: agi.submit
name: AGI inlämning till Skatteverket
+12
View File
@@ -632,3 +632,15 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-27] Bolagsskatt add-back (#1051): sumPostedYearEndDispositions now adds back 78xx planenlig avskrivning alongside 88xx and 7533, and excludes fiscal_periods.closing_entry_id from its fetch. Shipping only this "Stage 1" half of the issue: it corrects the tax base and the periodiseringsfond 25 % cap with no migration and no displayed-figure change. The issue's other half (making /rapporter show bokslut entries by moving generateIncomeStatement to excludeFinalClosingEntry) is deliberately NOT done here: it duplicates the exclusion in the kpi_report_aggregates RPC (so it needs a migration plus a pg test), it changes displayed profit for every company that ran the bokslut flow, and it requires removing the add-back at four call sites, including the one that caused the original too-high-tax customer bug. The closing-entry exclusion is part of Stage 1 rather than a follow-up because closing verifikat do carry 78xx/88xx/7533 reversal lines on production, so without it the new add-back silently cancels itself once the year is closed. The issue's stated constraint that source_type='year_end' is load-bearing for the iXBRL RR/BR split is stale: build-input.ts and arsredovisning/build-data.ts already moved to excludeFinalClosingEntry.
[2026-07-27] Documents bucket WORM (#1208): dropped the production-only `users_delete_own_documents` DELETE policy on storage.objects and pinned the invariant with a name-agnostic pg-real test, rather than adding a storage DELETE policy for authenticated users or building the orphan-cleanup script the issue asks for. The policy existed in no migration (dashboard drift, alongside `users_read_own_documents` / `users_upload_own_documents`, which production has INSTEAD of this repo's `documents_select_own` / `documents_insert_own`) and let any user delete, with a normal browser token, the storage bytes of documents linked to posted verifikat: rakenskapsinformation under BFL 7 kap 2 §. Neither deleteDocument()'s linked-check nor block_document_deletion() reaches that far; both protect the row, and the row survives pointing at nothing. Verified reproducible against a local replay of the full migration stream: with the policy present the uploader's own DELETE removes a legacy-layout object, with it dropped the DELETE matches zero rows. Dropping it breaks nothing because every in-app remove() on this bucket has run service-role since #1215. The two legacy read/insert policies are deliberately left alone: the Phase B backfill from 20260726092000 has not run, so dropping the legacy SELECT would make most existing documents unreadable. That is Phase C. The 326 orphan objects (49.5 MB) the issue also describes are NOT cleaned up here: irreversible deletion against 7-year-retention data is not worth 49 MB without a separate report-only pass. The test asserts no DELETE and no UPDATE policy over the bucket under ANY name, because the hole arrived under a name this repo never used.
[2026-07-27] Foreign 0 % supplier invoices (#1042): shipped as an advisory banner keyed on supplier_type + reverse_charge, NOT as the "confirm the reason for 0 % VAT (exempt vs import vs other)" picker the issue asks for. Two reasons. First, supplier_invoices.vat_treatment is pure metadata: createSupplierInvoiceRegistrationEntry branches on reverse_charge + supplier_type + per-line vat_rate and never reads it, and get_vat_declaration_totals maps rutor from journal account numbers alone, so a stored reason would change no accounting output. Second, an "import" option would be false confidence: nothing in the codebase can book import VAT (no generator emits 2615/2625/2635 or the 4545-4547 basis accounts), so offering it would imply ruta 50/60 were handled when they are not. The real defect underneath the issue is narrower and is what this fixes: a foreign supplier at 0 % with reverse charge left off books no 26x4 leg and no 44xx/45xx basis, emptying ruta 20-24/30-32/48. The check is deliberately silent for swedish_business, where 0 % is a genuine exemption belonging in no ruta, and never blocks submission, because a non-EU goods purchase cleared at customs is legitimately 0 % without reverse charge and forcing the switch there would book a wrong verifikat. Import VAT support stays a separate, larger issue.
[2026-07-28] /submit-pr removed at Emil's request; /resolve-pr is now the single PR skill and absorbed submit-pr's full contents (publish stage included, skipped when a PR already exists). Folding rather than plain deletion was necessary because /fix lane mode and /resolve-pr both called into it, so deleting alone would have left two skills pointing at nothing and lanes unable to push. All rules survive verbatim, including the one-fix-commit discipline and the bots-edit-comments-in-place trap. Backup of the old file kept in the session scratchpad only.
[2026-07-28] /orchestrate deleted the same day it was built; parallel work is now one /fix per terminal, each isolated by EnterWorktree. Two things killed the coordinator: a Claude instance cannot observe another instance's terminal, so it could only ever track lanes via a file the lanes themselves wrote (making it a reader, not a controller), and Emil says "it works" in the lane's own terminal, so the coordinator was never in the core loop. EnterWorktree then removed its last real job. The merge lock, shared board and file-scope collision holds went with it: /fix now stops at the commit and suggests /resolve-pr, so Emil approves each merge by hand and serialises them himself. Residual hazard (another session merged while this branch was tested) is handled where it already was, in /resolve-pr Stage 0: fetch, merge origin/main, migration-collision check, re-verify, and void the certification if the merge touched the feature's own files. Trade accepted knowingly: this drops the full-autonomy-including-merge grant from earlier the same day, in exchange for a much leaner /fix. Branch protection on main is OFF; enable it when a second human joins.
[2026-07-28] /fix reverted to its 2026-07-07 shape and trimmed further: issue -> understand -> plan card gate -> implement -> self-verify -> test card -> "it works" -> commit, and it ends there. Same-day additions removed: the Stage 0 worktree/bun-install/port-probing block, the `git merge origin/main` sync step, the tone-of-voice reply draft, and the closing /resolve-pr suggestion; the CLAUDE.md "Worktrees" section went with them, since it re-injected EnterWorktree into every /fix run. Emil's call, verbatim: an end-to-end skill that takes an issue, runs the steps, and commits when he says it works; nothing else. This reverses the /orchestrate post-mortem entry above, which assumed worktree isolation would stay. Parallel /fix sessions now share whatever branch their terminal is on, which is the cost accepted for the leaner skill; reply drafts move to a separate /tone-of-voice invocation.
[2026-07-28] Issue #1258 (supplier invoice line description): chose Option A, keep the fixed invoice-level verifikat text and fix only the preview, over Option B, propagating each item's typed description to its journal line. Deciding fact: the customer-invoice side already writes invoice-level descriptions too ("Forsaljning faktura {tag}", "Kreditfaktura {tag}" in lib/bookkeeping/invoice-entries.ts), with the only per-line variation being a voucher cross-reference suffix. Invoice-level text is therefore a system-wide convention, not a supplier-side oversight, so B would have created an inconsistency between the two invoice sides rather than removing one, while landing in the journal engine and needing an aggregation-collision policy for items that share an (account, dimensions) bucket. Accepted cost, stated plainly: the per-item description the user types stays UI-only and never reaches the books. Revisit if users ask for per-line ledger detail; the collision policy is the only hard part.
[2026-07-28] Preview honesty over prettier labels in the supplier-invoice voucher preview: the BESKRIVNING column now renders the exact line_description the engine will post, and the hardcoded ACCOUNT_LABELS map (11 accounts) was removed. That map made the column silently mix "friendly account label" (for its 11 entries) with "raw account number" (every expense account, the reported bug), and neither was the posted text. Account identity was not lost: AccountNumber already shows the BAS name on its hover card. The ankomstnummer suffix the engine appends is absent from the preview because it is assigned on save and does not exist yet at preview time.
[2026-07-28] The "senaste bokförda verifikat" line in the balans-/resultatrapport header (#1267) reads MAX(voucher_number) over posted entries, never voucher_sequences.last_number. The sequence counter is an allocation high-water mark that provably drifts from the books in both directions: next_voucher_number burns a number when the follow-up insert fails (the reversal path in engine.ts does exactly that), delete_last_voucher decrements blindly by one instead of resetting to the new MAX, and pre-RPC SIE imports left it behind MAX. Since the whole point of the line is avstämning, printing an allocated number would send a reconciler chasing a gap that does not exist, so the label states plainly that the number is the last posted one. Scoped to the report's own date range rather than the fiscal year, 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 entirely 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. 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, so the issue's acceptance criterion asking for sv+en strings does not apply here.
+14 -13
View File
@@ -520,19 +520,20 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
{t('correct_opening_balances')}
</Button>
)}
{entry.status === 'posted' && (
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}
disabled={!canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
{t('copy_entry')}
</Button>
)}
{/* Copy is not status-gated: it only prefills a fresh manual draft
(no voucher number, date or attachments carried over), so it is
offered on drafts too, matching the list surfaces. */}
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}
disabled={!canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
{t('copy_entry')}
</Button>
</div>
)}
</div>
+79 -2
View File
@@ -10,7 +10,11 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { maskCustomerPersonalNumber } from '@/lib/customers/mask-personal-number'
import {
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
maskCustomerPersonalNumber,
} from '@/lib/customers/mask-personal-number'
import { AttnLine } from '@/components/ui/attn-line'
import CustomerForm from '@/components/customers/CustomerForm'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import {
@@ -26,8 +30,12 @@ import {
Loader2,
ReceiptText,
Lock,
Eye,
EyeOff,
} from 'lucide-react'
import { useLocale } from 'next-intl'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { cn, formatDate } from '@/lib/utils'
import { invoiceNumberDisplay } from '@/lib/invoices/display'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
@@ -71,18 +79,57 @@ export default function CustomerDetailPage({
const { toast } = useToast()
const { canWrite } = useCanWrite()
const t = useTranslations('customer_detail')
const errorLocale = useLocale() as ErrorLocale
const [customer, setCustomer] = useState<CustomerWithRelations | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
// Full personnummer, fetched on demand and held only for this view. Cleared
// whenever the customer is refetched so it can never outlive the row it
// belongs to.
const [revealedPersonalNumber, setRevealedPersonalNumber] = useState<string | null>(null)
const [isRevealing, setIsRevealing] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
const isUnreadablePersonalNumber =
customer?.personal_number === UNDECRYPTABLE_PERSONAL_NUMBER_MASK
async function togglePersonalNumber() {
if (revealedPersonalNumber) {
setRevealedPersonalNumber(null)
return
}
setIsRevealing(true)
try {
const response = await fetch(`/api/customers/${id}/personal-number`)
const result = await response.json()
if (!response.ok) {
toast({
title: t('personal_number_reveal_failed_title'),
description: getErrorMessage(result, { context: 'customer', locale: errorLocale }),
variant: 'destructive',
})
return
}
setRevealedPersonalNumber(result.data.personal_number)
} catch {
toast({
title: t('personal_number_reveal_failed_title'),
description: t('retry'),
variant: 'destructive',
})
} finally {
setIsRevealing(false)
}
}
useEffect(() => {
fetchCustomer()
}, [id])
async function fetchCustomer() {
setIsLoading(true)
setRevealedPersonalNumber(null)
try {
const response = await fetch(`/api/customers/${id}`)
if (!response.ok) {
@@ -298,8 +345,38 @@ export default function CustomerDetailPage({
<div className="text-sm">
<span className="text-muted-foreground">{t('label_personal_number')} </span>
<span className="tabular-nums">
{maskCustomerPersonalNumber(customer.personal_number || customer.org_number)}
{revealedPersonalNumber ??
maskCustomerPersonalNumber(customer.personal_number || customer.org_number)}
</span>
{/* Viewers keep the mask: the endpoint refuses them anyway. */}
{canWrite && customer.personal_number && !isUnreadablePersonalNumber && (
<Button
type="button"
variant="ghost"
size="icon"
className="ml-1 h-10 w-10 align-middle"
onClick={togglePersonalNumber}
disabled={isRevealing}
aria-label={revealedPersonalNumber ? t('personal_number_hide') : t('personal_number_show')}
title={revealedPersonalNumber ? t('personal_number_hide') : t('personal_number_show')}
>
{isRevealing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : revealedPersonalNumber ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
{isUnreadablePersonalNumber && (
<AttnLine
className="mt-1"
action={{ label: t('personal_number_unreadable_action'), onClick: () => setIsEditOpen(true) }}
>
{t('personal_number_unreadable')}
</AttnLine>
)}
</div>
)}
{customer.vat_number && (
+27 -15
View File
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo, useCallback, Suspense } from 'react'
import dynamic from 'next/dynamic'
import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
@@ -18,7 +17,6 @@ import { EmptyCustomers, EmptyState } from '@/components/ui/empty-state'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { cn } from '@/lib/utils'
import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
@@ -64,7 +62,6 @@ function compareStrings(a: string, b: string): number {
}
function CustomersPageInner() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const [customers, setCustomers] = useState<Customer[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -73,7 +70,6 @@ function CustomersPageInner() {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('customers')
const tCommon = useTranslations('common')
const errorLocale = useLocale() as ErrorLocale
@@ -104,25 +100,38 @@ function CustomersPageInner() {
[searchParams, sortColumn, sortDir, router, pathname]
)
/**
* Read the roster through the API, not straight from Supabase.
*
* personal_number holds AES-256-GCM ciphertext (migration 20260726110000).
* A browser-side select('*') handed this page 76 to 82 hex characters and
* getIdentifier() rendered them into the nowrap identifier cell, which is
* what shredded the table layout for companies with private customers.
* GET /api/customers maps every row through maskCustomerRow, so the
* ciphertext now never leaves the server and the column shows the same
* '********-1234' the detail view does.
*
* No `company` guard: the route resolves the active company server-side, so
* the fetch no longer has to wait for CompanyContext to hydrate. The old
* guard could leave the list empty on a slow context load, because the
* effect below runs once and never retries.
*/
async function fetchCustomers() {
if (!company) return
setIsLoading(true)
const { data, error } = await supabase
.from('customers')
.select('*')
.eq('company_id', company.id)
.order('name', { ascending: true })
if (error) {
try {
const response = await fetch('/api/customers')
if (!response.ok) throw new Error('Failed to load customers')
const { data } = await response.json()
setCustomers(data || [])
} catch {
toast({
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
} else {
setCustomers(data || [])
} finally {
setIsLoading(false)
}
setIsLoading(false)
}
useEffect(() => {
@@ -167,6 +176,9 @@ function CustomersPageInner() {
c.name.toLowerCase().includes(term) ||
c.email?.toLowerCase().includes(term) ||
c.org_number?.includes(term) ||
// The masked form, so this matches the last four digits. Against the
// raw ciphertext it matched nothing, which read as "search is broken"
// for anyone looking up a private customer by personnummer.
c.personal_number?.includes(term) ||
c.city?.toLowerCase().includes(term) ||
c.notes?.toLowerCase().includes(term)
+21 -2
View File
@@ -43,6 +43,10 @@ import type {
StoredSkattekontoTransaction,
} from '@/types/skatteverket'
import { findBankSkvCounterparts } from '@/lib/skatteverket/bank-counterpart'
import {
MATCHABLE_INVOICE_STATUSES,
MATCHABLE_SUPPLIER_INVOICE_STATUSES,
} from '@/lib/invoices/matchable-statuses'
import { useCompany } from '@/contexts/CompanyContext'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -155,12 +159,27 @@ async function fetchPotentialMatches(
.filter((t) => t.potential_supplier_invoice_id)
.map((t) => t.potential_supplier_invoice_id)
// The hint columns are never revisited once written, so an invoice settled
// by a different transaction leaves a stale pointer behind. Revalidate here:
// an unmatchable candidate must not reach the row or the match dialog, which
// would otherwise compare the transaction against a 0 kr remaining balance
// and call it a partial payment.
const [invoiceResult, supplierInvoiceResult] = await Promise.all([
potentialInvoiceIds.length > 0
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
? supabase
.from('invoices')
.select('*, customer:customers(*)')
.in('id', potentialInvoiceIds)
.in('status', [...MATCHABLE_INVOICE_STATUSES])
.gt('remaining_amount', 0)
: Promise.resolve({ data: null, error: null }),
potentialSupplierInvoiceIds.length > 0
? supabase.from('supplier_invoices').select('*, supplier:suppliers(*)').in('id', potentialSupplierInvoiceIds)
? supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*)')
.in('id', potentialSupplierInvoiceIds)
.in('status', [...MATCHABLE_SUPPLIER_INVOICE_STATUSES])
.gt('remaining_amount', 0)
: Promise.resolve({ data: null, error: null }),
])
@@ -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,
})
@@ -30,6 +30,10 @@ interface BasLookupRow {
account_number: string
account_name: string | null
known: boolean
// Present since the lookup learned about the company's own chart: an account
// that is in_chart but not is_active is being reactivated, not added.
in_chart?: boolean
is_active?: boolean
}
export function ActivateAccountsDialog({
@@ -56,7 +60,15 @@ export function ActivateAccountsDialog({
})
.catch(() => {
if (cancelled) return
setRows(accountNumbers.map((n) => ({ account_number: n, account_name: null, known: false })))
setRows(
accountNumbers.map((n) => ({
account_number: n,
account_name: null,
known: false,
in_chart: false,
is_active: false,
})),
)
})
.finally(() => {
if (!cancelled) setLoading(false)
@@ -107,6 +119,11 @@ export function ActivateAccountsDialog({
<li key={r.account_number} className="flex items-baseline gap-3 px-3 py-2">
<span className="font-mono text-foreground w-14 shrink-0">{r.account_number}</span>
<span className="truncate">{r.account_name}</span>
{r.in_chart && !r.is_active && (
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
Aktiveras igen
</span>
)}
</li>
))}
</ul>
+81 -3
View File
@@ -20,10 +20,18 @@ import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import type { BASAccount } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* The create path hands back the full row the API inserted. The reactivate
* path only learns the account number back from /accounts/activate, and the
* stored account is deliberately left untouched, so the rest is unknown here.
* Every host refetches its own list and reads only account_number.
*/
type CreatedAccount = Partial<BASAccount> & { account_number: string }
interface AddAccountDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (account: BASAccount) => void
onCreated: (account: CreatedAccount) => void
initialAccountNumber?: string
initialAccountName?: string
}
@@ -45,6 +53,10 @@ export function AddAccountDialog({
const [normalBalance, setNormalBalance] = useState<'debit' | 'credit'>('debit')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
// Set when the create failed because the number belongs to a deactivated
// account. Creating it can never succeed (the unique constraint counts
// inactive rows), so the dialog offers reactivation instead of a dead end.
const [inactiveConflict, setInactiveConflict] = useState(false)
// Apply prefill values whenever the dialog opens. Resetting on close happens
// implicitly after a successful create; here we only need to seed inputs so
@@ -55,6 +67,7 @@ export function AddAccountDialog({
setAccountNumber(num)
setAccountName(initialAccountName ?? '')
setError('')
setInactiveConflict(false)
if (num.length === 4) {
setNormalBalance(classifyAccount(num).normal_balance)
}
@@ -65,6 +78,7 @@ export function AddAccountDialog({
async function handleCreate() {
setError('')
setInactiveConflict(false)
if (!/^\d{4}$/.test(accountNumber)) {
setError('Kontonumret måste vara exakt 4 siffror')
@@ -100,6 +114,8 @@ export function AddAccountDialog({
// route's own Swedish reason. Passing the parsed body plus the status
// resolves all three shapes (envelope, bare string, no body).
const body = await response.json().catch(() => null)
const code = (body as { error?: { code?: string } } | null)?.error?.code
setInactiveConflict(code === 'ACCOUNT_EXISTS_INACTIVE')
setError(getUserErrorMessage(body, { statusCode: response.status }))
return
}
@@ -121,6 +137,41 @@ export function AddAccountDialog({
}
}
// Recovery for ACCOUNT_EXISTS_INACTIVE: flip the existing account back on
// instead of trying to insert a second row. The values typed into this form
// are intentionally dropped — the account comes back exactly as it was, and
// renaming it is the kontoplan's job, not a side effect of a failed create.
async function handleReactivate() {
setError('')
setIsSaving(true)
try {
const response = await fetch('/api/bookkeeping/accounts/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_numbers: [accountNumber] }),
})
if (!response.ok) {
const body = await response.json().catch(() => null)
setError(getUserErrorMessage(body, { statusCode: response.status }))
return
}
setInactiveConflict(false)
setAccountNumber('')
setAccountName('')
setDescription('')
setDefaultVatRate('none')
setSruCode('')
onCreated({ account_number: accountNumber })
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? getUserErrorMessage(err) : 'Något gick fel')
} finally {
setIsSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@@ -149,6 +200,10 @@ export function AddAccountDialog({
onChange={(e) => {
const v = e.target.value.replace(/\D/g, '').slice(0, 4)
setAccountNumber(v)
// The conflict is about a specific number; editing it makes
// the reactivate offer stale.
setInactiveConflict(false)
setError('')
if (v.length === 4) {
setNormalBalance(classifyAccount(v).normal_balance)
}
@@ -220,6 +275,12 @@ export function AddAccountDialog({
<SelectItem value="0.06">6 %</SelectItem>
</SelectContent>
</Select>
{accountNumber.length === 4 && accountNumber.startsWith('3') && (
<p className="text-xs text-muted-foreground">
intäktskonton avgör satsen också om kontot räknas som
momspliktig försäljning i ruta 05 i momsdeklarationen.
</p>
)}
</div>
<div className="space-y-2">
<Label>SRU-kod <span className="text-muted-foreground">(valfritt)</span></Label>
@@ -231,16 +292,33 @@ export function AddAccountDialog({
</div>
</div>
{error && (
{error && !inactiveConflict && (
<p className="text-sm text-destructive">{error}</p>
)}
{inactiveConflict && (
<div className="space-y-3 rounded-lg border border-border p-3">
<p className="text-sm text-foreground">{error}</p>
<Button
type="button"
onClick={() => void handleReactivate()}
disabled={isSaving}
>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Aktivera kontot istället
</Button>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button onClick={handleCreate} disabled={isSaving || accountNumber.length !== 4 || !accountName.trim()}>
<Button
onClick={handleCreate}
disabled={isSaving || inactiveConflict || accountNumber.length !== 4 || !accountName.trim()}
>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Skapa konto
</Button>
@@ -1,6 +1,6 @@
'use client'
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'
import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -74,6 +74,10 @@ export default function ChartOfAccountsManager() {
const [collapsedMyClasses, setCollapsedMyClasses] = useState<Set<number>>(new Set())
const [expandedCatalogClasses, setExpandedCatalogClasses] = useState<Set<number>>(new Set())
const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(null)
// Off by default: the list endpoint's `?active=false` means "no filter", so
// leaving it off keeps first paint on the smaller active-only payload.
// A deactivated account is otherwise invisible everywhere and unrecoverable.
const [showInactive, setShowInactive] = useState(false)
// Data state
const [accounts, setAccounts] = useState<BASAccount[]>([])
@@ -99,8 +103,19 @@ export default function ChartOfAccountsManager() {
// Data fetching
// -------------------------------------------
// Read through a ref, not the state value: making fetchAccounts depend on
// showInactive would put it in the mount effect's dep list and re-run the
// whole blocking load on every toggle (the same double-fetch the comment
// below warns about for hideK2Excluded).
const showInactiveRef = useRef(showInactive)
showInactiveRef.current = showInactive
const fetchAccounts = useCallback(async () => {
const res = await fetch('/api/bookkeeping/accounts')
// `?active=false` disables the filter entirely (active + inactive), it does
// not select inactive rows only — see list_company_accounts.
const res = await fetch(
showInactiveRef.current ? '/api/bookkeeping/accounts?active=false' : '/api/bookkeeping/accounts',
)
const { data } = await res.json()
setAccounts(data || [])
}, [])
@@ -167,6 +182,18 @@ export default function ChartOfAccountsManager() {
}
}, [fetchAccounts, fetchUsage])
// Refetch when the inactive filter flips. Skips the first run (the mount
// effect above already fetched) and deliberately does not raise `loading`:
// swapping a filter should not blank the table back to the skeleton.
const showInactiveInitial = useRef(true)
useEffect(() => {
if (showInactiveInitial.current) {
showInactiveInitial.current = false
return
}
void fetchAccounts()
}, [showInactive, fetchAccounts])
// Loads the BAS catalog and the K2 default on demand, once, the first time
// the user opens the "BAS-katalog" tab.
const ensureReferenceLoaded = useCallback(async () => {
@@ -211,6 +238,22 @@ export default function ChartOfAccountsManager() {
// -------------------------------------------
async function toggleActive(account: BASAccount) {
// Deactivating an account that carries postings hides it from the
// kontoplan and from new verifikat while its balances stay in the books.
// That is legal and reversible, but it should never happen silently: it is
// what made a used account unreachable in the first place. The count is
// already in memory from fetchUsage, so no extra request.
const usage = usageCounts.get(account.account_number) ?? 0
if (account.is_active && usage > 0) {
const confirmed = await confirm({
title: t('deactivate_confirm_title', { number: account.account_number }),
description: t('deactivate_confirm', { count: usage }),
confirmLabel: t('deactivate_confirm_action'),
variant: 'warning',
})
if (!confirmed) return
}
setTogglingAccount(account.account_number)
try {
const res = await fetch(`/api/bookkeeping/accounts/${account.account_number}`, {
@@ -273,9 +316,13 @@ export default function ChartOfAccountsManager() {
body: JSON.stringify({ account_numbers: [accountNumber] }),
})
if (!res.ok) throw new Error(t('toast_activate_failed'))
const { activated } = await res.json()
// `reactivated` covers an account the company already had but had turned
// off. Without it the catalog tab flipped the row back on in silence.
const { activated, reactivated } = await res.json()
if (activated > 0) {
toast({ title: t('toast_activated_title'), description: t('toast_activated_description', { number: accountNumber }) })
} else if (reactivated > 0) {
toast({ title: t('toast_reactivated_title'), description: t('toast_reactivated_description', { number: accountNumber }) })
}
await refreshAll()
} catch {
@@ -458,6 +505,16 @@ export default function ChartOfAccountsManager() {
className="h-9 pl-10"
/>
</div>
{view === 'my-accounts' && (
<label className="ml-auto flex items-center gap-2 text-sm">
<Switch
checked={showInactive}
onCheckedChange={setShowInactive}
className="scale-75"
/>
<span className="text-muted-foreground">{t('show_inactive')}</span>
</label>
)}
{view === 'bas-catalog' && (
<label className="ml-auto flex items-center gap-2 text-sm">
<Switch
@@ -516,7 +573,10 @@ export default function ChartOfAccountsManager() {
key={account.id}
className={cn(
'group transition-colors duration-150 hover:bg-secondary/35',
!account.is_active && 'opacity-50',
// Dim the text rather than the whole row: a
// row-level opacity would drag the "Inaktiv"
// chip below readable contrast with it.
!account.is_active && 'text-muted-foreground',
)}
>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
@@ -535,6 +595,14 @@ export default function ChartOfAccountsManager() {
{t('own_badge')}
</span>
)}
{/* Exception chip: only visible while the
"Visa inaktiva" filter is on, so it never
lands on every row. */}
{!account.is_active && (
<Badge variant="secondary" className="shrink-0">
{t('prune_inactive_badge')}
</Badge>
)}
</span>
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
@@ -625,7 +693,10 @@ export default function ChartOfAccountsManager() {
.map(([cls, classAccounts]) => {
const classNum = Number(cls)
const open = expandedCatalogClasses.has(classNum) || !!searchQuery
const activatedCount = classAccounts.filter((a) => a.is_activated).length
// Row existence alone is not activation: a deactivated
// account is in the chart but not usable, so it must not
// count as active here either.
const activatedCount = classAccounts.filter((a) => a.is_activated && a.is_active).length
return (
<Fragment key={cls}>
{bandRow(
@@ -657,7 +728,11 @@ export default function ChartOfAccountsManager() {
{typeLabel(account.account_type)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
{account.is_activated ? (
{/* An account the company holds but has
deactivated is NOT activated: it falls
through to the button, relabelled so the
user sees it is coming back, not new. */}
{account.is_activated && account.is_active ? (
<span className="inline-flex items-center gap-1 text-xs text-success">
<CheckCircle2 className="h-3.5 w-3.5" />
{t('activated')}
@@ -675,7 +750,7 @@ export default function ChartOfAccountsManager() {
) : (
<Plus className="mr-1 h-3 w-3" />
)}
{t('add')}
{account.is_activated ? t('reactivate') : t('add')}
</Button>
)}
</td>
@@ -328,6 +328,12 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
<SelectItem value="0.06">6 %</SelectItem>
</SelectContent>
</Select>
{account.account_class === 3 && (
<p className="text-xs text-muted-foreground">
intäktskonton avgör satsen också om kontot räknas som
momspliktig försäljning i ruta 05 i momsdeklarationen.
</p>
)}
</div>
<div className="space-y-2">
<Label>SRU-kod</Label>
+5 -1
View File
@@ -786,7 +786,11 @@ export default function JournalEntryForm({
// After a new account is created, refresh the chart, auto-select it on the
// line that initiated the create, and close the dialog. All other form
// state is preserved: we never navigate away from the form.
const handleAccountCreated = async (account: BASAccount) => {
//
// Only the number is required: the dialog also reaches here after
// reactivating an existing account, where the rest of the row is whatever
// the company already had stored and is picked up by fetchAccounts.
const handleAccountCreated = async (account: { account_number: string }) => {
await fetchAccounts()
if (creatingAccountForLine != null) {
updateLine(creatingAccountForLine, 'account_number', account.account_number)
+29 -4
View File
@@ -38,7 +38,7 @@ import {
QUIET_LINK_CLASS,
RowFoldout,
} from '@/components/ui/dry-table'
import { ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Paperclip, CircleSlash, Loader2, BookOpen, X, Lock, Search, SlidersHorizontal, RotateCcw } from 'lucide-react'
import { ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Copy, Paperclip, CircleSlash, Loader2, BookOpen, X, Lock, Search, SlidersHorizontal, RotateCcw } from 'lucide-react'
import { cn, formatDate, formatCurrency } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { resolveCurrentPeriodId } from '@/lib/bookkeeping/suggest-fiscal-period'
@@ -1196,6 +1196,29 @@ export default function JournalEntryList() {
{t('post')}
</Button>
)}
{canWrite && (
<button
type="button"
aria-label={t('copy_voucher_tooltip')}
title={t('copy_voucher_tooltip')}
onClick={(e) => {
e.stopPropagation()
router.push(`/bookkeeping?copy_from=${entry.id}`)
}}
className={cn(
// p-2 grows the tap target to 30px without
// changing row height (the row is ~40px from
// the description cell).
'inline-flex items-center rounded p-2 text-muted-foreground transition-opacity duration-150 hover:text-foreground',
// Quiet at rest on desktop, but the table has no
// mobile card to fall back on, so touch keeps the
// icon visible.
'opacity-100 md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100',
)}
>
<Copy className="h-3.5 w-3.5" />
</button>
)}
<ChevronRight
className={cn(
'h-3.5 w-3.5 text-muted-foreground transition-all duration-200',
@@ -1330,9 +1353,11 @@ export default function JournalEntryList() {
{t('reverse_action')}
</button>
)}
<button type="button" className={QUIET_LINK_CLASS} onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}>
{t('copy')}
</button>
{canWrite && (
<button type="button" className={QUIET_LINK_CLASS} onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}>
{t('copy')}
</button>
)}
</div>
</div>
</RowFoldout>
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
const SRC = fs.readFileSync(
path.resolve(__dirname, '../JournalEntryList.tsx'),
'utf8',
)
/**
* Regression pin for the per-row copy affordance (#1266).
*
* The icon was removed as collateral of the row-language rewrite in #1123: its
* slot was reused for the expand toggle, and the only trace left was an
* orphaned `copy_voucher_tooltip` key in both message files. The repo does not
* render components in tests, so nothing observed the loss; pin the source
* shape instead, the same way NewInvoiceDialog's copy query is pinned.
*/
describe('JournalEntryList row copy affordance', () => {
it('keeps the Copy icon imported from lucide', () => {
const importLine = SRC.split('\n').find(
(l) => l.includes("from 'lucide-react'") && l.startsWith('import'),
)
expect(importLine).toBeDefined()
expect(importLine).toContain('Copy')
})
it('references copy_voucher_tooltip, so the i18n key is not orphaned', () => {
expect(SRC).toContain("t('copy_voucher_tooltip')")
})
it('labels the row icon for screen readers', () => {
expect(SRC).toContain("aria-label={t('copy_voucher_tooltip')}")
})
it('stops propagation before navigating, so copying never toggles the foldout', () => {
// The whole <tr> is the expand toggle, so a copy click that bubbles would
// open the row instead of (or as well as) starting the copy.
const start = SRC.indexOf("aria-label={t('copy_voucher_tooltip')}")
expect(start).toBeGreaterThan(-1)
const push = SRC.indexOf('/bookkeeping?copy_from=${entry.id}', start)
expect(push).toBeGreaterThan(-1)
const handler = SRC.slice(start, push)
expect(handler).toContain('e.stopPropagation()')
})
})
+24 -4
View File
@@ -10,10 +10,16 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { AttnLine } from '@/components/ui/attn-line'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, CheckCircle, XCircle, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import {
PERSONAL_NUMBER_INPUT_RE,
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
isMaskedPersonalNumber,
} from '@/lib/customers/mask-personal-number'
import type { CreateCustomerInput } from '@/types'
interface CustomerFormProps {
@@ -49,9 +55,14 @@ export default function CustomerForm({
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
// Accepts a plaintext personnummer or either mask the API returns. The
// '********-????' placeholder has to pass: it is what a row whose stored
// value cannot be decrypted renders as, and rejecting it here blocked the
// whole edit dialog, so the customer's name and address became unsavable
// over a field the user could not fix.
personal_number: z
.string()
.regex(/^(?:(\d{6}|\d{8})[-+]?\d{4}|\*{8}-\d{4})$/, t('personal_number_invalid'))
.regex(PERSONAL_NUMBER_INPUT_RE, t('personal_number_invalid'))
.optional()
.or(z.literal('')),
language: z.enum(['sv', 'en']).optional(),
@@ -90,6 +101,10 @@ export default function CustomerForm({
const customerType = watch('customer_type')
const vatNumber = watch('vat_number')
// The stored value could not be decrypted. The field is editable (typing a
// fresh personnummer replaces it); say so, because the placeholder on its own
// reads like a rendering fault.
const personalNumberUnreadable = watch('personal_number') === UNDECRYPTABLE_PERSONAL_NUMBER_MASK
const handleValidateVat = async () => {
if (!vatNumber) return
@@ -151,7 +166,10 @@ export default function CustomerForm({
email: data.email || undefined,
personal_number: data.personal_number || null,
}
if (data.personal_number?.startsWith('*') && data.personal_number === initialData?.personal_number) {
// A mask means "unchanged", whichever form it is. Sending it would be
// harmless (the route ignores masks too) but omitting it keeps the intent
// legible in the request body.
if (isMaskedPersonalNumber(data.personal_number)) {
delete payload.personal_number
}
onSubmit(payload)
@@ -287,9 +305,11 @@ export default function CustomerForm({
placeholder={t('personal_number_placeholder')}
{...register('personal_number')}
/>
{errors.personal_number && (
{errors.personal_number ? (
<p className="text-sm text-destructive">{errors.personal_number.message}</p>
)}
) : personalNumberUnreadable ? (
<AttnLine>{t('personal_number_unreadable')}</AttnLine>
) : null}
</div>
</div>
) : (
+11
View File
@@ -18,6 +18,7 @@ import { FyPicker } from '@/components/common/FyPicker'
import { ContextPicker } from '@/components/common/ContextPicker'
import { cn, formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { formatLatestVouchers, LATEST_VOUCHERS_LABEL } from '@/lib/reports/latest-vouchers-format'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { AccountNumber } from '@/components/ui/account-number'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
@@ -772,6 +773,11 @@ export function ResultatrapportView({ periodId, dateRange, dimensionFilter = nul
{ format: 'xlsx', href: `/api/reports/resultatrapport/xlsx?${reportQs}` },
]}
/>
{formatLatestVouchers(data.latest_vouchers) && (
<p className="text-sm text-muted-foreground">
{LATEST_VOUCHERS_LABEL}: {formatLatestVouchers(data.latest_vouchers)}
</p>
)}
<Card>
<CardContent className="p-0">
@@ -917,6 +923,11 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }:
{ format: 'xlsx', href: `/api/reports/balansrapport/xlsx?${reportQs}` },
]}
/>
{formatLatestVouchers(data.latest_vouchers) && (
<p className="text-sm text-muted-foreground">
{LATEST_VOUCHERS_LABEL}: {formatLatestVouchers(data.latest_vouchers)}
</p>
)}
<Card>
<CardContent className="p-0">
@@ -10,7 +10,9 @@ import {
resolveReverseChargeRate,
isReverseChargeBasisAccount,
generateReverseChargeBasisLines,
generateReverseChargeLines,
} from '@/lib/bookkeeping/vat-entries'
import { buildSupplierDescription } from '@/lib/bookkeeping/supplier-invoice-description'
import { resolveBookingAccount, itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import type { Supplier } from '@/types'
@@ -62,12 +64,6 @@ interface JournalPreviewLine {
credit: number
}
function getOutputVatAccount(rate: number): string {
if (rate === 0.12) return '2624'
if (rate === 0.06) return '2634'
return '2614'
}
function buildJournalPreview(
items: ReviewLineItem[],
subtotal: number,
@@ -80,6 +76,12 @@ function buildJournalPreview(
// resolveSekAmount(item.line_total, null, currency, exchange_rate), so the
// saved verifikation is always in SEK, never in invoice currency.
fxRate: number,
// The verifikat description the engine will write to EVERY line of this
// entry (createSupplierInvoiceRegistrationEntry builds one `desc` and reuses
// it). The preview showed the bare account number here instead, so a user
// reviewing "Verifikation som bokförs" saw "5615" where the books would say
// "Leverantörsfaktura 123, ACME AB".
desc: string,
): JournalPreviewLine[] {
const lines: JournalPreviewLine[] = []
const toSek = (n: number) => Math.round(n * fxRate * 100) / 100
@@ -99,7 +101,7 @@ function buildJournalPreview(
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
account_number: accountNumber,
description: accountNumber,
description: desc,
debit: amount,
credit: 0,
})
@@ -122,7 +124,6 @@ function buildJournalPreview(
// prohibited (Skatteverket felkod FK004). Driving off the resolved rate (not
// item.vat_rate) is what makes a 0%-rate RC line book its VAT at all.
const isDomesticRC = supplierType === 'swedish_business'
const inputAccount = isDomesticRC ? '2647' : '2645'
const rcSupplierType: 'eu_business' | 'non_eu_business' | 'swedish_business' =
supplierType === 'non_eu_business' || supplierType === 'swedish_business'
? supplierType
@@ -145,20 +146,17 @@ function buildJournalPreview(
for (const [rate, netAmount] of baseByRate) {
if (netAmount <= 0) continue
const fiktivVat = Math.round(netAmount * rate * 100) / 100
const outputAccount = getOutputVatAccount(rate)
lines.push({
account_number: inputAccount,
description: inputAccount,
debit: fiktivVat,
credit: 0,
})
lines.push({
account_number: outputAccount,
description: outputAccount,
debit: 0,
credit: fiktivVat,
})
// Same generator the engine calls, so the account pair AND the
// "Fiktiv in-/utgående moms" wording come from one place instead of
// being re-derived here (they used to render as bare account numbers).
for (const rcLine of generateReverseChargeLines(netAmount, rate, isDomesticRC)) {
lines.push({
account_number: rcLine.account_number,
description: rcLine.line_description ?? rcLine.account_number,
debit: rcLine.debit_amount,
credit: rcLine.credit_amount,
})
}
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
for (const bl of generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)) {
@@ -175,7 +173,7 @@ function buildJournalPreview(
// Credit: 2440 at subtotal (no real VAT for reverse charge)
lines.push({
account_number: '2440',
description: 'Leverantörsskulder',
description: desc,
debit: 0,
credit: toSek(subtotal),
})
@@ -190,10 +188,10 @@ function buildJournalPreview(
vatByRate.set(item.vat_rate, (vatByRate.get(item.vat_rate) || 0) + v)
}
}
for (const [, vat] of vatByRate) {
for (const [rate, vat] of vatByRate) {
lines.push({
account_number: '2641',
description: 'Ingående moms',
description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
debit: toSek(vat),
credit: 0,
})
@@ -202,7 +200,7 @@ function buildJournalPreview(
// Credit: 2440 at total incl. VAT
lines.push({
account_number: '2440',
description: 'Leverantörsskulder',
description: desc,
debit: 0,
credit: toSek(total),
})
@@ -229,25 +227,35 @@ export function SupplierInvoiceReviewContent({
const t = useTranslations('supplier_invoice_editor')
const parsedRate = exchangeRate ? parseFloat(exchangeRate) : NaN
const fxRate = currency !== 'SEK' && Number.isFinite(parsedRate) && parsedRate > 0 ? parsedRate : 1
const journalLines = buildJournalPreview(items, subtotal, totalVat, total, reverseCharge, supplier.supplier_type, fxRate)
// The description the engine will stamp on every line of this verifikat.
// The ankomstnummer suffix the backend appends is deliberately absent: it is
// assigned on save, so it does not exist yet at preview time. Everything
// before it is byte-identical to what gets posted.
const voucherDescription = buildSupplierDescription(
'Leverantörsfaktura',
invoiceNumber,
supplier.name,
)
const journalLines = buildJournalPreview(
items,
subtotal,
totalVat,
total,
reverseCharge,
supplier.supplier_type,
fxRate,
voucherDescription,
)
const totalDebit = journalLines.reduce((sum, l) => sum + l.debit, 0)
const totalCredit = journalLines.reduce((sum, l) => sum + l.credit, 0)
const showingSek = fxRate !== 1
const ACCOUNT_LABELS: Record<string, string> = {
'2440': t('account_2440'),
'2641': t('account_2641'),
'2645': t('account_2645'),
'2647': t('account_2647'),
'2614': t('account_2614'),
'2624': t('account_2624'),
'2634': t('account_2634'),
'1710': t('account_1710'),
'1720': t('account_1720'),
'1730': t('account_1730'),
'1740': t('account_1740'),
'1790': t('account_1790'),
}
// No account-label lookup any more: the BESKRIVNING column shows the
// line_description that will actually be posted. A hardcoded label map
// covering 11 accounts meant the column silently mixed "account label" (for
// those) with "raw account number" (for every expense account), and neither
// was the posted text. The account's own name stays available on the
// AccountNumber hover card.
return (
<div className="space-y-4">
@@ -424,7 +432,7 @@ export function SupplierInvoiceReviewContent({
<AccountNumber number={line.account_number} size="sm" />
</td>
<td className="py-1 text-xs">
{ACCOUNT_LABELS[line.account_number] || line.description}
{line.description}
</td>
<td className="py-1 text-right tabular-nums">
{line.debit > 0 ? formatAmount(line.debit) : ''}
@@ -451,7 +459,7 @@ export function SupplierInvoiceReviewContent({
<div className="flex items-center gap-1.5">
<AccountNumber number={line.account_number} size="sm" />
<span className="text-xs text-muted-foreground truncate">
{ACCOUNT_LABELS[line.account_number] || line.description}
{line.description}
</span>
</div>
</div>
+47 -5
View File
@@ -9,6 +9,10 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { isInvoiceBookingRateMissing, previewedFxGainSek } from './invoice-match-fx'
import {
isMatchableInvoice,
isMatchableSupplierInvoice,
} from '@/lib/invoices/matchable-statuses'
import { CheckCircle2, AlertTriangle, Trash2, Plus, Pencil } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
import type { BASAccount } from '@/types'
@@ -143,6 +147,21 @@ export default function InvoiceMatchDialog({
const isCustomerInvoice = !!transaction?.potential_invoice
const transactionId = transaction?.id ?? null
// The suggestion pointer is written once at import time and never revisited,
// so the invoice it names may since have been settled by a DIFFERENT
// transaction. The read paths filter those out, but the row in hand can
// still be stale (fetched before the other match, or settled in another
// tab), so re-check here rather than trust the pointer.
//
// This is not an advisory guard: both match routes reject a settled target
// outright (MATCH_INVOICE_ALREADY_PAID / MATCH_SI_ALREADY_PAID), so there is
// no "match anyway" that could succeed. Say so and block, instead of
// computing a diff against a 0 kr remaining balance and calling the result
// a partial payment.
const targetSettled =
(isSupplierInvoice && !isMatchableSupplierInvoice(transaction!.potential_supplier_invoice)) ||
(isCustomerInvoice && !isMatchableInvoice(transaction!.potential_invoice))
const [candidate, setCandidate] = useState<DuplicateCandidate | null>(null)
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false)
@@ -447,10 +466,13 @@ export default function InvoiceMatchDialog({
</div>
</div>
{/* Invoice details. Shows remaining_amount (what the customer
still owes) rather than the original total, so a partially-
paid invoice displays the actual figure the user is matching
against. Mirrors the supplier-invoice block below. */}
{/* Invoice details. Both branches show remaining_amount (what is
still owed) rather than the original total, so a partially-paid
invoice displays the actual figure the user is matching against
and the card can never contradict the amount comparison below.
The supplier branch used to render .total while the comparison
measured against remaining_amount: on a partially-paid invoice
that put "1 250 kr" on screen next to "Differens: 1 250 kr". */}
{isCustomerInvoice && (
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">{t('invoice_label')}</p>
@@ -489,7 +511,8 @@ export default function InvoiceMatchDialog({
</span>
<span className="font-medium">
{formatCurrency(
transaction.potential_supplier_invoice!.total,
transaction.potential_supplier_invoice!.remaining_amount ??
transaction.potential_supplier_invoice!.total,
transaction.potential_supplier_invoice!.currency,
)}
</span>
@@ -505,6 +528,22 @@ export default function InvoiceMatchDialog({
The customer branch previously fell back to .total; both
branches now mirror the supplier branch's correct logic. */}
{(() => {
// Settled target: the amount comparison below would be
// meaningless (it measures against a 0 kr remaining balance and
// reports the whole transaction as a "differens"), and no
// outcome it describes is reachable. Replace it outright.
if (targetSettled) {
return (
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning/10 text-warning-foreground">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">{t('target_settled_title')}</p>
<p>{t('target_settled_description')}</p>
</div>
</div>
)
}
const txAbs = Math.abs(transaction.amount)
const invRemaining = isSupplierInvoice
? transaction.potential_supplier_invoice!.remaining_amount ?? transaction.potential_supplier_invoice!.total
@@ -910,6 +949,9 @@ export default function InvoiceMatchDialog({
disabled={
isConfirming ||
isCheckingDuplicate ||
// Settled target: the route rejects this unconditionally, so the
// button has no reachable success path.
targetSettled ||
(isEditing && !editValidation.isValid) ||
// Block confirm when cross-currency lookup failed and the user
// hasn't typed a manual rate yet. Same-currency and auto-rate
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { mapCustomer } from '../entity-mapper'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
import type { CustomerDto, PartyDto } from '@/lib/providers/dto'
/**
@@ -8,7 +9,8 @@ import type { CustomerDto, PartyDto } from '@/lib/providers/dto'
* not be misfiled as a foreign org number non_eu_business (the Johan
* Ekengren 19700616-7113 bug);
* - an individual's number must land in personal_number (not org_number), or
* the individual customer form (which renders personal_number) hides it.
* the individual customer form (which renders personal_number) hides it,
* and it must be ENCRYPTED on the way in: the column takes ciphertext only.
*/
function makeCustomer(over: {
@@ -42,11 +44,24 @@ describe('mapCustomer: type inference & identity-number routing', () => {
expect(row.personal_number).toBeNull()
})
it('provider type=private → individual, personnummer routed to personal_number', () => {
it('provider type=private → individual, personnummer encrypted into personal_number', () => {
// personal_number is an encrypted column. customers_personal_number_check
// (migration 20260726110000) accepts AES-256-GCM hex and nothing else, so
// the plaintext this used to assert would abort the whole import with
// 23514 the moment a Privatperson appeared in the source data.
const row = mapCustomer(makeCustomer({ type: 'private', number: '930722-3207' }), 'u', 'c')
expect(row.customer_type).toBe('individual')
expect(row.personal_number).toBe('930722-3207')
expect(row.org_number).toBeNull()
const stored = row.personal_number as string
expect(stored).not.toBe('930722-3207')
expect(stored).toMatch(/^[0-9a-f]{76,255}$/)
expect(decryptPersonnummer(stored)).toBe('930722-3207')
})
it('leaves personal_number null for a business, with nothing to encrypt', () => {
const row = mapCustomer(makeCustomer({ type: 'company', number: '556055-1234' }), 'u', 'c')
expect(row.personal_number).toBeNull()
})
it('10-digit personnummer (provider type=company) still → swedish_business (unchanged)', () => {
@@ -7,6 +7,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { encryptCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
import type { Currency, CustomerType, ExchangeRate, SupplierType, VatTreatment } from '@/types'
import type {
CustomerDto,
@@ -429,6 +430,10 @@ export function mapCustomer(dto: CustomerDto, userId: string, companyId: string)
// Privatperson's personnummer lands in org_number and is hidden by the
// individual customer form, which renders personal_number for individuals.
const isIndividual = customerType === 'individual'
// personal_number is an encrypted column: customers_personal_number_check
// (migration 20260726110000) accepts AES-256-GCM hex and nothing else, so
// writing the identity number in plaintext here aborts the whole import with
// 23514 the moment a Privatperson appears in the source data.
return {
user_id: userId,
company_id: companyId,
@@ -438,7 +443,7 @@ export function mapCustomer(dto: CustomerDto, userId: string, companyId: string)
phone: dto.party.contact?.telephone || null,
...addr,
org_number: isIndividual ? null : number,
personal_number: isIndividual ? number : null,
personal_number: isIndividual ? encryptCustomerPersonalNumber(number) : null,
vat_number: dto.vatNumber || null,
vat_number_validated: false,
default_payment_terms: dto.defaultPaymentTermsDays || 30,
@@ -14,8 +14,17 @@ const { mockUpsertFromPsd2, mockAllocate, mockGetRevokedConnectionIds } = vi.hoi
}))
vi.mock('@/lib/cash-accounts/service', () => ({
upsertFromPsd2: (...args: unknown[]) => mockUpsertFromPsd2(...args),
allocatePsd2LedgerAccount: (...args: unknown[]) => mockAllocate(...args),
// See the callback route suite: mockAllocate stays the allocation stand-in
// and the wrapper wraps it in the resolver's envelope.
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' }
},
getRevokedConnectionIds: (...args: unknown[]) => mockGetRevokedConnectionIds(...args),
normalizeIban: (iban: string | null | undefined) =>
iban ? iban.replace(/\s+/g, '').toUpperCase() || null : null,
}))
import { enableBankingExtension } from '../index'
@@ -484,6 +484,18 @@ export function AccountPickerDialog({
</DialogDescription>
</DialogHeader>
{/* Which company's books this lands in. The connection is bound to the
company that was active when it was authorized, and the account
list below is that company's chart: without naming it here, a bank
authorized while the wrong company was active looks identical to
the right one. */}
{company?.name && (
<p className="text-[12.5px] leading-relaxed text-muted-foreground">
Kontona bokförs i <span className="font-medium text-foreground">{company.name}</span>
{' '}och bokföringskontona nedan kommer ur det bolagets kontoplan.
</p>
)}
{isInitialSelection && (
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-4 text-sm">
<div>
@@ -497,12 +509,18 @@ export function AccountPickerDialog({
{bookedCoverage && (
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-background/60 p-3">
{/* Stated as a fact with an opt-in shortcut, not as "vi
föreslår": the selected default below is the fiscal-year
start, and a recommendation that contradicts the selected
option reads as a broken prefill. Mid-year is the normal
place for the last verifikat to sit, so this line must not
push the user off a full-year backfill. */}
<p className="text-xs text-muted-foreground">
Ditt senaste bokförda verifikat är daterat{' '}
Din bokföring är bokförd till och med{' '}
<span className="font-medium tabular-nums text-foreground">{bookedCoverage.lastBookedDate}</span>.
Vi föreslår{' '}
Vill du hoppa över det som redan är bokfört kan du börja från{' '}
<span className="font-medium tabular-nums text-foreground">{bookedCoverage.suggestedStartDate}</span>{' '}
som startdatum inget överlappar din bokföring.
i stället.
</p>
<button
type="button"
@@ -84,6 +84,19 @@ export function BankConnectionStatus({
const isConnectionError = connection.status === 'error'
const errorMessage = connection.error_message ?? ''
// "Aktiv" is a stored status, not a live fact: a session killed bank-side
// keeps the row at 'active' until something tries to use it. The nightly
// health probe catches most of those, but a connection that has gone quiet
// for days is worth saying out loud rather than presenting old balances as
// current. The cron runs daily, so 3 days is several missed runs.
const STALE_SYNC_DAYS = 3
const daysSinceSync = connection.last_synced_at
? Math.floor((now - new Date(connection.last_synced_at).getTime()) / (1000 * 60 * 60 * 24))
: null
const isStale =
connection.status === 'active' && daysSinceSync !== null && daysSinceSync >= STALE_SYNC_DAYS
const neverSynced = connection.status === 'active' && !connection.last_synced_at
return (
<div className="border-b border-border px-1 py-3">
{/* Main line: identity + state left, quiet actions right */}
@@ -206,6 +219,20 @@ export function BankConnectionStatus({
</>
)}
{/* Gone quiet: the row still says Aktiv, but nothing has confirmed the
session is alive for days. */}
{isStale && (
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
Ingen synkning {daysSinceSync} dagar. Saldon och transaktioner kan vara inaktuella:
kör Synka för att kontrollera att anslutningen fortfarande fungerar.
</p>
)}
{neverSynced && (
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
Anslutningen har aldrig synkat. Kör Synka för att hämta transaktioner.
</p>
)}
{/* Consent expiry warning (for active connections) */}
{!isConnectionExpired && isExpiring && daysUntilExpiry !== null && (
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useMemo, useRef } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
@@ -25,7 +25,9 @@ import type { StoredAccount } from '../types'
*/
export default function BankingSettingsPanel() {
const { toast } = useToast()
const supabase = createClient()
// Stable across renders so effects can list it as a dependency without
// re-firing on every parent render (same reason as AccountPickerDialog).
const supabase = useMemo(() => createClient(), [])
// The OAuth callback lands here with ?select_accounts=<id> once a bank is
// successfully connected. Read via useSearchParams (SSR/hydration-safe) so
// the first-load spinner can say "bank connected, fetching accounts"
@@ -35,7 +37,7 @@ export default function BankingSettingsPanel() {
const arrivedFromBankCallback = !!searchParams?.get('select_accounts')
const { dialogProps, confirm } = useDestructiveConfirm()
const { company } = useCompany()
const { company, companies } = useCompany()
const hasBankSync = useCapability(CAPABILITY.bank_sync)
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
@@ -50,6 +52,17 @@ export default function BankingSettingsPanel() {
const [showCsvFallback, setShowCsvFallback] = useState(false)
const [psuType, setPsuType] = useState<'personal' | 'business'>('business')
const [pickerConnectionId, setPickerConnectionId] = useState<string | null>(null)
// Live connections the same user holds at the same banks in OTHER companies.
// Several ASPSPs allow only one active AIS session per PSU, so authorizing
// company B silently kills company A's connection. RLS scopes SELECT to
// user_company_ids(), so this read stays within the user's own companies.
const [otherCompanyConnections, setOtherCompanyConnections] = useState<
{ bank_name: string; company_id: string }[]
>([])
// Set when the OAuth callback pointed at a connection that belongs to a
// different company than the active one: without this the picker simply
// never opens and the connection looks like it vanished.
const [pickerCompanyMismatch, setPickerCompanyMismatch] = useState<string | null>(null)
// Must match STALE_THRESHOLD_MS in extensions/general/enable-banking/index.ts
const PENDING_LOCK_MS = 30 * 1000
@@ -103,13 +116,30 @@ export default function BankingSettingsPanel() {
const match = bankConnections.find(c => c.id === targetId)
if (match) {
setPickerConnectionId(targetId)
setPickerCompanyMismatch(null)
} else {
// The callback finished, but the connection belongs to a company that
// isn't the active one (the user switched company during the bank
// round-trip, or authorized while another company was active). Name the
// owner instead of dropping the user on a panel that looks unchanged.
void (async () => {
const { data } = await supabase
.from('bank_connections')
.select('company_id')
.eq('id', targetId)
.maybeSingle()
const ownerId = (data as { company_id?: string } | null)?.company_id
if (!ownerId) return
const owner = companies.find((c) => c.company.id === ownerId)
setPickerCompanyMismatch(owner?.company.name ?? 'ett annat bolag')
})()
}
params.delete('select_accounts')
const newQuery = params.toString()
const newUrl = `${window.location.pathname}${newQuery ? `?${newQuery}` : ''}`
window.history.replaceState({}, '', newUrl)
}, [isLoading, bankConnections])
}, [isLoading, bankConnections, companies, supabase])
function releaseConnectingLock() {
connectingRef.current = false
@@ -147,6 +177,19 @@ export default function BankingSettingsPanel() {
setBankConnections(connections || [])
// Same-bank connections in the user's other companies. Only sessions
// that actually hold a consent count: a revoked or errored row is not
// competing for the bank's one-session-per-login slot.
const { data: allConnections } = await supabase
.from('bank_connections')
.select('bank_name, company_id, status')
.in('status', ['active', 'pending_selection'])
setOtherCompanyConnections(
((allConnections || []) as { bank_name: string; company_id: string }[]).filter(
(c) => c.company_id !== company.id
)
)
// If a pending connection exists from a recent attempt (e.g. user bounced back from
// the bank's auth page), keep the connect button disabled until the server-side lock expires.
const freshPending = (connections || []).find((c) => c.status === 'pending')
@@ -169,9 +212,45 @@ export default function BankingSettingsPanel() {
}
}
/**
* Warn before authorizing a bank where the same user already holds live
* connections in other companies. Several ASPSPs bind one active AIS session
* per PSU, so the new authorization silently invalidates the existing ones,
* and nothing in the product tells the user until a sync fails days later.
* Advisory only: legitimate multi-company setups must still be able to
* proceed, so the dialog always offers a working "Fortsätt".
*/
async function confirmSameBankConnections(bankName: string): Promise<boolean> {
const clashes = otherCompanyConnections.filter((c) => c.bank_name === bankName)
if (clashes.length === 0) return true
const names = clashes
.map((c) => companies.find((entry) => entry.company.id === c.company_id)?.company.name)
.filter((name): name is string => !!name)
const companyList = names.length > 0 ? ` (${names.join(', ')})` : ''
const count = clashes.length
return confirm({
title: `Du har redan ${count} ${count === 1 ? 'anslutning' : 'anslutningar'} till ${bankName}`,
description:
`${bankName} är sedan tidigare ansluten i ${count === 1 ? 'ett annat bolag' : 'andra bolag'}${companyList}. ` +
'Vissa banker tillåter bara en aktiv anslutning per inloggning: när du slutför den här kan de andra sluta synka ' +
'och behöva förnyas. Fortsätt om du vet att din bank tillåter flera.',
confirmLabel: 'Fortsätt',
variant: 'warning',
})
}
async function handleConnectBank(bank: Bank, psuTypeOverride?: 'personal' | 'business') {
if (connectingRef.current) return
// Claim the lock BEFORE the confirm await. The dialog can sit open
// indefinitely, and a second click in that window would otherwise sail
// past the guard above and start a concurrent connect flow.
connectingRef.current = true
if (!(await confirmSameBankConnections(bank.name))) {
connectingRef.current = false
return
}
setIsConnecting(true)
setConnectingBankName(bank.name)
@@ -233,7 +312,12 @@ export default function BankingSettingsPanel() {
// back through account selection to active.
async function handleReconnect(connection: BankConnection, psuTypeOverride?: 'personal' | 'business') {
if (connectingRef.current) return
// Lock before the confirm await, same reason as handleConnectBank.
connectingRef.current = true
if (!(await confirmSameBankConnections(connection.bank_name))) {
connectingRef.current = false
return
}
setIsConnecting(true)
setConnectingBankName(connection.bank_name)
@@ -477,6 +561,15 @@ export default function BankingSettingsPanel() {
/>
)}
{/* The callback landed on a connection owned by another of the user's
companies: one ochre line naming where it went (convention 6). */}
{pickerCompanyMismatch && (
<p className="px-1 pt-6 text-[12.5px] leading-relaxed text-attn">
Bankanslutningen slutfördes för {pickerCompanyMismatch}, inte för det bolag som är aktivt
nu. Byt till {pickerCompanyMismatch} för att välja vilka konton som ska synka.
</p>
)}
{/* Persistent CSV fallback after connection/sync failure: a live hint,
kept visible as a compact line instead of a boxed strip. */}
{showCsvFallback && (
@@ -570,6 +663,10 @@ export default function BankingSettingsPanel() {
help={
<div className="space-y-2">
<p>Välj din bank nedan för att koppla ditt konto via PSD2.</p>
<p>
Anslutningen görs för det bolag som är aktivt just nu. Byt bolag först om du vill
ansluta banken åt ett annat bolag.
</p>
<p className="font-medium">Om bankintegration (PSD2)</p>
<p>
Automatisk import av transaktioner via PSD2 open banking.
@@ -607,6 +704,15 @@ export default function BankingSettingsPanel() {
/>
</SettingsRow>
<div className="px-1 pt-4">
{/* Name the company on the surface itself, not only in the help
popover: the bank login that follows says nothing about which
set of books the accounts will land in. */}
{company?.name && (
<p className="mb-3 text-[12.5px] leading-relaxed text-muted-foreground">
Anslutningen görs för{' '}
<span className="font-medium text-foreground">{company.name}</span>.
</p>
)}
<BankSelector
onConnect={(bank) => handleConnectBank(bank, psuType)}
onPsuTypeDetected={setPsuType}
+43 -7
View File
@@ -876,19 +876,41 @@ export const enableBankingExtension: Extension = {
// cash_accounts, whose UNIQUE (company_id, ledger_account) constraint
// would otherwise fail per-account and get swallowed, leaving accounts
// silently unmirrored.
const { allocatePsd2LedgerAccount, upsertFromPsd2, getRevokedConnectionIds } = await import(
'@/lib/cash-accounts/service'
)
const { resolvePsd2LedgerAccount, upsertFromPsd2, getRevokedConnectionIds, normalizeIban } =
await import('@/lib/cash-accounts/service')
const { data: companyCashRows } = await supabase
.from('cash_accounts')
.select('external_uid, bank_connection_id, ledger_account')
.select('id, external_uid, bank_connection_id, ledger_account, iban')
.eq('company_id', companyId)
const cashRows = (companyCashRows ?? []) as Array<{
id: string
external_uid: string | null
bank_connection_id: string | null
ledger_account: string
iban: string | null
}>
// Rows that already represent one of THIS connection's accounts, matched
// on IBAN rather than on the provider uid. After a re-authorization (new
// uids) or a fresh connect to an already-connected bank (new connection
// row), these are the user's own mappings wearing a stale owner: they
// must not be treated as another bank's territory, and the mirror pass
// below promotes them in place instead of inserting a second row.
const rowByIban = new Map<string, { id: string; ledger_account: string }>()
for (const r of cashRows) {
const normalized = normalizeIban(r.iban)
if (normalized && !rowByIban.has(normalized)) {
rowByIban.set(normalized, { id: r.id, ledger_account: r.ledger_account })
}
}
const reuseRowByUid = new Map<string, { id: string; ledger_account: string }>()
for (const a of updatedAccounts) {
const normalized = normalizeIban(a.iban)
const row = normalized ? rowByIban.get(normalized) : undefined
if (row) reuseRowByUid.set(a.uid, row)
}
const ownIbanRowIds = new Set([...reuseRowByUid.values()].map(r => r.id))
const existingLedgerByUid = new Map(
cashRows
.filter(r => r.bank_connection_id === connection.id && r.external_uid)
@@ -920,7 +942,10 @@ export const enableBankingExtension: Extension = {
r =>
r.bank_connection_id !== null &&
r.bank_connection_id !== connection.id &&
!revokedConnectionIds.has(r.bank_connection_id)
!revokedConnectionIds.has(r.bank_connection_id) &&
// Same IBAN as one of this connection's accounts: the same
// physical account under a stale owner, not a foreign claim.
!ownIbanRowIds.has(r.id)
)
.map(r => r.ledger_account)
)
@@ -970,11 +995,13 @@ export const enableBankingExtension: Extension = {
if (effectiveLedgerByUid.has(a.uid)) continue
let allocated: string | null = null
try {
allocated = await allocatePsd2LedgerAccount(supabase, companyId, user.id, {
const resolved = await resolvePsd2LedgerAccount(supabase, companyId, user.id, {
iban: a.iban,
currency: a.currency,
accountName: a.name,
exclude: usedLedgers,
})
allocated = resolved?.ledgerAccount ?? null
} catch (allocErr) {
log.warn('[enable-banking] ledger allocation failed on selection save', {
connectionId: connection.id,
@@ -1024,17 +1051,26 @@ export const enableBankingExtension: Extension = {
// without reading the JSONB column.
{
for (const a of updatedAccounts) {
const ledgerAccount = a.ledger_account ?? '1930'
// Only reuse the IBAN-matched row when it already sits on the
// ledger we are about to write. If the user deliberately remapped
// the account to a different BAS number, promoting the old row
// would move a row out from under the ledger it still holds.
const reuseRow = reuseRowByUid.get(a.uid)
const reuseCashAccountId =
reuseRow && reuseRow.ledger_account === ledgerAccount ? reuseRow.id : null
try {
await upsertFromPsd2(supabase, companyId, {
bank_connection_id: connection.id,
external_uid: a.uid,
currency: a.currency,
ledger_account: a.ledger_account ?? '1930',
ledger_account: ledgerAccount,
iban: a.iban ?? null,
name: a.name ?? null,
balance: a.balance ?? null,
balance_updated_at: a.balance_updated_at ?? null,
enabled: a.enabled ?? true,
reuse_cash_account_id: reuseCashAccountId,
})
} catch (cashErr) {
log.error('[enable-banking] Failed to mirror cash_account on selection save', {
@@ -18,6 +18,7 @@ import {
getAllTransactions,
getAllTransactionsWithRaw,
convertTransaction,
probeSessionHealth,
type Transaction,
} from '../api-client'
@@ -512,3 +513,61 @@ describe('convertTransaction', () => {
expect(out.proprietary_bank_transaction_code).toBe('XB')
})
})
// ---------------------------------------------------------------------------
// probeSessionHealth: nightly liveness check
// ---------------------------------------------------------------------------
describe('probeSessionHealth', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
fetchSpy = vi.spyOn(globalThis, 'fetch')
})
afterEach(() => {
fetchSpy.mockRestore()
})
function respond(status: number, body: unknown) {
fetchSpy.mockResolvedValue(
new Response(typeof body === 'string' ? body : JSON.stringify(body), { status }),
)
}
it('reports alive for an authorized session', async () => {
respond(200, { session_id: 's1', status: 'AUTHORIZED' })
expect(await probeSessionHealth('s1')).toBe('alive')
})
it('reports dead for a session the bank closed', async () => {
respond(200, { session_id: 's1', status: 'CLOSED' })
expect(await probeSessionHealth('s1')).toBe('dead')
})
it('reports dead when the session record is gone', async () => {
respond(404, { message: 'Not found' })
expect(await probeSessionHealth('s1')).toBe('dead')
})
it('reports dead on a 401 carrying a session-expiry signal', async () => {
respond(401, { error: 'SESSION_EXPIRED' })
expect(await probeSessionHealth('s1')).toBe('dead')
})
it('reports unknown for an unrecognized status rather than expiring a live connection', async () => {
respond(200, { session_id: 's1', status: 'SOMETHING_NEW' })
expect(await probeSessionHealth('s1')).toBe('unknown')
})
it('reports unknown on a bare 401 (app credentials, not a dead consent)', async () => {
respond(401, 'Unauthorized')
expect(await probeSessionHealth('s1')).toBe('unknown')
})
it('reports unknown when the request itself fails', async () => {
fetchSpy.mockRejectedValue(new Error('network down'))
expect(await probeSessionHealth('s1')).toBe('unknown')
})
})
@@ -63,6 +63,12 @@ export interface SessionResponse {
country: string
}
psu_type: string
/**
* Lifecycle state of the PSD2 session as Enable Banking sees it. Present on
* GET /sessions/{id}; used by probeSessionHealth to detect a consent that
* died bank-side without us attempting a transaction fetch.
*/
status?: string
}
export interface AccountInfo {
@@ -547,6 +553,73 @@ export async function getSession(sessionId: string): Promise<SessionResponse> {
return response.json()
}
/**
* Session lifecycle values that mean the consent can no longer be used. Kept
* deliberately narrow: an unrecognized status resolves to 'unknown' and leaves
* the stored connection state untouched, because wrongly flipping a live
* connection to 'expired' costs the user a full BankID re-authorization.
*/
const SESSION_DEAD_STATUSES = new Set([
'CANCELLED',
'CLOSED',
'EXPIRED',
'INVALID',
'REJECTED',
'REVOKED',
])
/**
* Session lifecycle values that mean the consent is usable. Anything outside
* both sets (including a response carrying no status at all) is 'unknown':
* claiming 'alive' for a value we do not recognize would be asserting more
* than the probe actually established.
*/
const SESSION_ALIVE_STATUSES = new Set(['AUTHORIZED', 'VALID', 'ACTIVE'])
export type SessionHealth = 'alive' | 'dead' | 'unknown'
/**
* Ask Enable Banking whether a PSD2 session is still usable, without fetching
* any account data.
*
* Until this existed, a connection only ever learned its session was dead by
* TRYING to sync: a bank that invalidates a consent server-side (several
* ASPSPs drop the previous session when the same PSU authorizes again) left
* the row sitting at status 'active' with a stale last_synced_at, so the UI
* kept presenting old balances as current. Never throws: probing is a
* best-effort health signal, and 'unknown' is always a safe answer.
*/
export async function probeSessionHealth(sessionId: string): Promise<SessionHealth> {
try {
const response = await authenticatedFetchWithRetry(`/sessions/${sessionId}`)
const body = await response.text()
if (!response.ok) {
if (isSessionExpiredResponse(response.status, body)) return 'dead'
// The session record itself is gone: nothing left to sync with.
if (response.status === 404) return 'dead'
return 'unknown'
}
try {
const parsed = JSON.parse(body) as SessionResponse
const status = typeof parsed.status === 'string' ? parsed.status.toUpperCase() : null
if (status && SESSION_DEAD_STATUSES.has(status)) return 'dead'
if (status && SESSION_ALIVE_STATUSES.has(status)) return 'alive'
} catch {
// Unparseable body on a 200: treat as inconclusive, not as dead.
return 'unknown'
}
return 'unknown'
} catch (error) {
console.warn('[enable-banking] probeSessionHealth failed', {
sessionId,
error: error instanceof Error ? error.message : String(error),
})
return 'unknown'
}
}
/**
* Delete/revoke a session
*
@@ -46,14 +46,21 @@ function mockSupabaseWithLines(lines: MockLine[]) {
const makeChain = (rows: unknown[]) => {
const chain: Record<string, () => unknown> = {}
chain.range = () => ({ data: rows, error: null })
for (const m of ['order', 'lte', 'gte', 'neq', 'in', 'eq', 'select', 'limit', 'contains', 'filter']) {
for (const m of ['order', 'lte', 'gte', 'neq', 'in', 'not', 'eq', 'select', 'limit', 'contains', 'filter']) {
chain[m] = () => chain
}
return chain
}
return {
from: (table: string) => (table === 'journal_entries' ? makeChain(entries) : makeChain(bareLines)),
// chart_of_accounts feeds fetchDynamicRuta05Accounts (the company's own
// ruta 05 konton). Empty here: these fixtures are plain BAS charts, and the
// dynamic path has its own coverage in lib/reports/__tests__.
from: (table: string) => {
if (table === 'journal_entries') return makeChain(entries)
if (table === 'chart_of_accounts') return makeChain([])
return makeChain(bareLines)
},
} as never
}
+24 -6
View File
@@ -34,6 +34,7 @@ import {
rcInputTotalsFromDeclaration,
calculateVatDeclaration,
} from '@/lib/reports/vat-declaration'
import { fetchDynamicRuta05Accounts } from '@/lib/reports/vat-revenue-accounts'
// The momsdeklaration completeness checks live in core (lib/reports) and are
// shared with the web UI's "Kontroll av underlaget" gate. The MCP surface
// imports them instead of mirroring them: a hand-rolled copy here is exactly
@@ -1162,10 +1163,17 @@ const SKV_AGI_STATUS_OUTPUT_SCHEMA = {
* rare case of taxable EU goods (momspliktig EU-leverans, e.g. when the
* buyer's VAT number is invalid).
*
* Companies using non-standard charts must either book to one of these
* or extend the list: Accounted's BAS chart only ships 3001/3002/3003/3004
* by default, but 30xx alternates are common in custom charts. */
* This hand-maintained widening predates #1261 and is kept so no company
* loses a figure it already saw. It is no longer the only path: a company's
* own class 3 konto marked with a moms-sats is resolved at runtime by
* fetchDynamicRuta05Accounts and unioned in below, which is what actually
* covers non-standard charts (Accounted's BAS chart ships no varugrupp
* accounts at all). */
const RUTA_05_ACCOUNTS = [
// The 30xx gruppkonto. ACCOUNT_RUTA maps it to ruta05, so leaving it out here
// made a balance on 3000 appear in the filed projection but not in
// report.rutor.ruta05.
'3000',
// Domestic sales by VAT rate (canonical BAS)
'3001', '3002', '3003', '3005', '3006', '3007', '3008',
// Taxable EU goods (momspliktig, buyer's VAT number invalid or buyer is private)
@@ -1208,7 +1216,8 @@ export interface VatReportWithRutor {
* The two also differ on ruta 05 by design: `report.rutor.ruta05` sums the
* widened RUTA_05_ACCOUNTS list for display, while this one is the canonical
* ACCOUNT_RUTA projection, i.e. what would actually be filed. Checks run on
* the filed shape, never on the display shape.
* the filed shape, never on the display shape. The company's own ruta 05
* accounts feed BOTH: they are part of the filing, not a display widening.
*/
declarationRutor: VatDeclarationRutor
/**
@@ -1344,7 +1353,12 @@ export async function computeVatReportWithRutor(
return t ? Math.round((t.debit - t.credit) * 100) / 100 : 0
}
const ruta05 = RUTA_05_ACCOUNTS.reduce((sum, acc) => sum + creditBalance(acc), 0)
// The company's own momspliktiga intäktskonton join the hand-maintained list.
// Deduped: an account can appear in both (e.g. 3041 with a moms-sats set),
// and counting it twice would inflate ruta 05.
const dynamicRuta05 = await fetchDynamicRuta05Accounts(supabase, companyId)
const ruta05Accounts = [...new Set([...RUTA_05_ACCOUNTS, ...dynamicRuta05.accounts])]
const ruta05 = ruta05Accounts.reduce((sum, acc) => sum + creditBalance(acc), 0)
const ruta10 = creditBalance('2611')
const ruta11 = creditBalance('2621')
const ruta12 = creditBalance('2631')
@@ -1423,7 +1437,11 @@ export async function computeVatReportWithRutor(
// Same `accountTotals` the report is built from, projected through core's
// ACCOUNT_RUTA map so the completeness checks see the full declaration
// (incl. rutor 20-24 and 50) instead of the trimmed report view.
return { report, declarationRutor: rutorFromTotals(accountTotals), accountTotals }
return {
report,
declarationRutor: rutorFromTotals(accountTotals, dynamicRuta05.accounts),
accountTotals,
}
}
/**
+10 -4
View File
@@ -7,6 +7,7 @@ import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import { PERSONAL_NUMBER_INPUT_RE } from '@/lib/customers/mask-personal-number'
import type { AuditAction } from '@/types'
// ============================================================
@@ -848,14 +849,19 @@ export const UpdateCustomerSchema = z.object({
org_number: z.string().optional(),
vat_number: z.string().optional(),
// Plaintext personnummer (validated here, then encrypted by the route), or
// the masked form '********-1234' that every read path returns. The route
// reads the mask as "leave the stored value alone" and never stores it, so
// a client echoing back what it read cannot wipe the personnummer.
// either masked form a read path returns: '********-1234' when the stored
// value decrypted, '********-????' when it did not. The route reads a mask
// as "leave the stored value alone" and never stores it, so a client echoing
// back what it read cannot wipe the personnummer.
// Both forms must pass. Accepting only the '-1234' one made an undecryptable
// row completely uneditable: the mask the API had just returned failed
// validation here, so PATCHing the customer's name or address 400'd on a
// field the user had no way to correct.
// CreateCustomerSchema stays strict: on create there is no stored value to
// preserve, so a mask there is a client error and earns a 400.
personal_number: z
.string()
.regex(/^(?:(\d{6}|\d{8})[-+]?\d{4}|\*{8}-\d{4})$/, 'Invalid personal number')
.regex(PERSONAL_NUMBER_INPUT_RE, 'Invalid personal number')
.nullable()
.optional(),
language: z.enum(['sv', 'en']).optional(),
@@ -0,0 +1,22 @@
/**
* Verifikation description for supplier-invoice vouchers: event type,
* counterparty and an identifying suffix (BFL 5 kap 7 §).
*
* Deliberately dependency-free and in its own module: the pre-save preview in
* components/suppliers/SupplierInvoiceReviewContent.tsx is a client component
* and must render the exact string the engine will post. Importing it from
* supplier-invoice-entries.ts would drag the journal engine (and its Supabase
* server client) into the browser bundle, so the text lives here and both
* sides call it.
*/
export function buildSupplierDescription(
prefix: string,
invoiceNumber: string,
supplierName?: string,
suffix?: string,
): string {
const base = supplierName
? `${prefix} ${invoiceNumber}, ${supplierName}`
: `${prefix} ${invoiceNumber}`
return suffix ? `${base} ${suffix}` : base
}
+1 -13
View File
@@ -1,6 +1,7 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { resolveBookingAccount } from './accruals/account-suggestions'
import { buildSupplierDescription } from './supplier-invoice-description'
import {
generateReverseChargeLines,
generateReverseChargeBasisLines,
@@ -82,19 +83,6 @@ function toSekOrThrow(
return resolveSekAmount(amount, null, currency, exchangeRate)
}
/**
* Build a BFL-compliant verifikation description with event type, counterparty, and suffix.
* Falls back to prefix + invoiceNumber + suffix if name is not provided (backward compat).
*/
function buildSupplierDescription(
prefix: string, invoiceNumber: string, supplierName?: string, suffix?: string
): string {
const base = supplierName
? `${prefix} ${invoiceNumber}, ${supplierName}`
: `${prefix} ${invoiceNumber}`
return suffix ? `${base} ${suffix}` : base
}
/**
* Aggregate item amounts per (booking account, merged dimensions bag):
* dimensions PR7. The merged bag (item.dimensions over the invoice's
+235 -6
View File
@@ -13,13 +13,20 @@ vi.mock('@/lib/import/account-sync', () => ({
import {
findFreeLedgerAccount,
allocatePsd2LedgerAccount,
resolvePsd2LedgerAccount,
normalizeIban,
defaultLedgerForCurrency,
getRevokedConnectionIds,
upsertFromPsd2,
ensureManualCashAccount,
} from '../service'
type CashRow = { ledger_account: string; bank_connection_id: string | null }
type CashRow = {
ledger_account: string
bank_connection_id: string | null
id?: string
iban?: string | null
}
type ConnRow = { id: string; status: string }
interface MakeSupabaseOpts {
@@ -27,6 +34,26 @@ interface MakeSupabaseOpts {
/** bank_connections rows for the status lookup. Missing ids = not revoked. */
connections?: ConnRow[]
connectionsError?: { message: string } | null
/** 19xx account numbers already present in the company's chart. */
chart?: string[]
chartError?: { message: string } | null
}
/**
* Thenable query stub: PostgREST chains terminate on await, not on a fixed
* method, so the same object has to answer .eq()/.not()/.like() and still
* resolve when awaited. Without this a chain that ends in .not() (the IBAN
* lookup) cannot share a mock with one that ends in .eq().
*/
function chainable(result: { data: unknown; error: unknown }) {
const chain: Record<string, unknown> = {}
for (const method of ['select', 'eq', 'neq', 'not', 'is', 'like', 'in', 'order', 'limit']) {
chain[method] = vi.fn(() => chain)
}
chain.then = (onFulfilled: (value: unknown) => unknown) =>
Promise.resolve(result).then(onFulfilled)
chain.maybeSingle = vi.fn(() => Promise.resolve(result))
return chain
}
function makeSupabase(rows: CashRow[], opts: MakeSupabaseOpts = {}) {
@@ -48,12 +75,17 @@ function makeSupabase(rows: CashRow[], opts: MakeSupabaseOpts = {}) {
),
}
}
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn(() =>
Promise.resolve({ data: opts.error ? null : rows, error: opts.error ?? null }),
),
if (table === 'chart_of_accounts') {
return chainable(
opts.chartError
? { data: null, error: opts.chartError }
: { data: (opts.chart ?? []).map(n => ({ account_number: n })), error: null },
)
}
return chainable({
data: opts.error ? null : rows,
error: opts.error ?? null,
})
}),
} as unknown as SupabaseClient
}
@@ -264,6 +296,164 @@ describe('allocatePsd2LedgerAccount', () => {
})
})
describe('findFreeLedgerAccount: chart awareness', () => {
it('skips an overflow slot that already names a bank account in the chart', async () => {
// A chart imported from SIE carries the company's real bank accounts
// ("1931 Nordnet") with no cash_accounts row behind them. Handing one out
// as free is how a SEK företagskonto got proposed as someone else's
// brokerage account.
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: 'conn-1' }], {
chart: ['1930', '1931', '1935'],
})
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1936')
})
it('still returns the currency default when the chart holds it', async () => {
// 1930 exists in every chart; that must not push the SEK account into
// overflow when no PSD2 row actually claims it.
const supabase = makeSupabase([], { chart: ['1930'] })
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1930')
})
it('falls back to a chart-occupied slot when nothing unnamed is left', async () => {
const chart: string[] = []
for (let n = 1931; n <= 1959; n++) chart.push(String(n))
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: 'conn-1' }], {
chart,
})
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1931')
})
it('allocates normally when the chart lookup fails', async () => {
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: 'conn-1' }], {
chartError: { message: 'boom' },
})
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1931')
})
})
describe('normalizeIban', () => {
it('strips formatting so the same account compares equal', () => {
expect(normalizeIban('SE45 5000 0000 0583 9825 7466')).toBe('SE4550000000058398257466')
expect(normalizeIban('se4550000000058398257466')).toBe('SE4550000000058398257466')
expect(normalizeIban(null)).toBeNull()
expect(normalizeIban(' ')).toBeNull()
})
})
describe('resolvePsd2LedgerAccount', () => {
const IBAN = 'SE4550000000058398257466'
it('reuses the ledger of the row with the same IBAN instead of allocating', async () => {
// The reconnect case: the bank minted a new account uid (and possibly a
// whole new connection row), but it is the same physical account.
const supabase = makeSupabase([
{ id: 'row-1', ledger_account: '1930', bank_connection_id: 'conn-old', iban: IBAN },
])
const resolved = await resolvePsd2LedgerAccount(supabase, 'c1', 'u1', {
iban: IBAN,
currency: 'SEK',
})
expect(resolved).toEqual({
ledgerAccount: '1930',
reuseCashAccountId: 'row-1',
source: 'iban',
})
// No chart write: we are adopting an account that already exists.
expect(mockSyncMappedAccounts).not.toHaveBeenCalled()
})
it('matches on IBAN across formatting differences', async () => {
const supabase = makeSupabase([
{
id: 'row-1',
ledger_account: '1941',
bank_connection_id: 'conn-old',
iban: 'SE45 5000 0000 0583 9825 7466',
},
])
const resolved = await resolvePsd2LedgerAccount(supabase, 'c1', 'u1', {
iban: 'se4550000000058398257466',
currency: 'EUR',
})
expect(resolved?.ledgerAccount).toBe('1941')
expect(resolved?.source).toBe('iban')
})
it('reuses even when the previous holder connection is still active', async () => {
// The bank killed the old session without telling us, so the old row still
// reads as a live claim. One IBAN is one account: the connection that just
// authorized owns it.
const supabase = makeSupabase(
[{ id: 'row-1', ledger_account: '1930', bank_connection_id: 'conn-old', iban: IBAN }],
{ connections: [{ id: 'conn-old', status: 'active' }] },
)
const resolved = await resolvePsd2LedgerAccount(supabase, 'c1', 'u1', {
iban: IBAN,
currency: 'SEK',
})
expect(resolved?.ledgerAccount).toBe('1930')
expect(resolved?.reuseCashAccountId).toBe('row-1')
})
it('allocates when the IBAN is unknown', async () => {
const supabase = makeSupabase([
{ id: 'row-1', ledger_account: '1930', bank_connection_id: 'conn-1', iban: 'SE9999' },
])
const resolved = await resolvePsd2LedgerAccount(supabase, 'c1', 'u1', {
iban: IBAN,
currency: 'SEK',
})
expect(resolved?.source).toBe('allocated')
expect(resolved?.reuseCashAccountId).toBeNull()
expect(resolved?.ledgerAccount).toBe('1931')
})
it('allocates when the IBAN match was already claimed earlier in the loop', async () => {
// Two accounts cannot share a ledger: the UNIQUE (company_id,
// ledger_account) constraint would reject the second write.
const supabase = makeSupabase([
{ id: 'row-1', ledger_account: '1930', bank_connection_id: null, iban: IBAN },
])
const resolved = await resolvePsd2LedgerAccount(supabase, 'c1', 'u1', {
iban: IBAN,
currency: 'SEK',
exclude: new Set(['1930']),
})
expect(resolved?.source).toBe('allocated')
expect(resolved?.ledgerAccount).not.toBe('1930')
})
it('allocates for an account the bank gave no IBAN for', async () => {
const supabase = makeSupabase([])
const resolved = await resolvePsd2LedgerAccount(supabase, 'c1', 'u1', {
iban: null,
currency: 'SEK',
})
expect(resolved).toEqual({
ledgerAccount: '1930',
reuseCashAccountId: null,
source: 'allocated',
})
})
})
// ---------------------------------------------------------------------------
// upsertFromPsd2: promote-in-place + duplicate merge (issue #916)
// ---------------------------------------------------------------------------
@@ -445,6 +635,45 @@ describe('upsertFromPsd2', () => {
expect(stub.upserts).toHaveLength(1)
})
it('promotes an IBAN-matched holder even when a live connection still holds it', async () => {
// The reconnect fix: resolvePsd2LedgerAccount matched this row by IBAN, so
// it is this account under a stale owner. Promoting keeps the row id (and
// its linked transactions) and re-points it at the new connection; without
// this the INSERT would trip the (company_id, ledger_account) constraint
// and the user's 1930 mapping would land on an overflow slot instead.
const stub = makeUpsertStub({
holder: { id: 'row-known', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'active' }],
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', {
...UPSERT_INPUT,
reuse_cash_account_id: 'row-known',
})
expect(stub.upserts).toHaveLength(0)
expect(stub.updates).toHaveLength(1)
expect(stub.updates[0].id).toBe('row-known')
expect(stub.updates[0].payload).toMatchObject({
bank_connection_id: 'conn-new',
external_uid: 'uid-1',
ledger_account: '1930',
})
})
it('ignores a reuse id that does not match the row holding the ledger', async () => {
const stub = makeUpsertStub({
holder: { id: 'row-other', bank_connection_id: 'conn-other' },
connections: [{ id: 'conn-other', status: 'active' }],
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', {
...UPSERT_INPUT,
reuse_cash_account_id: 'row-stale',
})
expect(stub.updates).toHaveLength(0)
expect(stub.upserts).toHaveLength(1)
})
it('routes a holder owned by the SAME connection through the plain upsert', async () => {
const stub = makeUpsertStub({
holder: { id: 'row-self', bank_connection_id: 'conn-new' },
+153 -2
View File
@@ -45,6 +45,27 @@ export interface UpsertFromPsd2Input {
balance?: number | null
balance_updated_at?: string | null
enabled?: boolean
/**
* Existing cash_accounts row this PSD2 account was matched to by IBAN
* (see resolvePsd2LedgerAccount). The row is promoted in place: it keeps its
* id, its ledger_account and its linked transactions, and is re-pointed at
* this connection + external_uid. Without this the reconnect path would try
* to INSERT a second row on the same ledger and trip the
* (company_id, ledger_account) UNIQUE constraint.
*/
reuse_cash_account_id?: string | null
}
/**
* Normalize an IBAN for comparison: ASPSPs format the same account both as
* "SE45 5000 0000 0583 9825 7466" and "SE4550000000058398257466", and a plain
* string compare would read those as two different accounts. Mirrors the
* normalization the sync path already applies when deriving external_ids.
*/
export function normalizeIban(iban: string | null | undefined): string | null {
if (!iban) return null
const normalized = iban.replace(/\s+/g, '').toUpperCase()
return normalized || null
}
export async function listForCompany(
@@ -183,6 +204,14 @@ export async function getRevokedConnectionIds(
* four currency defaults (reserved as suggestions for their currencies)
* and any slot held by ANY existing row promoting an unrelated manual
* account (SIE-imported, kassa) would silently steal it.
* - Overflow ALSO skips 19xx numbers that already exist in the company's
* chart of accounts, even when no cash_accounts row holds them. A chart
* imported from SIE carries the company's real bank accounts by name
* ("1942 Nordnet", "1938 Danske eSett Settlement") without any PSD2 row
* behind them, and handing one of those out as "free" is how a SEK
* företagskonto ended up proposed as 1942 Nordnet. Only when every
* chart-free slot is exhausted do we fall back to chart-occupied numbers,
* so a company with a fully populated 19xx chart still gets an answer.
* - `exclude` carries slots already assigned earlier in the caller's loop
* but not yet visible in the table.
*
@@ -207,6 +236,24 @@ export async function findFreeLedgerAccount(
return null
}
// Chart accounts are advisory here: a failed lookup must not block
// allocation, it just costs us the "don't steal a named bank account" guard.
const { data: chartRows, error: chartError } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.like('account_number', '19%')
if (chartError) {
log.warn('findFreeLedgerAccount chart lookup failed', {
companyId,
error: chartError.message,
})
}
const chartTaken = new Set(
((chartRows ?? []) as Array<{ account_number: string }>).map(r => r.account_number),
)
const typedRows = (rows ?? []) as Array<{ ledger_account: string; bank_connection_id: string | null }>
const revokedConnectionIds = await getRevokedConnectionIds(
supabase,
@@ -226,11 +273,26 @@ export async function findFreeLedgerAccount(
if (!exclude.has(preferred) && !connectedTaken.has(preferred)) return preferred
const reserved = new Set(Object.values(CURRENCY_LEDGER_DEFAULTS))
const candidates: string[] = []
for (let n = 1931; n <= 1959; n++) {
const candidate = String(n)
if (reserved.has(candidate)) continue
if (exclude.has(candidate) || anyTaken.has(candidate)) continue
return candidate
candidates.push(candidate)
}
// First pass: slots the chart has never heard of, so we can create them
// cleanly. Second pass: chart-occupied slots, the pre-fix behavior, only
// once nothing unnamed is left.
const unnamed = candidates.find(c => !chartTaken.has(c))
if (unnamed) return unnamed
if (candidates.length > 0) {
log.warn('findFreeLedgerAccount fell back to a chart-occupied slot', {
companyId,
currency,
ledger: candidates[0],
})
return candidates[0]
}
log.warn('findFreeLedgerAccount exhausted 19311959', { companyId, currency })
@@ -283,6 +345,90 @@ export async function allocatePsd2LedgerAccount(
return ledger
}
export interface Psd2LedgerResolution {
ledgerAccount: string
/**
* Existing row to promote in place, when the IBAN was already known. Null
* when the ledger was freshly allocated.
*/
reuseCashAccountId: string | null
source: 'iban' | 'allocated'
}
/**
* Decide which BAS account a PSD2 account should book to, IBAN first.
*
* The IBAN identifies the physical bank account; the provider's account `uid`
* does not survive a re-authorization at every ASPSP, and a fresh connect to
* an already-connected bank mints a new bank_connection row regardless. Both
* cases used to look like "an account we have never seen", so the allocator
* handed out the next free 19xx slot and the user's mapping (1930/1940/1941)
* silently moved to 1942-1946 on every consent renewal.
*
* Matching on the IBAN instead means a known account keeps its ledger, its
* cash_accounts row id and therefore its linked transactions. The previous
* holder's connection status is deliberately NOT considered: one IBAN is one
* physical account, so the connection that just authorized it is the one that
* owns it. This matters for the case that motivated the fix, where the old
* connection still reads 'active' because its session was killed bank-side
* without telling us.
*
* Returns null only when allocation itself fails; callers keep their existing
* fallback.
*/
export async function resolvePsd2LedgerAccount(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: {
iban?: string | null
currency: string
accountName?: string | null
exclude?: ReadonlySet<string>
},
): Promise<Psd2LedgerResolution | null> {
const exclude = input.exclude ?? new Set<string>()
const wanted = normalizeIban(input.iban)
if (wanted) {
const { data, error } = await supabase
.from('cash_accounts')
.select('id, iban, ledger_account')
.eq('company_id', companyId)
.not('iban', 'is', null)
if (error) {
// Fall through to allocation: a failed lookup must not block the
// connection, it just costs us the reuse.
log.warn('resolvePsd2LedgerAccount iban lookup failed', {
companyId,
error: error.message,
})
} else {
const match = ((data ?? []) as Array<{ id: string; iban: string | null; ledger_account: string }>)
.find(row => normalizeIban(row.iban) === wanted)
// A ledger already claimed earlier in the caller's loop cannot be handed
// out twice, even on an IBAN hit: two rows on one ledger violate the
// (company_id, ledger_account) UNIQUE constraint.
if (match && !exclude.has(match.ledger_account)) {
return {
ledgerAccount: match.ledger_account,
reuseCashAccountId: match.id,
source: 'iban',
}
}
}
}
const allocated = await allocatePsd2LedgerAccount(supabase, companyId, userId, {
currency: input.currency,
accountName: input.accountName,
exclude,
})
if (!allocated) return null
return { ledgerAccount: allocated, reuseCashAccountId: null, source: 'allocated' }
}
/**
* Upsert a PSD2-sourced cash account during connection callback / sync. Keyed on
* (company_id, bank_connection_id, external_uid). When the row exists, balance
@@ -342,7 +488,12 @@ export async function upsertFromPsd2(
const typedHolder = holderRow as { id: string; bank_connection_id: string | null } | null
let promotableRowId: string | null = null
if (typedHolder) {
if (typedHolder.bank_connection_id === null) {
if (typedHolder.id === input.reuse_cash_account_id) {
// Matched by IBAN upstream: this row IS this account, whoever held it
// last. Promoting keeps its id (transactions.cash_account_id stays
// linked) and re-points it at the connection that just authorized.
promotableRowId = typedHolder.id
} else if (typedHolder.bank_connection_id === null) {
promotableRowId = typedHolder.id
} else if (typedHolder.bank_connection_id !== input.bank_connection_id) {
const revoked = await getRevokedConnectionIds(supabase, companyId, [
@@ -18,9 +18,16 @@ import { encryptPersonnummer } from '@/lib/salary/personnummer'
import {
encryptCustomerPersonalNumber,
maskCustomerRow,
maskEmbeddedCustomer,
maskStoredCustomerPersonalNumber,
revealStoredCustomerPersonalNumber,
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
} from '../protect-personal-number'
import {
PERSONAL_NUMBER_INPUT_RE,
isMaskedPersonalNumber,
maskCustomerPersonalNumber,
} from '../mask-personal-number'
// Synthetic personnummer, never a real one.
const PERSONAL_NUMBER = '19900101-1234'
@@ -65,13 +72,101 @@ describe('maskStoredCustomerPersonalNumber', () => {
)
})
it('placeholder mask carries no digits and cannot pass personnummer validation', () => {
// It must never read as a real suffix, and a client that blindly PATCHes
// it back must fail plaintext validation rather than store or clear
// anything (null here would let a round-trip DELETE the stored value).
it('placeholder mask carries no digits and is never a personnummer', () => {
// It must never read as a real suffix, and it must not be null: null here
// would let a blind read-modify-write round-trip DELETE the stored value.
expect(UNDECRYPTABLE_PERSONAL_NUMBER_MASK).not.toMatch(/\d/)
expect(UNDECRYPTABLE_PERSONAL_NUMBER_MASK).not.toMatch(/^(\d{6}|\d{8})[-+]?\d{4}$/)
})
it('recognizes the placeholder as a mask, so a round-trip means "unchanged"', () => {
// The property that makes an undecryptable row editable. When only
// '********-1234' counted as a mask, the placeholder failed validation in
// the schema, the route and the form at once, so the customer could not be
// edited at all: not the personnummer, not the name, not the address.
expect(isMaskedPersonalNumber(UNDECRYPTABLE_PERSONAL_NUMBER_MASK)).toBe(true)
expect(isMaskedPersonalNumber(MASKED)).toBe(true)
expect(PERSONAL_NUMBER_INPUT_RE.test(UNDECRYPTABLE_PERSONAL_NUMBER_MASK)).toBe(true)
expect(PERSONAL_NUMBER_INPUT_RE.test(MASKED)).toBe(true)
})
it('does not mistake a real personnummer for a mask', () => {
for (const plaintext of ['9001011234', '900101-1234', '199001011234', '19900101-1234']) {
expect(isMaskedPersonalNumber(plaintext)).toBe(false)
expect(PERSONAL_NUMBER_INPUT_RE.test(plaintext)).toBe(true)
}
expect(isMaskedPersonalNumber(null)).toBe(false)
expect(isMaskedPersonalNumber('')).toBe(false)
expect(isMaskedPersonalNumber('********-12345')).toBe(false)
expect(isMaskedPersonalNumber('****-1234')).toBe(false)
expect(PERSONAL_NUMBER_INPUT_RE.test('not-a-personal-number')).toBe(false)
})
})
describe('maskCustomerPersonalNumber', () => {
it('is idempotent, so masking an already-masked API value is safe', () => {
// The detail view re-masks what the API already masked. Re-masking used to
// turn the placeholder into null via the digit-stripping path, which
// rendered a row that HAS a personnummer as having none.
expect(maskCustomerPersonalNumber(MASKED)).toBe(MASKED)
expect(maskCustomerPersonalNumber(UNDECRYPTABLE_PERSONAL_NUMBER_MASK)).toBe(
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
)
})
it('still masks a plaintext personnummer down to the last four digits', () => {
expect(maskCustomerPersonalNumber('19900101-1234')).toBe(MASKED)
expect(maskCustomerPersonalNumber(null)).toBeNull()
})
})
describe('revealStoredCustomerPersonalNumber', () => {
it('returns the full personnummer for stored ciphertext', () => {
// The drill-in behind the mask. Without it the field is write-only: a user
// can save a personnummer and never verify what was actually stored.
const stored = encryptPersonnummer(PERSONAL_NUMBER)
expect(revealStoredCustomerPersonalNumber(stored)).toBe(PERSONAL_NUMBER)
})
it('passes a legacy plaintext row through unchanged', () => {
expect(revealStoredCustomerPersonalNumber('900101-1234')).toBe('900101-1234')
})
it('returns null when nothing is stored', () => {
expect(revealStoredCustomerPersonalNumber(null)).toBeNull()
expect(revealStoredCustomerPersonalNumber(undefined)).toBeNull()
expect(revealStoredCustomerPersonalNumber('')).toBeNull()
})
it('throws on an undecryptable value rather than inventing a number', () => {
// Deliberately unlike the masking helpers, which must never throw: here a
// wrong-but-plausible answer would be worse than an error the caller can
// turn into "retype it".
expect(() => revealStoredCustomerPersonalNumber(GARBAGE_HEX)).toThrow()
})
})
describe('maskEmbeddedCustomer', () => {
it('masks the personnummer on an embedded customer join', () => {
const stored = encryptPersonnummer(PERSONAL_NUMBER)
const invoice = {
id: 'i1',
total: 1250,
customer: { id: 'c1', name: 'Anna Andersson', personal_number: stored },
}
const masked = maskEmbeddedCustomer(invoice)
expect(masked.customer.personal_number).toBe(MASKED)
// Everything else survives untouched.
expect(masked.id).toBe('i1')
expect(masked.total).toBe(1250)
expect(masked.customer.name).toBe('Anna Andersson')
})
it('leaves rows without an embedded customer alone', () => {
expect(maskEmbeddedCustomer({ id: 'i1', customer: null })).toEqual({ id: 'i1', customer: null })
expect(maskEmbeddedCustomer({ id: 'i1' })).toEqual({ id: 'i1' })
expect(maskEmbeddedCustomer(null)).toBeNull()
})
})
describe('maskCustomerRow', () => {
+61
View File
@@ -1,8 +1,69 @@
/**
* Display forms of customers.personal_number, and the one regex that
* recognizes them.
*
* This module is deliberately free of any crypto import so it can be shared by
* the client form, the Zod schemas and the server read paths. The decrypting
* half lives in lib/customers/protect-personal-number.ts, which is server-only.
*/
/**
* Placeholder used when a stored personal_number cannot be decrypted
* (corrupted ciphertext, a value written under a different
* PERSONNUMMER_ENCRYPTION_KEY, or pre-encryption garbage on a self-hosted DB).
*
* Shape rationale: it must be recognizably a mask (never mistakable for a real
* suffix, so no fabricated digits) and it must NOT be null. Returning null
* would render as "no personnummer", and worse: a client that reads the
* customer and PATCHes the whole object back would send personal_number: null,
* which the update route treats as "clear the column", destroying the stored
* ciphertext.
*/
export const UNDECRYPTABLE_PERSONAL_NUMBER_MASK = '********-????'
/**
* Every masked display form the read paths can emit: '********-1234' for a
* value that decrypted, '********-????' for one that did not.
*
* Both mean the same thing to a write path: the client is echoing back what it
* read, so leave the stored value alone. Keeping one regex here rather than a
* copy in each of the three consumers (UpdateCustomerSchema, the PATCH route,
* CustomerForm) is what makes that true. The earlier copies covered the
* '-1234' form only, so an undecryptable row failed validation in all three
* places at once and its customer could not be edited at all: not the
* personnummer, not the name, not the address.
*
* The asterisk prefix can never collide with a valid personnummer, so widening
* the suffix does not loosen anything a real value relies on.
*/
export const PERSONAL_NUMBER_MASK_RE = /^\*{8}-(?:\d{4}|\?{4})$/
/** True when `value` is a masked display form rather than a real personnummer. */
export function isMaskedPersonalNumber(value: unknown): boolean {
return typeof value === 'string' && PERSONAL_NUMBER_MASK_RE.test(value)
}
/**
* What an edit surface may submit for personal_number: a plaintext personnummer
* in any of the four accepted forms, or a mask meaning "unchanged".
*
* Shared by UpdateCustomerSchema and the CustomerForm resolver so the client
* and the server cannot drift on which values are submittable. Create paths use
* the plaintext half alone.
*/
export const PERSONAL_NUMBER_INPUT_RE = /^(?:(?:\d{6}|\d{8})[-+]?\d{4}|\*{8}-(?:\d{4}|\?{4}))$/
/**
* Display a personal identity number without exposing birth date or full ID.
*
* Already-masked input is returned unchanged: the API masks on read, so a
* client that masks again on render must not turn '********-1234' into
* something else, nor '********-????' into null (which would render as "no
* personnummer" for a row that has one).
*/
export function maskCustomerPersonalNumber(value: string | null | undefined): string | null {
if (!value) return null
if (isMaskedPersonalNumber(value)) return value
const last4 = value.replace(/\D/g, '').slice(-4)
return last4.length === 4 ? `********-${last4}` : null
}
+51 -15
View File
@@ -1,23 +1,17 @@
import { createLogger } from '@/lib/logger'
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
import { maskCustomerPersonalNumber } from '@/lib/customers/mask-personal-number'
import {
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
maskCustomerPersonalNumber,
} from '@/lib/customers/mask-personal-number'
const log = createLogger('customers/protect-personal-number')
/**
* Placeholder returned when a stored personal_number cannot be decrypted
* (corrupted ciphertext, a value written under a different
* PERSONNUMMER_ENCRYPTION_KEY, or pre-encryption garbage on a self-hosted DB).
*
* Shape rationale: it must be recognizably a mask (never mistakable for a real
* suffix, so no fabricated digits) and it must NOT be null. Returning null
* would render as "no personnummer", and worse: a client that reads the
* customer and PATCHes the whole object back would send personal_number: null,
* which the update route treats as "clear the column", destroying the stored
* ciphertext. The placeholder fails the route's plaintext validation instead,
* so a blind round-trip errors loudly rather than deleting data.
*/
export const UNDECRYPTABLE_PERSONAL_NUMBER_MASK = '********-????'
// Re-exported so server callers can keep importing the placeholder from the
// module that produces it. The constant itself lives in mask-personal-number.ts
// alongside the regex that recognizes it, which the client form and the Zod
// schemas also need and which must not pull in this module's crypto imports.
export { UNDECRYPTABLE_PERSONAL_NUMBER_MASK }
export function encryptCustomerPersonalNumber(value: string | null | undefined): string | null {
return value ? encryptPersonnummer(value) : null
@@ -46,9 +40,51 @@ export function maskStoredCustomerPersonalNumber(value: string | null | undefine
}
}
/**
* Decrypt a stored customers.personal_number back to plaintext.
*
* The deliberate drill-in behind the mask, and the ONLY function that returns
* the full identifier. It mirrors the employee convention (v1 employee list
* masks, v1 employee detail returns all 12 digits): a value the user typed in
* has to be readable back, or the field is write-only and unverifiable.
*
* Callers own the access decision and the audit entry; this function only
* decrypts. Returns null when there is nothing stored, and throws when the
* value cannot be decrypted so the caller can answer with a specific error
* rather than a plausible-looking wrong number.
*/
export function revealStoredCustomerPersonalNumber(value: string | null | undefined): string | null {
if (!value) return null
// A legacy plaintext row (written before the 2026-07-15 encryption change,
// or on a self-hosted DB) is already the answer.
if (/^(\d{6}|\d{8})[-+]?\d{4}$/.test(value)) return value
return decryptPersonnummer(value)
}
export function maskCustomerRow<T extends { personal_number?: string | null }>(row: T): T {
return {
...row,
personal_number: maskStoredCustomerPersonalNumber(row.personal_number),
}
}
/**
* Mask the personnummer on a row that EMBEDS a customer, e.g. the
* `customer:customers(*)` join every invoice endpoint selects.
*
* Those embeds carry the raw ciphertext out to the browser. Nothing renders
* it, so it produced no visible bug the way the customer list did, but it is
* the same value crossing the same wire for no reason. Masking at the response
* boundary rather than narrowing the projection keeps every other customer
* field available to the server-side consumers (PDF rendering, invoice email,
* ROT/RUT validation) that legitimately read the whole row.
*
* Null-safe on both the row and the embed: PostgREST returns `customer: null`
* for an invoice whose customer was removed.
*/
export function maskEmbeddedCustomer<T>(row: T): T {
if (!row || typeof row !== 'object') return row
const embedded = (row as { customer?: { personal_number?: string | null } | null }).customer
if (!embedded || typeof embedded !== 'object') return row
return { ...row, customer: maskCustomerRow(embedded) }
}
+31
View File
@@ -153,6 +153,21 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
resource: 'Accounted://chart-of-accounts',
},
},
// Distinct from a plain duplicate: the account number is taken by a row the
// company deactivated. Creating it again can never succeed (the unique
// constraint counts inactive rows), so the only way forward is reactivation.
// Callers key on this code to offer that instead of a dead-end 409.
ACCOUNT_EXISTS_INACTIVE: {
httpStatus: 409,
message_sv: 'Kontot finns redan i din kontoplan men är inaktiverat.',
message_en:
'The account number already exists in this company chart of accounts but is deactivated.',
remediation: {
description:
'Reactivate the existing account instead of creating it: POST /api/bookkeeping/accounts/activate with { account_numbers: [number] }.',
resource: 'Accounted://chart-of-accounts',
},
},
JOURNAL_ENTRY_NOT_BALANCED: {
httpStatus: 400,
message_sv: 'Verifikationen balanserar inte.',
@@ -1943,6 +1958,22 @@ const CUSTOMER: Record<string, StructuredErrorEntry> = {
message_sv: 'Kunden har fakturor och kan inte tas bort.',
message_en: 'Customer cannot be deleted while invoices reference it.',
},
CUSTOMER_NO_PERSONAL_NUMBER: {
httpStatus: 404,
message_sv: 'Kunden har inget sparat personnummer.',
message_en: 'No personal number is stored for this customer.',
},
// The stored ciphertext could not be decrypted (written under a different
// PERSONNUMMER_ENCRYPTION_KEY, or corrupted). Deliberately not an
// INTERNAL_ERROR: it is not transient, retrying never helps, and the user
// can fix it in one step by typing the personnummer in again.
CUSTOMER_PERSONAL_NUMBER_UNREADABLE: {
httpStatus: 422,
message_sv:
'Det sparade personnumret kan inte läsas. Skriv in det igen för att ersätta det.',
message_en:
'The stored personal number cannot be read. Enter it again to replace it.',
},
}
const ARTICLE: Record<string, StructuredErrorEntry> = {
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest'
import {
MATCHABLE_INVOICE_STATUSES,
MATCHABLE_SUPPLIER_INVOICE_STATUSES,
isMatchableInvoice,
isMatchableSupplierInvoice,
} from '../matchable-statuses'
// These lists must stay in lockstep with the CAS guards in
// app/api/transactions/[id]/match-invoice/route.ts and
// .../match-supplier-invoice/route.ts. A surface offering a target the route
// rejects produces a confirm button that can only fail.
describe('matchable status lists', () => {
it('mirrors the customer-invoice route guard', () => {
expect([...MATCHABLE_INVOICE_STATUSES]).toEqual(['sent', 'overdue', 'partially_paid'])
})
it('mirrors the supplier-invoice route guard', () => {
expect([...MATCHABLE_SUPPLIER_INVOICE_STATUSES]).toEqual([
'registered',
'approved',
'overdue',
'partially_paid',
])
})
it('never treats a settled state as matchable', () => {
for (const settled of ['paid', 'credited', 'cancelled', 'draft']) {
expect([...MATCHABLE_INVOICE_STATUSES]).not.toContain(settled)
expect([...MATCHABLE_SUPPLIER_INVOICE_STATUSES]).not.toContain(settled)
}
})
})
describe('isMatchableInvoice', () => {
it('accepts an open invoice with an outstanding balance', () => {
expect(isMatchableInvoice({ status: 'sent', remaining_amount: 1250 })).toBe(true)
expect(isMatchableInvoice({ status: 'partially_paid', remaining_amount: 20 })).toBe(true)
expect(isMatchableInvoice({ status: 'overdue', remaining_amount: 1 })).toBe(true)
})
// The reported bug: an invoice settled by a different transaction keeps
// status 'paid' / remaining 0, and the dialog measured the bank amount
// against that 0 and called it a partial payment.
it('rejects a fully paid invoice', () => {
expect(isMatchableInvoice({ status: 'paid', remaining_amount: 0 })).toBe(false)
})
it('rejects an open status whose balance is already zero', () => {
expect(isMatchableInvoice({ status: 'sent', remaining_amount: 0 })).toBe(false)
})
it('rejects non-payable statuses', () => {
expect(isMatchableInvoice({ status: 'draft', remaining_amount: 500 })).toBe(false)
expect(isMatchableInvoice({ status: 'cancelled', remaining_amount: 500 })).toBe(false)
})
it('rejects a missing or malformed candidate rather than assuming matchable', () => {
expect(isMatchableInvoice(null)).toBe(false)
expect(isMatchableInvoice(undefined)).toBe(false)
expect(isMatchableInvoice({})).toBe(false)
expect(isMatchableInvoice({ status: 'sent' })).toBe(false)
expect(isMatchableInvoice({ status: 'sent', remaining_amount: null })).toBe(false)
})
})
describe('isMatchableSupplierInvoice', () => {
it('accepts the open supplier states', () => {
expect(isMatchableSupplierInvoice({ status: 'registered', remaining_amount: 549 })).toBe(true)
expect(isMatchableSupplierInvoice({ status: 'approved', remaining_amount: 549 })).toBe(true)
expect(isMatchableSupplierInvoice({ status: 'overdue', remaining_amount: 549 })).toBe(true)
expect(isMatchableSupplierInvoice({ status: 'partially_paid', remaining_amount: 49 })).toBe(true)
})
it('rejects paid and credited targets, matching MATCH_SI_ALREADY_PAID', () => {
expect(isMatchableSupplierInvoice({ status: 'paid', remaining_amount: 0 })).toBe(false)
expect(isMatchableSupplierInvoice({ status: 'credited', remaining_amount: 0 })).toBe(false)
})
it('rejects a zero balance even on an open status', () => {
expect(isMatchableSupplierInvoice({ status: 'registered', remaining_amount: 0 })).toBe(false)
})
it('rejects a missing candidate', () => {
expect(isMatchableSupplierInvoice(null)).toBe(false)
expect(isMatchableSupplierInvoice({})).toBe(false)
})
})
+48
View File
@@ -0,0 +1,48 @@
/**
* Invoice statuses a bank transaction can still be matched against.
*
* These mirror the CAS guards the match routes actually enforce:
* app/api/transactions/[id]/match-invoice/route.ts (.in('status', ...))
* app/api/transactions/[id]/match-supplier-invoice/route.ts (.in('status', ...))
*
* Every surface that offers a match (suggestion lists, the match dialog, the
* batch-allocation picker) must filter on the same lists. Offering a target
* outside them produces a confirm button that can only ever fail with
* MATCH_INVOICE_ALREADY_PAID / MATCH_SI_ALREADY_PAID.
*
* Dependency-free on purpose: client components import this too.
*/
export const MATCHABLE_INVOICE_STATUSES = ['sent', 'overdue', 'partially_paid'] as const
export const MATCHABLE_SUPPLIER_INVOICE_STATUSES = [
'registered',
'approved',
'overdue',
'partially_paid',
] as const
/**
* A candidate is matchable when its status is still open AND it has an
* outstanding balance. Both columns are NOT NULL in the schema (migrations
* 20240101000025 / 20260323120001), so a missing value cannot silently hide a
* legitimate suggestion here.
*/
export function isMatchableInvoice(
candidate: { status?: string | null; remaining_amount?: number | null } | null | undefined,
): boolean {
if (!candidate?.status) return false
return (
(MATCHABLE_INVOICE_STATUSES as readonly string[]).includes(candidate.status) &&
(candidate.remaining_amount ?? 0) > 0
)
}
export function isMatchableSupplierInvoice(
candidate: { status?: string | null; remaining_amount?: number | null } | null | undefined,
): boolean {
if (!candidate?.status) return false
return (
(MATCHABLE_SUPPLIER_INVOICE_STATUSES as readonly string[]).includes(candidate.status) &&
(candidate.remaining_amount ?? 0) > 0
)
}
@@ -9,18 +9,28 @@ vi.mock('../imbalance-diagnosis', () => ({
buildImbalanceDiagnosis: vi.fn(),
}))
// Mocked rather than fed through the queued Supabase stub: the queue resolves
// in strict call order, so an extra real query inside the engine would shift
// every enqueued response in this file.
vi.mock('../latest-vouchers', () => ({
getLatestPostedVouchers: vi.fn(),
}))
import { generateBalansrapport } from '../balansrapport'
import { generateTrialBalance } from '../trial-balance'
import { findUntransferredResults, buildImbalanceDiagnosis } from '../imbalance-diagnosis'
import { getLatestPostedVouchers } from '../latest-vouchers'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
const mockFindUntransferred = vi.mocked(findUntransferredResults)
const mockBuildDiagnosis = vi.mocked(buildImbalanceDiagnosis)
const mockLatestVouchers = vi.mocked(getLatestPostedVouchers)
beforeEach(() => {
vi.clearAllMocks()
mockLatestVouchers.mockResolvedValue([])
})
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
@@ -410,4 +420,77 @@ describe('generateBalansrapport', () => {
expect(report.beraknat_resultat).toBe(0)
expect(report.is_balanced).toBe(true)
})
describe('latest_vouchers header line', () => {
function enqueuePeriod(q: ReturnType<typeof createQueuedMockSupabase>) {
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31' },
error: null,
})
mockTrialBalance.mockResolvedValueOnce(tb([]))
}
it('carries the last posted voucher per series', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
mockLatestVouchers.mockResolvedValueOnce([
{ series: 'A', last_number: 214 },
{ series: 'B', last_number: 37 },
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-1')
expect(report.latest_vouchers).toEqual([
{ series: 'A', last_number: 214 },
{ series: 'B', last_number: 37 },
])
})
it('omits the field entirely when the period has no vouchers', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
mockLatestVouchers.mockResolvedValueOnce([])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-1')
expect('latest_vouchers' in report).toBe(false)
})
it('still returns the report when the lookup fails', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
mockLatestVouchers.mockRejectedValueOnce(new Error('boom'))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-1')
expect(report.latest_vouchers).toBeUndefined()
expect(report.is_balanced).toBe(true)
})
it('bounds the window at the as-of date with no lower bound (accumulating report)', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
await generateBalansrapport(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ fromDate: '2026-02-01', toDate: '2026-03-31' }
)
// fromDate narrows the report's own period label, but a balansrapport
// accumulates from the fiscal-year start, so the voucher window must not
// inherit that lower bound.
expect(mockLatestVouchers).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'period-1',
{ toDate: '2026-03-31' }
)
})
})
})
@@ -0,0 +1,187 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getLatestPostedVouchers } from '../latest-vouchers'
import { formatLatestVouchers, LATEST_VOUCHERS_LABEL } from '../latest-vouchers-format'
import { createQueuedMockSupabase } from '@/tests/helpers'
beforeEach(() => {
vi.clearAllMocks()
})
interface Row {
id: string
voucher_series: string | null
voucher_number: number
}
function row(id: string, series: string | null, number: number): Row {
return { id, voucher_series: series, voucher_number: number }
}
describe('getLatestPostedVouchers', () => {
it('returns the highest number per series, sorted by series', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: [
row('e1', 'B', 12),
row('e2', 'A', 214),
row('e3', 'A', 7),
row('e4', 'B', 37),
row('e5', 'A', 100),
],
error: null,
})
const result = await getLatestPostedVouchers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ toDate: '2026-12-31' }
)
expect(result).toEqual([
{ series: 'A', last_number: 214 },
{ series: 'B', last_number: 37 },
])
})
it('returns an empty array when the window holds no posted vouchers', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: [], error: null })
const result = await getLatestPostedVouchers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ toDate: '2026-12-31' }
)
expect(result).toEqual([])
})
it('excludes drafts and cancelled entries, which is what voucher_number > 0 buys', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: [row('e1', 'A', 5)], error: null })
await getLatestPostedVouchers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ toDate: '2026-12-31' }
)
// Drafts and cancelled entries both carry voucher_number = 0.
expect(q.findCall('journal_entries', 'gt')).toEqual(['voucher_number', 0])
// Reversed entries keep their number and their slot in the series, so they
// count: excluding them would report a gap that is not there.
const statusFilter = q.findCall('journal_entries', 'in')
expect(statusFilter?.[0]).toBe('status')
expect(statusFilter?.[1]).toEqual(['posted', 'reversed'])
})
it('bounds the window by toDate only when no fromDate is given (balansrapport)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: [row('e1', 'A', 5)], error: null })
await getLatestPostedVouchers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ toDate: '2026-03-31' }
)
expect(q.findCall('journal_entries', 'lte')).toEqual(['entry_date', '2026-03-31'])
// The fiscal period filter is the lower bound; no explicit gte.
expect(q.findCall('journal_entries', 'gte')).toBeUndefined()
expect(q.findCall('journal_entries', 'eq')).toEqual(['company_id', 'company-1'])
})
it('bounds both ends when fromDate is given (resultatrapport)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: [row('e1', 'A', 5)], error: null })
await getLatestPostedVouchers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ fromDate: '2026-01-01', toDate: '2026-03-31' }
)
expect(q.findCall('journal_entries', 'gte')).toEqual(['entry_date', '2026-01-01'])
expect(q.findCall('journal_entries', 'lte')).toEqual(['entry_date', '2026-03-31'])
})
it('orders on the primary key so paging cannot skip or duplicate rows', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: [row('e1', 'A', 5)], error: null })
await getLatestPostedVouchers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ toDate: '2026-12-31' }
)
expect(q.findCall('journal_entries', 'order')).toEqual(['id', { ascending: true }])
})
it('falls back to series A for entries with a null voucher_series', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: [row('e1', null, 9), row('e2', 'A', 4)], error: null })
const result = await getLatestPostedVouchers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ toDate: '2026-12-31' }
)
expect(result).toEqual([{ series: 'A', last_number: 9 }])
})
it('propagates a query error so the caller can decide (engines swallow it)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: null, error: { message: 'boom' } })
await expect(
getLatestPostedVouchers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ toDate: '2026-12-31' }
)
).rejects.toThrow('boom')
})
})
describe('formatLatestVouchers', () => {
it('joins series and number with a comma', () => {
expect(
formatLatestVouchers([
{ series: 'A', last_number: 214 },
{ series: 'B', last_number: 37 },
])
).toBe('A 214, B 37')
})
it('returns null for an empty list so surfaces drop the line entirely', () => {
expect(formatLatestVouchers([])).toBeNull()
})
it('returns null for undefined', () => {
expect(formatLatestVouchers(undefined)).toBeNull()
})
it('names the number as posted, not allocated', () => {
// The distinction is the whole point: an allocated number can sit ahead of
// the books, and a reconciler told the wrong one chases a phantom gap.
expect(LATEST_VOUCHERS_LABEL).toBe('Senaste bokförda verifikat')
})
})
@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest'
import { renderToBuffer } from '@react-pdf/renderer'
import { BalansrapportPDF, ResultatrapportPDF } from '../operational-report-pdf-template'
import type { BalansrapportReport, CompanySettings, ResultatrapportReport } from '@/types'
// Real @react-pdf/renderer layout is CPU-heavy; under a fully parallel
// test run these can exceed the 5s default on a saturated machine.
const RENDER_TIMEOUT = 30_000
function fakeCompany(): CompanySettings {
return {
company_name: 'Testbolaget AB',
org_number: '5566778899',
vat_number: 'SE556677889901',
entity_type: 'aktiebolag',
} as unknown as CompanySettings
}
function balansrapport(overrides: Partial<BalansrapportReport> = {}): BalansrapportReport {
return {
groups: [
{
class: 1,
class_label: '1 Tillgångar',
rows: [
{ account_number: '1930', account_name: 'Företagskonto', ib: 50000, ub: 75000, period_change: 25000 },
],
subtotal_ib: 50000,
subtotal_ub: 75000,
},
],
total_assets_ub: 75000,
total_equity_liabilities_ub: -75000,
beraknat_resultat: 0,
is_balanced: true,
period: { start: '2026-01-01', end: '2026-12-31' },
...overrides,
}
}
function resultatrapport(overrides: Partial<ResultatrapportReport> = {}): ResultatrapportReport {
return {
groups: [
{
class: 3,
class_label: '3 Rörelsens inkomster/intäkter',
rows: [
{ account_number: '3001', account_name: 'Försäljning 25%', current_period: 100000, prior_period: 80000 },
],
subtotal_current: 100000,
subtotal_prior: 80000,
},
],
net_result_current: 100000,
net_result_prior: 80000,
period: { start: '2026-01-01', end: '2026-12-31' },
prior_period: { start: '2025-01-01', end: '2025-12-31' },
...overrides,
}
}
describe('operational report PDFs', () => {
it(
'renders the balansrapport with the latest-voucher header line',
async () => {
const buffer = await renderToBuffer(
BalansrapportPDF({
report: balansrapport({
latest_vouchers: [
{ series: 'A', last_number: 214 },
{ series: 'B', last_number: 37 },
],
}),
company: fakeCompany(),
generatedAt: '2026-07-28T10:00:00.000Z',
})
)
expect(buffer).toBeInstanceOf(Buffer)
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
expect(buffer.length).toBeGreaterThan(1000)
},
RENDER_TIMEOUT
)
it(
'renders the balansrapport without the line when no vouchers exist',
async () => {
const buffer = await renderToBuffer(
BalansrapportPDF({
report: balansrapport(),
company: fakeCompany(),
generatedAt: '2026-07-28T10:00:00.000Z',
})
)
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
},
RENDER_TIMEOUT
)
it(
'renders the resultatrapport with both the voucher line and a filter note',
async () => {
const buffer = await renderToBuffer(
ResultatrapportPDF({
report: resultatrapport({
latest_vouchers: [{ series: 'A', last_number: 88 }],
}),
company: fakeCompany(),
generatedAt: '2026-07-28T10:00:00.000Z',
filterNote: 'Filtrerad (dimension 6: P001), ej fullständig rapport',
})
)
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
expect(buffer.length).toBeGreaterThan(1000)
},
RENDER_TIMEOUT
)
})
@@ -4,15 +4,25 @@ vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
// Mocked rather than fed through the queued Supabase stub: the queue resolves
// in strict call order, so an extra real query inside the engine would shift
// every enqueued response in this file.
vi.mock('../latest-vouchers', () => ({
getLatestPostedVouchers: vi.fn(),
}))
import { generateResultatrapport, shiftDateOneYearBack } from '../resultatrapport'
import { generateTrialBalance } from '../trial-balance'
import { getLatestPostedVouchers } from '../latest-vouchers'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
const mockLatestVouchers = vi.mocked(getLatestPostedVouchers)
beforeEach(() => {
vi.clearAllMocks()
mockLatestVouchers.mockResolvedValue([])
})
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
@@ -450,6 +460,94 @@ describe('generateResultatrapport', () => {
expect(report.groups[0].rows[0].current_period).toBe(40000)
expect(report.net_result_current).toBe(40000)
})
describe('latest_vouchers header line', () => {
function enqueuePeriod(q: ReturnType<typeof createQueuedMockSupabase>) {
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
error: null,
})
mockTrialBalance.mockResolvedValueOnce(tb([]))
}
it('carries the last posted voucher per series', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
mockLatestVouchers.mockResolvedValueOnce([
{ series: 'A', last_number: 214 },
{ series: 'B', last_number: 37 },
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
expect(report.latest_vouchers).toEqual([
{ series: 'A', last_number: 214 },
{ series: 'B', last_number: 37 },
])
})
it('omits the field entirely when the period has no vouchers', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
mockLatestVouchers.mockResolvedValueOnce([])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
expect('latest_vouchers' in report).toBe(false)
})
it('still returns the report when the lookup fails', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
mockLatestVouchers.mockRejectedValueOnce(new Error('boom'))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
expect(report.latest_vouchers).toBeUndefined()
expect(report.net_result_current).toBe(0)
})
it('scopes the window to the reported date range', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
await generateResultatrapport(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ fromDate: '2026-01-01', toDate: '2026-03-31' }
)
expect(mockLatestVouchers).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'period-1',
{ fromDate: '2026-01-01', toDate: '2026-03-31' }
)
})
it('skips the lookup entirely on a dimension-filtered report', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
const report = await generateResultatrapport(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ dimensions: { '6': 'P001' } }
)
// The report already discloses that it is partial; an unfiltered voucher
// range next to a filtered result would invite the wrong conclusion.
expect(mockLatestVouchers).not.toHaveBeenCalled()
expect(report.latest_vouchers).toBeUndefined()
})
})
})
describe('shiftDateOneYearBack', () => {
+251 -4
View File
@@ -7,6 +7,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
/**
* The company's own class 3 accounts carrying a "Standard moms", as
* fetchDynamicRuta05Accounts reads them. Answered off a table-routed builder
* rather than the sequential queue: every calculateVatDeclaration test would
* otherwise have to seed one, and a missing seed would silently hand the chart
* query the ledger result.
*/
let chartAccounts: Array<{ account_number: string; default_vat_rate: number | null }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'neq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'order', 'range', 'limit']) {
@@ -18,9 +27,31 @@ function makeBuilder() {
return b
}
/**
* chart_of_accounts builder. Applies the same filters the real query relies on
* (account_class = 3, default_vat_rate in the taxable sats) so a fixture can
* assert that a rate-less or non-revenue konto never reaches ruta 05.
*/
function makeChartBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'not', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.then = (resolve: (v: unknown) => void) =>
resolve({
data: chartAccounts.filter(
(a) => a.default_vat_rate != null && [0.25, 0.12, 0.06].includes(a.default_vat_rate)
),
error: null,
})
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
from: vi.fn().mockImplementation((table: string) =>
table === 'chart_of_accounts' ? makeChartBuilder() : makeBuilder()
),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
@@ -76,6 +107,7 @@ beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
chartAccounts = []
supabase = makeClient()
})
@@ -896,6 +928,220 @@ describe('calculateVatDeclaration: parent/summary accounts', () => {
})
})
// ============================================================
// #1261: the company's OWN revenue accounts reach ruta 05.
//
// ACCOUNT_RUTA maps 3000-3003 only, and Accounted's BAS chart ships no
// varugrupp accounts at all, so a company selling on 3013 had that revenue
// dropped from the declaration entirely: the map's keys ARE the account filter
// sent to the aggregation RPC. Membership now comes from the konto's own
// "Standard moms" (chart_of_accounts.default_vat_rate).
// ============================================================
describe('calculateVatDeclaration: company-specific ruta 05 accounts', () => {
it('includes a user-added revenue account carrying a moms-sats', async () => {
chartAccounts = [{ account_number: '3013', default_vat_rate: 0.06 }]
seedLedger([
{ account_number: '3013', debit_amount: 0, credit_amount: 8000 },
{ account_number: '2631', debit_amount: 0, credit_amount: 480 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(8000)
expect(result.rutor.ruta12).toBe(480)
})
it('books the account into the base bucket of its own sats', async () => {
// Without this the ruta 05 total would have no matching base25/12/6, and
// the proportional SALES_OUTPUT_VAT_SHORTFALL check reads an unaccounted
// base as missing utgående moms.
chartAccounts = [
{ account_number: '3011', default_vat_rate: 0.25 },
{ account_number: '3013', default_vat_rate: 0.06 },
]
seedLedger([
{ account_number: '3011', debit_amount: 0, credit_amount: 4000 },
{ account_number: '3013', debit_amount: 0, credit_amount: 1000 },
{ account_number: '3001', debit_amount: 0, credit_amount: 2000 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(7000)
expect(result.breakdown.invoices.base25).toBe(6000) // 3001 + 3011
expect(result.breakdown.invoices.base6).toBe(1000) // 3013
expect(result.breakdown.invoices.base12).toBe(0)
})
it('counts a 3000 gruppkonto balance in ruta 05 exactly once', async () => {
// 3000 is already summed into ruta 05 by the static map. Surfacing its
// "Standard moms" must not also add it to the dynamic account list, which
// would double the filed figure.
chartAccounts = [{ account_number: '3000', default_vat_rate: 0.25 }]
seedLedger([
{ account_number: '3000', debit_amount: 0, credit_amount: 5000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 1250 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(5000)
expect(result.rutor.ruta10).toBe(1250)
})
it('books a rated 3000 into its base bucket so the split adds up to ruta 05', async () => {
chartAccounts = [{ account_number: '3000', default_vat_rate: 0.25 }]
seedLedger([
{ account_number: '3000', debit_amount: 0, credit_amount: 5000 },
{ account_number: '3001', debit_amount: 0, credit_amount: 2000 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(7000)
expect(result.breakdown.invoices.base25).toBe(7000) // 3001 + 3000
expect(result.breakdown.invoices.base12).toBe(0)
expect(result.breakdown.invoices.base6).toBe(0)
})
it('leaves 3000 in ruta 05 with no base bucket when no sats is set', async () => {
// The filed figure is unaffected: only the breakdown is incomplete, and
// the checks derive their expected base from the output-VAT rutor.
chartAccounts = []
seedLedger([{ account_number: '3000', debit_amount: 0, credit_amount: 5000 }])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(5000)
expect(result.breakdown.invoices.base25).toBe(0)
})
it('nets a credit note booked as a debit on the account', async () => {
chartAccounts = [{ account_number: '3013', default_vat_rate: 0.06 }]
seedLedger([
{ account_number: '3013', debit_amount: 0, credit_amount: 8000 },
{ account_number: '3013', debit_amount: 1000, credit_amount: 0 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(7000)
expect(result.breakdown.invoices.base6).toBe(7000)
})
it('does not double-count an account the static map already owns', async () => {
// The BAS backfill sets 3001 = 25 %, so it comes back from the chart query
// too. Counting it in both projections would double ruta 05.
chartAccounts = [{ account_number: '3001', default_vat_rate: 0.25 }]
seedLedger([
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(10000)
expect(result.breakdown.invoices.base25).toBe(10000)
})
it('leaves accounts that belong to another ruta out of ruta 05', async () => {
// VMB (3211) is ruta 07 and 3231 is ruta 41. Neither is mappable yet, so
// they stay out of the declaration: filing an amount in the wrong box is
// worse than omitting it.
chartAccounts = [
{ account_number: '3211', default_vat_rate: 0.25 },
{ account_number: '3231', default_vat_rate: 0.25 },
]
seedLedger([
{ account_number: '3211', debit_amount: 0, credit_amount: 5000 },
{ account_number: '3231', debit_amount: 0, credit_amount: 3000 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
})
it('keeps an account the ACCOUNT_TO_BOX mirror already maps in its own ruta', async () => {
// 3108 is momsfri EU-leverans (ruta 35). A user who sets a sats on it must
// not move it to ruta 05.
chartAccounts = [{ account_number: '3108', default_vat_rate: 0.25 }]
seedLedger([{ account_number: '3108', debit_amount: 0, credit_amount: 4000 }])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta35).toBe(4000)
expect(result.rutor.ruta05).toBe(0)
})
it('routes momspliktig EU-försäljning (3106) to ruta 05', async () => {
// 3106 carries Swedish moms (buyer not VAT-registered), so it is ordinary
// momspliktig försäljning. Neither ACCOUNT_RUTA nor the mirror maps it; the
// MCP report has widened ruta 05 with it by hand for the same reason.
chartAccounts = [{ account_number: '3106', default_vat_rate: 0.25 }]
seedLedger([
{ account_number: '3106', debit_amount: 0, credit_amount: 2000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 500 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(2000)
expect(result.breakdown.invoices.base25).toBe(2000)
})
it('ignores revenue accounts with no sats or an explicit 0 %', async () => {
// "Ingen standard" and "Ingen moms" both mean the konto is not declared
// momspliktig: momsfri revenue belongs in ruta 42, not 05.
chartAccounts = [
{ account_number: '3013', default_vat_rate: null },
{ account_number: '3014', default_vat_rate: 0 },
]
seedLedger([
{ account_number: '3013', debit_amount: 0, credit_amount: 8000 },
{ account_number: '3014', debit_amount: 0, credit_amount: 2000 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
})
it('adds the accounts to p_accounts but never to p_ruta_accounts', async () => {
// p_ruta_accounts is the settlement SHAPE detector inside the RPC: an entry
// touching it plus 2650/1650 is classified a momsredovisning and dropped
// from the totals. A plain sale booked 1930 / 3013 / 2650 would then vanish
// from its own declaration. This is invisible to an outcome assertion, so
// assert the RPC arguments directly.
chartAccounts = [{ account_number: '3013', default_vat_rate: 0.06 }]
seedLedger([{ account_number: '3013', debit_amount: 0, credit_amount: 8000 }])
await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const [, args] = supabase.rpc.mock.calls[0]
expect(args.p_accounts).toContain('3013')
expect(args.p_ruta_accounts).not.toContain('3013')
expect(args.p_ruta_accounts).toContain('3001') // static list still intact
})
it('clears the blocking OUTPUT_VAT_WITHOUT_SALES finding (#1261)', async () => {
// The reported symptom was not just an understated ruta 05: with ruta05 = 0
// and output VAT on 2611, runVatDeclarationChecks failed the declaration
// with a blocking ERROR and the user could not file at all.
chartAccounts = [{ account_number: '3013', default_vat_rate: 0.06 }]
seedLedger([
{ account_number: '3013', debit_amount: 0, credit_amount: 8000 },
{ account_number: '2631', debit_amount: 0, credit_amount: 480 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const findings = runVatDeclarationChecks(result.rutor)
expect(findings.map((f) => f.code)).not.toContain('OUTPUT_VAT_WITHOUT_SALES')
expect(findings.map((f) => f.code)).not.toContain('SALES_OUTPUT_VAT_SHORTFALL')
})
})
describe('calculateVatDeclaration: annual VAT spans the räkenskapsår', () => {
it('uses the fiscal period bounds for yearly when a fiscalPeriodId is given', async () => {
// Förlängt räkenskapsår (extended first year, 18 months): annual VAT
@@ -977,9 +1223,10 @@ describe('calculateVatDeclaration: annual VAT spans the räkenskapsår', () => {
expect(result.period.start).toBe('2026-03-01')
expect(result.period.end).toBe('2026-03-31')
// The räkenskapsår path is yearly-only: monthly makes no table query at
// all, just the single totals RPC.
expect(supabase.from).not.toHaveBeenCalled()
// The räkenskapsår path is yearly-only: monthly never touches
// fiscal_periods. (chart_of_accounts is read on every period type, for the
// company's own ruta 05 accounts.)
expect(supabase.from).not.toHaveBeenCalledWith('fiscal_periods')
expect(supabase.rpc).toHaveBeenCalledTimes(1)
})
})
+16
View File
@@ -1,11 +1,13 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from './trial-balance'
import { findUntransferredResults, buildImbalanceDiagnosis } from './imbalance-diagnosis'
import { getLatestPostedVouchers } from './latest-vouchers'
import type {
BalanceImbalanceDiagnosis,
BalansrapportReport,
BalansrapportRow,
BalansrapportGroup,
LatestVoucherPerSeries,
} from '@/types'
const CLASS_LABELS: Record<number, string> = {
@@ -115,6 +117,19 @@ export async function generateBalansrapport(
}
}
// Reconciliation aid for the header: which vouchers are actually in here.
// The balansrapport accumulates from the fiscal year start, so the window has
// no lower bound beyond fiscal_period_id even when the user narrows fromDate.
// Best-effort: a header nicety never breaks the report.
let latestVouchers: LatestVoucherPerSeries[] = []
try {
latestVouchers = await getLatestPostedVouchers(supabase, companyId, fiscalPeriodId, {
toDate: effectiveToDate,
})
} catch {
// Best-effort header line only.
}
return {
groups,
total_assets_ub: totalAssetsUb,
@@ -123,6 +138,7 @@ export async function generateBalansrapport(
is_balanced: trialBalance.isBalanced,
period: { start: effectiveFromDate, end: effectiveToDate },
...(imbalanceDiagnosis ? { imbalance_diagnosis: imbalanceDiagnosis } : {}),
...(latestVouchers.length > 0 ? { latest_vouchers: latestVouchers } : {}),
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { LatestVoucherPerSeries } from '@/types'
/**
* Rendering half of the "senaste bokförda verifikat" header line (#1267).
*
* Kept dependency-free and separate from `latest-vouchers.ts` so the client
* report views can import it without dragging the Supabase query path (and
* through it the logger and observability sink) into the browser bundle.
*/
/**
* Swedish-only report surface (see .claude/rules/i18n.md).
*
* The wording is load-bearing: this is the last POSTED number, not the last
* number the sequence counter handed out. A reconciler who assumes the other
* one chases a gap that is not there.
*/
export const LATEST_VOUCHERS_LABEL = 'Senaste bokförda verifikat'
/**
* Renders the header value, e.g. "A 214, B 37".
*
* Returns null when there is nothing to show, so every surface drops the line
* entirely rather than printing an empty label.
*/
export function formatLatestVouchers(entries: LatestVoucherPerSeries[] | undefined): string | null {
if (!entries || entries.length === 0) return null
return entries.map((e) => `${e.series} ${e.last_number}`).join(', ')
}
+78
View File
@@ -0,0 +1,78 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { LatestVoucherPerSeries } from '@/types'
/**
* Statuses that count as "bokförd" for the purposes of this line.
*
* `reversed` is included deliberately: a stornerad verifikat keeps its number
* and still occupies its slot in the series, so leaving it out would report a
* gap that does not exist. Drafts and cancelled entries carry
* `voucher_number = 0` and are excluded by the `> 0` filter instead.
*/
const POSTED_STATUSES = ['posted', 'reversed'] as const
interface VoucherRow {
id: string
voucher_series: string | null
voucher_number: number
}
/**
* Highest POSTED voucher number per series within a reported window.
*
* Reads `journal_entries`, never `voucher_sequences`. The sequence table holds
* an allocation high-water mark that drifts from reality in both directions:
* `next_voucher_number` burns a number when the follow-up insert fails,
* `delete_last_voucher` decrements blindly by one rather than resetting to the
* new MAX, and pre-RPC SIE imports left it behind. Since the whole point of
* this figure is reconciliation, only a number the user can actually look up in
* the books is worth printing.
*
* The window is bounded below by `fiscalPeriodId` alone when `fromDate` is
* omitted, which is what an accumulating report (balansrapport) wants.
*/
export async function getLatestPostedVouchers(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
window: { fromDate?: string; toDate: string }
): Promise<LatestVoucherPerSeries[]> {
const rows = await fetchAllRows<VoucherRow>(
({ from, to }) => {
let query = supabase
.from('journal_entries')
.select('id, voucher_series, voucher_number')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', POSTED_STATUSES)
.gt('voucher_number', 0)
.lte('entry_date', window.toDate)
if (window.fromDate) {
query = query.gte('entry_date', window.fromDate)
}
// Order on the PK: paging is only stable with a unique total order.
return query.order('id', { ascending: true }).range(from, to)
},
{ dedupeBy: (r) => r.id }
)
const maxBySeries = new Map<string, number>()
for (const row of rows) {
const series = row.voucher_series || 'A'
const current = maxBySeries.get(series) ?? 0
if (row.voucher_number > current) {
maxBySeries.set(series, row.voucher_number)
}
}
return [...maxBySeries.entries()]
.map(([series, last_number]) => ({ series, last_number }))
.sort((a, b) => a.series.localeCompare(b.series, 'sv'))
}
// Re-exported for convenience on server surfaces; the canonical home is the
// dependency-free format module, which client components import directly.
export { formatLatestVouchers, LATEST_VOUCHERS_LABEL } from './latest-vouchers-format'
@@ -7,9 +7,11 @@ import {
} from '@react-pdf/renderer'
import type {
CompanySettings,
LatestVoucherPerSeries,
ResultatrapportReport,
BalansrapportReport,
} from '@/types'
import { formatLatestVouchers, LATEST_VOUCHERS_LABEL } from './latest-vouchers'
// Operational reports (Resultatrapport / Balansrapport) are löpande
// bookkeeping documents, not draft årsredovisning per ÅRL 2:7 §, so this
@@ -254,13 +256,16 @@ interface CommonHeaderProps {
period: { start: string; end: string }
/** Partial-view disclosure (dimension-filtered exports, BFNAR 2013:2). */
filterNote?: string
/** Highest posted voucher per series in the window. Reconciliation aid (#1267). */
latestVouchers?: LatestVoucherPerSeries[]
}
function HeaderBlock({ title, company, period, filterNote }: CommonHeaderProps) {
function HeaderBlock({ title, company, period, filterNote, latestVouchers }: CommonHeaderProps) {
const companyDisplayName = company.company_name || ''
const periodLabel = period.start && period.end
? `${formatDateSv(period.start)}: ${formatDateSv(period.end)}`
: ''
const vouchersLabel = formatLatestVouchers(latestVouchers)
return (
<View style={styles.header} fixed>
<View style={styles.titleBlock}>
@@ -271,6 +276,9 @@ function HeaderBlock({ title, company, period, filterNote }: CommonHeaderProps)
{periodLabel && (
<Text style={styles.period}>Period: {periodLabel}</Text>
)}
{vouchersLabel && (
<Text style={styles.period}>{LATEST_VOUCHERS_LABEL}: {vouchersLabel}</Text>
)}
{filterNote && (
<Text style={styles.period}>{filterNote}</Text>
)}
@@ -322,7 +330,13 @@ export function ResultatrapportPDF({ report, company, generatedAt, filterNote }:
return (
<Document>
<Page size="A4" style={styles.page}>
<HeaderBlock title="Resultatrapport" company={company} period={report.period} filterNote={filterNote} />
<HeaderBlock
title="Resultatrapport"
company={company}
period={report.period}
filterNote={filterNote}
latestVouchers={report.latest_vouchers}
/>
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderText, styles.colAccount]}>Konto</Text>
@@ -391,7 +405,12 @@ export function BalansrapportPDF({ report, company, generatedAt }: Balansrapport
return (
<Document>
<Page size="A4" style={styles.page}>
<HeaderBlock title="Balansrapport" company={company} period={report.period} />
<HeaderBlock
title="Balansrapport"
company={company}
period={report.period}
latestVouchers={report.latest_vouchers}
/>
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderText, styles.colAccount]}>Konto</Text>
+21
View File
@@ -1,6 +1,8 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from './trial-balance'
import { getLatestPostedVouchers } from './latest-vouchers'
import type {
LatestVoucherPerSeries,
ResultatrapportReport,
ResultatrapportRow,
ResultatrapportGroup,
@@ -157,12 +159,31 @@ export async function generateResultatrapport(
const netResultCurrent = sumNet(currentRows)
const netResultPrior = sumNet(priorRows)
// Reconciliation aid for the header: which vouchers are actually in here.
// Scoped to the reported window, so a Q1 report says something true about Q1.
// Dimension filter: skipped entirely. The report already discloses that it is
// partial, and an unfiltered voucher range next to a filtered result invites
// exactly the wrong conclusion during avstämning.
// Best-effort: a header nicety never breaks the report.
let latestVouchers: LatestVoucherPerSeries[] = []
if (!options?.dimensions) {
try {
latestVouchers = await getLatestPostedVouchers(supabase, companyId, fiscalPeriodId, {
fromDate: effectiveFromDate,
toDate: effectiveToDate,
})
} catch {
// Best-effort header line only.
}
}
return {
groups,
net_result_current: round2(netResultCurrent),
net_result_prior: round2(netResultPrior),
period: { start: effectiveFromDate, end: effectiveToDate },
prior_period: priorPeriodInfo,
...(latestVouchers.length > 0 ? { latest_vouchers: latestVouchers } : {}),
}
}
+73 -8
View File
@@ -5,6 +5,7 @@ import type {
VatPeriodType,
} from '@/types'
import type { VatCheckAccountTotals } from './vat-declaration-checks'
import { fetchDynamicRuta05Accounts } from './vat-revenue-accounts'
/**
* Calculate VAT declaration (Momsdeklaration) for a given period.
@@ -33,7 +34,10 @@ import type { VatCheckAccountTotals } from './vat-declaration-checks'
* Reverse charge output (2614/2624/2634) ruta 30/31/32 (credit)
* Import VAT (2615/2625/2635) ruta 60/61/62 (credit)
* Input VAT (2640-2649) ruta 48 (debit), incl. parent 2640
* Domestic taxable sales (3001-3003) ruta 05 (credit)
* Domestic taxable sales (3000-3003) ruta 05 (credit)
* The company's OWN class 3 accounts marked with a moms-sats join ruta 05 on
* top of this fixed list: see fetchDynamicRuta05Accounts (#1261). This map
* only covers the accounts Accounted itself seeds.
* Uttag (3401-3403) ruta 06 (credit)
* EU goods (3108) ruta 35; EU services (3308) ruta 39 (credit)
* Export (3105/3305) ruta 36/40; Exempt (3004/3100/3404/3994/3980) ruta 42 (credit)
@@ -85,6 +89,7 @@ export const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side
'2625': { box: 'ruta61', side: 'credit' }, // Import 12%
'2635': { box: 'ruta62', side: 'credit' }, // Import 6%
// Revenue: domestic taxable sales → ruta 05
'3000': { box: 'ruta05', side: 'credit' }, // Försäljning inom Sverige (summary/parent)
'3001': { box: 'ruta05', side: 'credit' },
'3002': { box: 'ruta05', side: 'credit' },
'3003': { box: 'ruta05', side: 'credit' },
@@ -345,18 +350,28 @@ export async function fetchVatAccountTotals(
supabase: SupabaseClient,
companyId: string,
start: string,
end: string
end: string,
dynamicRuta05Accounts: string[] = []
): Promise<VatAccountTotals> {
// Aggregation, settlement-shape detection, and source_type counts all
// happen in one SQL pass (get_vat_declaration_totals). The previous
// implementation paged every entry + line for the period through PostgREST
// and reduced in JS: dozens of round trips for a busy quarter. The account
// lists are parameters so ACCOUNT_RUTA stays the single source of truth.
//
// The company's own ruta 05 accounts join p_accounts (they must be summed)
// but deliberately NOT p_ruta_accounts. That second list is the settlement
// SHAPE detector: an entry with a line on it plus a line on 2650/1650 is
// classified a momsredovisning and dropped from the totals entirely. A plain
// sale booked 1930 / 3013 / 2650 (a company clearing moms straight off the
// revenue voucher) would then vanish from its own declaration. The fixed
// ACCOUNT_RUTA list is what defines settlement shape; user accounts widen
// what is measured, never what counts as a momsredovisning.
const { data, error } = await supabase.rpc('get_vat_declaration_totals', {
p_company_id: companyId,
p_start: start,
p_end: end,
p_accounts: [...VAT_ACCOUNTS, ...VAT_SETTLEMENT_NET_ACCOUNTS],
p_accounts: [...VAT_ACCOUNTS, ...VAT_SETTLEMENT_NET_ACCOUNTS, ...dynamicRuta05Accounts],
p_ruta_accounts: VAT_ACCOUNTS,
p_net_accounts: VAT_SETTLEMENT_NET_ACCOUNTS,
})
@@ -382,10 +397,16 @@ export async function fetchVatAccountTotals(
/**
* Map aggregated per-account totals to the momsdeklaration boxes, including
* the recomputed ruta 49 net (FK009). Pure projection over ACCOUNT_RUTA.
* the recomputed ruta 49 net (FK009). Pure projection over ACCOUNT_RUTA plus
* the company's own ruta 05 accounts (fetchDynamicRuta05Accounts).
*
* `dynamicRuta05Accounts` is optional so callers that only need the 26xx boxes
* keep working untouched: ruta 05 is a beskattningsunderlag, not moms, so it
* never reaches ruta 49 and the settlement proposal nets the same either way.
*/
export function rutorFromTotals(
totals: Map<string, { debit: number; credit: number }>
totals: Map<string, { debit: number; credit: number }>,
dynamicRuta05Accounts: string[] = []
): VatDeclarationRutor {
const rutor: VatDeclarationRutor = {
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
@@ -407,6 +428,14 @@ export function rutorFromTotals(
rutor[mapping.box] = round(rutor[mapping.box] + balance)
}
// The company's own momspliktiga intäktskonton. Always credit-side: these are
// revenue accounts by construction (account_class 3).
for (const account of dynamicRuta05Accounts) {
const t = totals.get(account)
if (!t) continue
rutor.ruta05 = round(rutor.ruta05 + (t.credit - t.debit))
}
// FK009: summaMoms = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48
rutor.ruta49 = round(
rutor.ruta10 + rutor.ruta11 + rutor.ruta12 +
@@ -484,23 +513,59 @@ export async function calculateVatDeclaration(
supabase, companyId, periodType, year, period, options.fiscalPeriodId
)
// Which of the company's OWN class 3 accounts count as momspliktig
// försäljning. Resolved from their "Standard moms" rather than a fixed BAS
// list, because Accounted seeds no varugrupp accounts: every 3011/3013-style
// konto is user-added and would otherwise never be fetched at all (#1261).
const dynamicRuta05 = await fetchDynamicRuta05Accounts(supabase, companyId)
// Fetch and aggregate posted VAT-account activity for the period. The same
// RPC round trip carries the per-source_type entry counts for the metadata.
const { totals, sourceTypeCounts } = await fetchVatAccountTotals(supabase, companyId, start, end)
const { totals, sourceTypeCounts } = await fetchVatAccountTotals(
supabase, companyId, start, end, dynamicRuta05.accounts
)
// Map account balances to momsdeklaration boxes
const rutor = rutorFromTotals(totals)
const rutor = rutorFromTotals(totals, dynamicRuta05.accounts)
// Compute per-rate base amounts from individual revenue accounts
// Compute per-rate base amounts from individual revenue accounts. The
// company's own accounts carry their rate on the konto itself, so they land
// in the same three buckets: without that, a 3013 company would show a
// ruta 05 base that none of base25/12/6 accounts for.
//
// These three are REPORTING metadata (breakdown.invoices), not check inputs:
// vat-declaration-checks.ts derives its expected base from the output-VAT
// rutor (ruta10/0.25 + ruta11/0.12 + ruta12/0.06) and never reads base25/12/6.
// So an incomplete split understates nothing that gets filed; it only makes
// the breakdown fail to add up to ruta 05.
const revenueByRate = {
base25: 0, // 3001
base12: 0, // 3002
base6: 0, // 3003
}
const RATE_BUCKET = { 0.25: 'base25', 0.12: 'base12', 0.06: 'base6' } as const
for (const [account, rate] of [['3001', 'base25'], ['3002', 'base12'], ['3003', 'base6']] as const) {
const t = totals.get(account)
if (t) revenueByRate[rate] = round(t.credit - t.debit)
}
for (const [account, rate] of dynamicRuta05.rateByAccount) {
const t = totals.get(account)
if (!t) continue
const bucket = RATE_BUCKET[rate as keyof typeof RATE_BUCKET]
if (!bucket) continue
revenueByRate[bucket] = round(revenueByRate[bucket] + (t.credit - t.debit))
}
// Accounts the static map ALREADY sums into ruta 05 (3000, the 30xx
// gruppkonto) but whose rate only exists as the konto's "Standard moms".
// Rate-only on purpose: their balance is in ruta 05 either way, so adding
// them to dynamicRuta05.accounts would double the filed figure.
for (const [account, rate] of dynamicRuta05.staticRateByAccount) {
const t = totals.get(account)
if (!t) continue
const bucket = RATE_BUCKET[rate as keyof typeof RATE_BUCKET]
if (!bucket) continue
revenueByRate[bucket] = round(revenueByRate[bucket] + (t.credit - t.debit))
}
// Entry counts by source type for metadata: aggregated by the RPC in the
// same round trip as the totals (SQL GROUP BY, so a busy VAT period can
+150
View File
@@ -0,0 +1,150 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { ACCOUNT_TO_BOX } from '@/lib/vat/moms-box-mapping'
/**
* Resolve which of a company's OWN revenue accounts belong in ruta 05
* (momspliktig försäljning som inte ingår i någon annan ruta).
*
* ACCOUNT_RUTA is a fixed BAS whitelist and ruta 05 is literally 3001/3002/3003
* there. That is only correct for a company that never touched its kontoplan:
* the shipped BAS reference (lib/bookkeeping/bas-data/class-3-revenue.ts) has
* no varugrupp accounts at all, so every 3011/3013/3041-style konto is added by
* the user. Their revenue was not merely mapped to the wrong ruta, it was never
* fetched from the ledger: ACCOUNT_RUTA's keys ARE the account filter passed to
* get_vat_declaration_totals. Ruta 05 came out short and, because
* runVatDeclarationChecks compares rutor 05-08 against 10-12, a perfectly
* correct declaration got a blocking OUTPUT_VAT_WITHOUT_SALES error (#1261).
*
* The per-account "Standard moms" (chart_of_accounts.default_vat_rate) is the
* resolver: a class 3 konto the user marked 25/12/6 % is by definition domestic
* taxable sales, which is exactly what ruta 05 collects. The account dialogs
* say so, since the field now carries declaration weight and not just line
* prefill.
*/
/**
* Class 3 accounts that carry a moms-sats but belong in a DIFFERENT ruta, so
* "has a rate" must not be read as "ruta 05". Per the SKV 4700 mapping in
* .claude/skills/swedish-vat/references/vat-compliance-reference.md §7:
*
* 3211/3212/3220 ruta 07 (vinstmarginalbeskattning)
* 3231/3232/3233 ruta 41 (försäljning där köparen är betalningsskyldig)
* 3913 ruta 08 (hyresinkomster, frivillig skattskyldighet)
*
* Rutor 07, 08 and 41 are not mappable yet (see the note in
* vat-declaration-checks.ts). Until they are, these accounts stay out of the
* declaration entirely: that understates one ruta, whereas sweeping them into
* ruta 05 would file the amount in the wrong box.
*/
export const RUTA_05_EXCLUDED_ACCOUNTS = new Set([
'3211', '3212', '3220',
'3231', '3232', '3233',
'3913',
])
/** VAT rates that mark a konto as momspliktig försäljning. 0 and NULL do not. */
const TAXABLE_RATES = [0.25, 0.12, 0.06]
/**
* Ruta 05 accounts that ACCOUNT_TO_BOX already sums, but whose per-rate bucket
* cannot be inferred from the account number.
*
* 3000 "Försäljning inom Sverige" is the BAS gruppkonto for the 30xx range. A
* BAS-conformant company posts to 3001/3002/3003 and never to 3000, but a
* company that does post to it has genuine domestic taxable sales, so ruta 05
* stays the right box and the filed figure is already correct. What is missing
* is only the rate split: unlike 3001/3002/3003 the number carries no sats, so
* `breakdown.invoices.base25/12/6` would not add up to ruta 05.
*
* These accounts therefore contribute a RATE ONLY. Adding them to `accounts`
* would double-count them, since ACCOUNT_TO_BOX already puts them in the sum.
*/
const RUTA_05_STATIC_RATE_ACCOUNTS = new Set(['3000'])
export interface DynamicRuta05Accounts {
/** Accounts to add to the ledger fetch and to the ruta 05 sum. */
accounts: string[]
/** account_number → 0.25 | 0.12 | 0.06, for the per-rate base breakdown. */
rateByAccount: Map<string, number>
/**
* Rates for accounts ALREADY counted in ruta 05 by the static map. Feeds the
* base breakdown only, never the sum. Empty unless the user set a
* "Standard moms" on one of RUTA_05_STATIC_RATE_ACCOUNTS.
*/
staticRateByAccount: Map<string, number>
}
const EMPTY: DynamicRuta05Accounts = {
accounts: [],
rateByAccount: new Map(),
staticRateByAccount: new Map(),
}
/**
* Fetch the company-specific ruta 05 accounts.
*
* Excluded:
* - every account in ACCOUNT_TO_BOX. This covers all of ACCOUNT_RUTA (the
* alignment test in lib/vat/__tests__/moms-box-mapping.test.ts fails if the
* mirror ever stops being a superset) and additionally the accounts only
* the mirror maps (3106, 3109, 3521, 3522). Filtering on the superset alone
* keeps this module off vat-declaration.ts, which imports it.
*
* This exclusion is what makes the BAS backfill safe: it sets 3001 = 25 %,
* and without it 3001 would be counted once by ACCOUNT_RUTA and once here,
* doubling ruta 05.
* - RUTA_05_EXCLUDED_ACCOUNTS above.
*
* The company_id filter is explicit rather than left to RLS:
* calculateVatDeclaration is also reached from /api/v1/* on a service client,
* which has no RLS.
*/
export async function fetchDynamicRuta05Accounts(
supabase: SupabaseClient,
companyId: string
): Promise<DynamicRuta05Accounts> {
const rows = await fetchAllRows<{ account_number: string; default_vat_rate: number | string | null }>(
({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, default_vat_rate')
.eq('company_id', companyId)
.eq('account_class', 3)
// Deactivated accounts only. is_active is nullable (boolean DEFAULT
// true, never made NOT NULL) and the accounts API treats only an
// explicit false as deactivated, so `eq(true)` would silently drop a
// NULL-flagged konto: the same kind of quiet omission this whole fix
// exists to remove.
.not('is_active', 'is', false)
.in('default_vat_rate', TAXABLE_RATES)
.order('account_number', { ascending: true })
.range(from, to)
)
if (rows.length === 0) return EMPTY
const accounts: string[] = []
const rateByAccount = new Map<string, number>()
const staticRateByAccount = new Map<string, number>()
for (const row of rows) {
const account = row.account_number
const rate = Number(row.default_vat_rate)
if (!TAXABLE_RATES.includes(rate)) continue
// Checked before the ACCOUNT_TO_BOX skip: these accounts ARE in that map,
// which is precisely why they need the rate surfaced separately.
if (RUTA_05_STATIC_RATE_ACCOUNTS.has(account)) {
staticRateByAccount.set(account, rate)
continue
}
if (ACCOUNT_TO_BOX[account]) continue
if (RUTA_05_EXCLUDED_ACCOUNTS.has(account)) continue
accounts.push(account)
rateByAccount.set(account, rate)
}
return { accounts, rateByAccount, staticRateByAccount }
}
+1
View File
@@ -55,6 +55,7 @@ export type MomsBox =
*/
export const ACCOUNT_TO_BOX: Record<string, MomsBox> = {
// Domestic revenue (taxable) → Box 05
'3000': '05', // Försäljning inom Sverige (gruppkonto)
'3001': '05', // Försäljning varor/tjänster 25%
'3002': '05', // Försäljning varor/tjänster 12%
'3003': '05', // Försäljning varor/tjänster 6%
+75 -1
View File
@@ -12,8 +12,12 @@ import {
countVerifikatMissingDocument,
listSuggestedMatches,
} from '../categories'
import {
MATCHABLE_INVOICE_STATUSES,
MATCHABLE_SUPPLIER_INVOICE_STATUSES,
} from '@/lib/invoices/matchable-statuses'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const supabase = mockSupabase as unknown as SupabaseClient
const COMPANY = 'company-1'
@@ -208,4 +212,74 @@ describe('listSuggestedMatches', () => {
enqueue({ error: { message: 'boom' } })
await expect(listSuggestedMatches(supabase, COMPANY)).resolves.toEqual([])
})
// Regression: the hint columns are written once and never revisited, so an
// invoice paid off by a DIFFERENT transaction leaves a stale pointer. The
// candidate lookup must revalidate instead of trusting it, or the worklist
// renders a one-click confirm row whose endpoint can only answer
// ALREADY_PAID.
it('revalidates hinted candidates against the matchable statuses', async () => {
enqueue({
data: [
{
id: 'tx-1',
date: '2026-06-01',
description: 'X',
amount: 100,
currency: 'SEK',
potential_invoice_id: 'inv-1',
potential_supplier_invoice_id: null,
},
{
id: 'tx-2',
date: '2026-06-02',
description: 'Y',
amount: -100,
currency: 'SEK',
potential_invoice_id: null,
potential_supplier_invoice_id: 'sinv-1',
},
],
})
enqueue({ data: [] })
enqueue({ data: [] })
await listSuggestedMatches(supabase, COMPANY)
// Both revalidation conditions, not just the id filter: findCall returns
// only the FIRST .in() per table, which is the id one, so the status
// filter needs findCalls to be covered at all.
expect(findCalls('invoices', 'in')).toEqual([
['id', ['inv-1']],
['status', [...MATCHABLE_INVOICE_STATUSES]],
])
expect(findCall('invoices', 'gt')).toEqual(['remaining_amount', 0])
expect(findCalls('supplier_invoices', 'in')).toEqual([
['id', ['sinv-1']],
['status', [...MATCHABLE_SUPPLIER_INVOICE_STATUSES]],
])
expect(findCall('supplier_invoices', 'gt')).toEqual(['remaining_amount', 0])
})
it('drops a hint whose invoice has since been settled elsewhere', async () => {
enqueue({
data: [
{
id: 'tx-1',
date: '2026-06-01',
description: 'MONTHLY FEE',
amount: -549,
currency: 'SEK',
potential_invoice_id: null,
potential_supplier_invoice_id: 'sinv-paid',
},
],
})
enqueue({ data: [] })
// The status/remaining filters exclude the settled invoice server-side, so
// the lookup comes back empty and the row must not be offered.
enqueue({ data: [] })
await expect(listSuggestedMatches(supabase, COMPANY)).resolves.toEqual([])
})
})
+21 -2
View File
@@ -10,6 +10,10 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import {
MATCHABLE_INVOICE_STATUSES,
MATCHABLE_SUPPLIER_INVOICE_STATUSES,
} from '@/lib/invoices/matchable-statuses'
import type { SuggestedMatch } from './types'
const log = createLogger('worklist')
@@ -260,6 +264,17 @@ interface SuggestedMatchTxRow {
* one-click confirm row. Confirm endpoints:
* kind 'invoice' POST /api/transactions/{id}/match-invoice
* kind 'supplier_invoice' POST /api/transactions/{id}/match-supplier-invoice
*
* The hint columns are write-once suggestions: nothing revisits them when the
* invoice is later settled by a DIFFERENT transaction (or by mark-paid, MCP,
* bank reconciliation or a SIE import). So the candidate lookup revalidates
* against MATCHABLE_*_STATUSES instead of trusting the pointer: an invoice
* that has since been paid would otherwise render a one-click confirm row
* whose endpoint can only answer ALREADY_PAID.
*
* Revalidation is done here, at read time, rather than by cleaning up sibling
* pointers on settle: the settle paths are many and a missed one leaks, while
* this check covers every route into the list.
*/
export async function listSuggestedMatches(
supabase: SupabaseClient,
@@ -295,6 +310,8 @@ export async function listSuggestedMatches(
.select('id, invoice_number, total, customer:customers(name)')
.eq('company_id', companyId)
.in('id', invoiceIds)
.in('status', [...MATCHABLE_INVOICE_STATUSES])
.gt('remaining_amount', 0)
: Promise.resolve({ data: [], error: null }),
supplierInvoiceIds.length > 0
? supabase
@@ -302,6 +319,8 @@ export async function listSuggestedMatches(
.select('id, supplier_invoice_number, total, supplier:suppliers(name)')
.eq('company_id', companyId)
.in('id', supplierInvoiceIds)
.in('status', [...MATCHABLE_SUPPLIER_INVOICE_STATUSES])
.gt('remaining_amount', 0)
: Promise.resolve({ data: [], error: null }),
])
@@ -358,8 +377,8 @@ export async function listSuggestedMatches(
candidate_total: supplierInvoice.total ?? null,
})
}
// Hint pointing at a deleted/foreign candidate → drop the row rather
// than render an unconfirmable suggestion.
// Hint pointing at a deleted, foreign or already-settled candidate → drop
// the row rather than render an unconfirmable suggestion.
}
return matches
}
+17 -2
View File
@@ -986,7 +986,12 @@
"type_individual": "Individual",
"type_swedish_business": "Swedish company or organization",
"type_eu_business": "EU business",
"type_non_eu_business": "Outside EU"
"type_non_eu_business": "Outside EU",
"personal_number_show": "Show personal number",
"personal_number_hide": "Hide personal number",
"personal_number_reveal_failed_title": "Could not show the personal number",
"personal_number_unreadable": "The stored personal number cannot be read.",
"personal_number_unreadable_action": "Enter it again"
},
"form_customer": {
"type_label": "Customer type *",
@@ -1043,7 +1048,8 @@
"notes_placeholder": "Internal notes about the customer...",
"submit_save": "Save customer",
"submit_saving": "Saving...",
"viewer_disabled_tooltip": "You only have viewer access in this company"
"viewer_disabled_tooltip": "You only have viewer access in this company",
"personal_number_unreadable": "The stored personal number cannot be read. Enter it again to replace it."
},
"form_supplier": {
"name_label": "Name *",
@@ -2589,6 +2595,8 @@
"different_currencies": " (different currencies)",
"partial_payment_note": ": the invoice will become partially paid.",
"ore_rounding_note": "The {amount} difference is booked as rounding (account 3740). The invoice is marked as paid.",
"target_settled_title": "This invoice is already fully paid",
"target_settled_description": "This match suggestion is out of date: the invoice was paid by another transaction and cannot be matched again. Close this dialog and match the transaction against the right invoice, or book it as usual.",
"fx_title": "Currency conversion",
"fx_rate_description": "Riksbanken mid-rate {date}: 1 {invoiceCurrency} = {rate} SEK",
"fx_paid_in_invoice_currency": "Payment equals: {amount}",
@@ -4290,6 +4298,8 @@
"tab_bas_catalog": "BAS catalog",
"add_own": "Custom account",
"hide_k2_excluded": "Hide K2-excluded",
"show_inactive": "Show inactive",
"reactivate": "Activate",
"search_placeholder": "Search account (number or name)...",
"class_heading": "Class {cls}: {label}",
"col_account": "Account",
@@ -4313,6 +4323,11 @@
"toast_activate_failed": "Could not activate the account",
"toast_activated_title": "Account activated",
"toast_activated_description": "Account {number} has been added to your chart of accounts",
"toast_reactivated_title": "Account activated again",
"toast_reactivated_description": "Account {number} is active in your chart of accounts again",
"deactivate_confirm_title": "Deactivate account {number}?",
"deactivate_confirm": "This account is used in {count} posted journal entries. Existing bookkeeping is unaffected, but the account is hidden from the chart of accounts and cannot be selected in new entries.",
"deactivate_confirm_action": "Deactivate",
"col_usage": "Vouchers",
"own_badge": "Custom",
"prune_button": "Delete accounts",
+17 -2
View File
@@ -986,7 +986,12 @@
"type_individual": "Privatperson",
"type_swedish_business": "Svenskt företag eller organisation",
"type_eu_business": "EU-företag",
"type_non_eu_business": "Utanför EU"
"type_non_eu_business": "Utanför EU",
"personal_number_show": "Visa personnummer",
"personal_number_hide": "Dölj personnummer",
"personal_number_reveal_failed_title": "Personnumret kunde inte visas",
"personal_number_unreadable": "Det sparade personnumret kan inte läsas.",
"personal_number_unreadable_action": "Skriv in det igen"
},
"form_customer": {
"type_label": "Kundtyp *",
@@ -1043,7 +1048,8 @@
"notes_placeholder": "Interna anteckningar om kunden...",
"submit_save": "Spara kund",
"submit_saving": "Sparar...",
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag"
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
"personal_number_unreadable": "Det sparade personnumret kan inte läsas. Skriv in det igen för att ersätta det."
},
"form_supplier": {
"name_label": "Namn *",
@@ -2589,6 +2595,8 @@
"different_currencies": " (olika valutor)",
"partial_payment_note": ": fakturan blir delbetald.",
"ore_rounding_note": "Differens {amount} bokförs som öresavrundning (konto 3740). Fakturan markeras som betald.",
"target_settled_title": "Fakturan är redan slutbetald",
"target_settled_description": "Matchningsförslaget är inaktuellt: fakturan har betalats av en annan transaktion och kan inte matchas igen. Stäng dialogen och matcha transaktionen mot rätt faktura, eller bokför den som vanligt.",
"fx_title": "Valutaomräkning",
"fx_rate_description": "Riksbankens mittkurs {date}: 1 {invoiceCurrency} = {rate} SEK",
"fx_paid_in_invoice_currency": "Inbetalning motsvarar: {amount}",
@@ -4290,6 +4298,8 @@
"tab_bas_catalog": "BAS-katalog",
"add_own": "Eget konto",
"hide_k2_excluded": "Dölj K2-undantagna",
"show_inactive": "Visa inaktiva",
"reactivate": "Aktivera",
"search_placeholder": "Sök konto (nummer eller namn)...",
"class_heading": "Klass {cls}: {label}",
"col_account": "Konto",
@@ -4313,6 +4323,11 @@
"toast_activate_failed": "Kunde inte aktivera kontot",
"toast_activated_title": "Konto aktiverat",
"toast_activated_description": "Konto {number} har lagts till i din kontoplan",
"toast_reactivated_title": "Konto aktiverat igen",
"toast_reactivated_description": "Konto {number} är aktivt i din kontoplan igen",
"deactivate_confirm_title": "Inaktivera konto {number}?",
"deactivate_confirm": "Kontot används i {count} bokförda verifikationer. Befintlig bokföring påverkas inte, men kontot döljs i kontoplanen och kan inte väljas i nya verifikat.",
"deactivate_confirm_action": "Inaktivera",
"col_usage": "Verifikat",
"own_badge": "Egen",
"prune_button": "Ta bort konton",
@@ -0,0 +1,75 @@
-- Seed "Standard moms" (chart_of_accounts.default_vat_rate) on the BAS revenue
-- accounts Accounted ships.
--
-- Background (#1261): the momsdeklaration resolves ruta 05 from a fixed BAS
-- whitelist (ACCOUNT_RUTA), which is only right for a company that never
-- touched its kontoplan. Accounted's BAS chart ships no varugrupp accounts, so
-- every 3011/3013/3041-style konto is user-added, and its revenue was never
-- even fetched from the ledger. The fix reads default_vat_rate on class 3
-- accounts instead, which makes the field the answer to "is this momspliktig
-- forsaljning, and at what sats?".
--
-- That only works if the field is populated. 3001/3002/3003 shipped with an
-- empty "Standard moms" even though their whole identity is the moms-sats in
-- their name, which reads as "no rate set" in the account dialogs and made the
-- new rule look arbitrary. This seeds the four accounts whose sats is not a
-- judgement call.
--
-- No declaration figure changes: fetchDynamicRuta05Accounts excludes every
-- account already mapped in ACCOUNT_TO_BOX (a superset of ACCOUNT_RUTA), so
-- 3001-3004 keep coming from the static map and are not counted twice.
--
-- pg-test: supabase/migrations/__tests__/account-default-vat-rate.pg.test.ts
-- Existing companies. Only rows without an explicit value: a user who
-- deliberately set something else keeps it.
UPDATE public.chart_of_accounts SET default_vat_rate = 0.25
WHERE account_number = '3001' AND default_vat_rate IS NULL;
UPDATE public.chart_of_accounts SET default_vat_rate = 0.12
WHERE account_number = '3002' AND default_vat_rate IS NULL;
UPDATE public.chart_of_accounts SET default_vat_rate = 0.06
WHERE account_number = '3003' AND default_vat_rate IS NULL;
UPDATE public.chart_of_accounts SET default_vat_rate = 0
WHERE account_number = '3004' AND default_vat_rate IS NULL;
-- New / imported / on-demand rows. Widens the BEFORE INSERT default introduced
-- for 3740 in 20260709120000: seed_chart_of_accounts() does not write a VAT
-- column, so the trigger is still the one place that covers every insert path
-- (company seed, SIE import, on-demand backfill, manual add). Fires only when
-- the caller left the rate unset, so an explicit choice always wins.
--
-- Replaces set_known_momsfri_default_vat_rate(): the name stopped being true
-- once the map carries momspliktiga sats as well.
CREATE OR REPLACE FUNCTION public.set_known_default_vat_rate()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
IF NEW.default_vat_rate IS NULL THEN
NEW.default_vat_rate := CASE NEW.account_number
WHEN '3001' THEN 0.25
WHEN '3002' THEN 0.12
WHEN '3003' THEN 0.06
WHEN '3004' THEN 0 -- momsfri forsaljning
WHEN '3740' THEN 0 -- oresavrundning, never carries moms
ELSE NULL
END;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_chart_of_accounts_momsfri_default ON public.chart_of_accounts;
DROP TRIGGER IF EXISTS trg_chart_of_accounts_default_vat_rate ON public.chart_of_accounts;
CREATE TRIGGER trg_chart_of_accounts_default_vat_rate
BEFORE INSERT ON public.chart_of_accounts
FOR EACH ROW
EXECUTE FUNCTION public.set_known_default_vat_rate();
DROP FUNCTION IF EXISTS public.set_known_momsfri_default_vat_rate();
COMMENT ON COLUMN public.chart_of_accounts.default_vat_rate IS
'Per-account default VAT rate for booking lines (0/0.06/0.12/0.25). NULL = no default. On class 3 accounts it also decides whether the konto counts as momspliktig forsaljning in ruta 05 of the momsdeklaration (see lib/reports/vat-revenue-accounts.ts).';
NOTIFY pgrst, 'reload schema';
@@ -106,3 +106,71 @@ describe('chart_of_accounts.default_vat_rate', () => {
).rejects.toThrow()
})
})
/**
* Migration 20260728120000 widened the insert default to the BAS revenue
* accounts whose sats is in their own name. The momsdeklaration reads
* default_vat_rate on class 3 to decide ruta 05 membership (#1261), so these
* shipping with an empty "Standard moms" made the rule look arbitrary in the
* account dialogs.
*/
describe('chart_of_accounts.default_vat_rate: BAS revenue seeds', () => {
it('ships 3001/3002/3003 with their own sats and 3004 as momsfri', async () => {
const { companyId, userId } = await seedCompany()
const expected: Array<[string, number]> = [
['3001', 0.25],
['3002', 0.12],
['3003', 0.06],
['3004', 0],
]
for (const [number, rate] of expected) {
expect(
await insertAccount(companyId, userId, {
number,
name: `Försäljning ${number}`,
type: 'revenue',
balance: 'credit',
}),
).toBe(rate)
}
})
it('still fills 3740 (the original momsfri case survives the rewrite)', async () => {
const { companyId, userId } = await seedCompany()
expect(
await insertAccount(companyId, userId, {
number: '3740',
name: 'Öres- och kronutjämning',
type: 'revenue',
balance: 'credit',
}),
).toBe(0)
})
it('keeps an explicit sats on a seeded account', async () => {
const { companyId, userId } = await seedCompany()
expect(
await insertAccount(companyId, userId, {
number: '3001',
name: 'Försäljning inom Sverige, 25 % moms',
type: 'revenue',
balance: 'credit',
rate: 0,
}),
).toBe(0)
})
it('leaves user-added revenue accounts unset (the user picks the sats)', async () => {
// 3013 is exactly the #1261 case: Accounted seeds no varugrupp accounts, so
// the company adds it and chooses the sats itself.
const { companyId, userId } = await seedCompany()
expect(
await insertAccount(companyId, userId, {
number: '3013',
name: 'Försäljning varugrupp 1, 6 % moms',
type: 'revenue',
balance: 'credit',
}),
).toBeNull()
})
})
+37 -11
View File
@@ -733,11 +733,28 @@ export function createQueuedMockSupabase() {
}
}
/**
* Every chained builder call, in order: { table, method, args }. The proxy
* otherwise swallows its arguments, so filters and update payloads were
* invisible to assertions. Recording is passive: it changes nothing about
* what a chain resolves to.
*/
const calls: { table: string; method: string; args: unknown[] }[] = []
/** Args of the first `method` call made against `table`, or undefined. */
const findCall = (table: string, method: string): unknown[] | undefined =>
calls.find((c) => c.table === table && c.method === method)?.args
/** Args of every `method` call made against `table`. */
const findCalls = (table: string, method: string): unknown[][] =>
calls.filter((c) => c.table === table && c.method === method).map((c) => c.args)
const reset = () => {
queue.length = 0
calls.length = 0
}
const buildChain = (): unknown => {
const buildChain = (table: string): unknown => {
// Capture the result at chain creation (when from/rpc is called)
const result = queue.shift() || { data: null, error: null, count: null }
@@ -746,24 +763,33 @@ export function createQueuedMockSupabase() {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
return (..._args: unknown[]) => buildChain2(result)
return (...args: unknown[]) => {
calls.push({ table, method: String(prop), args })
return buildChain2(table, result)
}
},
}
return new Proxy({}, handler)
}
// Inner chain methods reuse the same result
const buildChain2 = (result: {
data: unknown
error: unknown
count?: number | null
}): unknown => {
const buildChain2 = (
table: string,
result: {
data: unknown
error: unknown
count?: number | null
},
): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
return (..._args: unknown[]) => buildChain2(result)
return (...args: unknown[]) => {
calls.push({ table, method: String(prop), args })
return buildChain2(table, result)
}
},
}
return new Proxy({}, handler)
@@ -788,15 +814,15 @@ export function createQueuedMockSupabase() {
}
const supabase = {
from: vi.fn().mockImplementation(() => buildChain()),
rpc: vi.fn().mockImplementation(() => buildChain()),
from: vi.fn().mockImplementation((table: string) => buildChain(table)),
rpc: vi.fn().mockImplementation((fn: string) => buildChain(`rpc:${fn}`)),
storage: storageMock,
auth: {
getUser: vi.fn(),
},
}
return { supabase, enqueue, enqueueMany, reset }
return { supabase, enqueue, enqueueMany, reset, calls, findCall, findCalls }
}
export function makeCategorizationTemplate(
@@ -104,12 +104,19 @@ describe('customers.personal_number ciphertext check.pg', () => {
}
})
it('rejects the masked display form', async () => {
it('rejects both masked display forms', async () => {
// The last of the three guards that keep a mask from ever being stored:
// CustomerForm strips it, the PATCH route reads it as "unchanged", and
// Postgres refuses it outright. '********-????' is what an undecryptable
// row renders as; the write paths now accept it as a no-op sentinel, so
// the DB backstop has to cover it too.
const { userId, companyId } = await seedCompany()
await expect(
insertCustomer({ userId, companyId, personalNumber: '********-1234' }),
).rejects.toThrow(/customers_personal_number_check/)
for (const mask of ['********-1234', '********-????']) {
await expect(
insertCustomer({ userId, companyId, personalNumber: mask }),
).rejects.toThrow(/customers_personal_number_check/)
}
})
it('rejects hex that is too short to be ciphertext', async () => {
+16
View File
@@ -1950,6 +1950,18 @@ export interface BalanceSheetReport {
imbalance_diagnosis?: BalanceImbalanceDiagnosis
}
/**
* Highest POSTED voucher number per series inside a reported window.
*
* Reconciliation aid, not statutory (BFL does not require it). Deliberately the
* last posted number, not `voucher_sequences.last_number`: the sequence counter
* is an allocation high-water mark that can sit ahead of the books.
*/
export interface LatestVoucherPerSeries {
series: string
last_number: number
}
export interface ResultatrapportRow {
account_number: string
account_name: string
@@ -1971,6 +1983,8 @@ export interface ResultatrapportReport {
net_result_prior: number
period: { start: string; end: string }
prior_period: { start: string; end: string } | null
/** Omitted when the window holds no posted vouchers, or the report is dimension-filtered. */
latest_vouchers?: LatestVoucherPerSeries[]
}
// Resultat per projekt/kostnadsställe: value-as-column P&L matrix over one
@@ -2031,6 +2045,8 @@ export interface BalansrapportReport {
period: { start: string; end: string }
/** Present only when the underlying trial balance does not balance. */
imbalance_diagnosis?: BalanceImbalanceDiagnosis
/** Omitted when the window holds no posted vouchers. */
latest_vouchers?: LatestVoucherPerSeries[]
}
export interface SIEExportOptions {