fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)

* fix(security): gate replace_sie_import behind owner/admin membership

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-27 03:34:56 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a43a8b03cf
commit f24b26a139
536 changed files with 68921 additions and 4851 deletions
+3 -2
View File
@@ -17,7 +17,6 @@ For Swedish accounting-law questions, use the domain skills (`swedish-vat`, `swe
- `bookkeeping/period-service.ts`: Fiscal period lifecycle management (open, close, lock)
- `bookkeeping/year-end-service.ts`: Year-end closing procedures
- `bookkeeping/storno-service.ts`: Reversal/correction entry generation
- `tax/tax-code-service.ts`: Tax code definitions and rates
- `audit/audit-service.ts`: Audit trail and compliance logging
- `documents/document-service.ts`: Document attachment lifecycle (WORM storage with version chains)
@@ -31,10 +30,12 @@ BAS data (`lib/bookkeeping/bas-data/`): full BAS 2026 chart by class (1-8) + SRU
`standard_25`, `reduced_12`, `reduced_6`, `reverse_charge`, `export`, `exempt`
Invoice items support individual `vat_rate` values (mixed-rate invoices). Use `getAvailableVatRates(customerType, vatNumberValidated)` from `lib/invoices/vat-rules.ts`. VIES validation via `lib/vat/vies-client.ts`.
Invoice items support individual `vat_rate` values (mixed-rate invoices). Both helpers live in `lib/invoices/vat-rules.ts` and are not interchangeable: `getAvailableVatRates(customerType, vatNumberValidated)` is the DEFAULT a picker offers or a line starts on (a single locked 0% for a foreign business); `getPermittedVatRates(...)` is the LAWFUL set every validation gate must use, because the ML 6 kap. supplies taxed where they are performed carry Swedish VAT even to a foreign business. VIES validation via `lib/vat/vies-client.ts`.
## VAT Declaration Rutor (SKV 4700)
Ruta assignment is driven by **BAS account number**, never by a per-line tax code: `ACCOUNT_RUTA` in `lib/reports/vat-declaration.ts` is the source of truth, mirrored by `ACCOUNT_TO_BOX` in `lib/vat/moms-box-mapping.ts` (a regression test asserts the two agree). `journal_entry_lines.tax_code` is a free-text tag that nothing reads; there is no `tax_codes` table (migration 012 is a placeholder for a design that was never deployed).
`VatDeclarationRutor` type maps to momsdeklaration:
- **Ruta 05**: Domestic taxable sales (3001+3002+3003)
- **Ruta 06/07**: Unused, always 0
+18
View File
@@ -52,3 +52,21 @@ CRON_SECRET=generate-a-random-secret
# NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED=false
# BOLAGSVERKET_ARELLE_VALIDATOR_URL=
# BOLAGSVERKET_ARELLE_VALIDATOR_TOKEN=
# ── Optional: error tracking / observability ──────────────
# The app routes every error-level log line, and anything flagged
# `alert: true`, to a provider-agnostic sink (lib/observability). No vendor is
# wired up: the sink is a NO-OP until an adapter is registered with
# registerObservabilitySink(). Setting these variables alone changes nothing,
# and self-hosted installs can leave them unset forever.
#
# Names are generic placeholders. When a provider is picked, either keep these
# and read them in the adapter, or replace them with the vendor's own names.
# OBSERVABILITY_DSN= # server-side ingest endpoint / key
# NEXT_PUBLIC_OBSERVABILITY_DSN= # browser ingest endpoint / key, if used
# Optional overrides. Both have sensible defaults: the environment falls back
# to VERCEL_ENV then NODE_ENV, and the release falls back to
# NEXT_PUBLIC_BUILD_ID (the commit sha next.config.ts inlines at build time)
# then VERCEL_GIT_COMMIT_SHA. Set them only when tagging must differ.
# OBSERVABILITY_ENVIRONMENT=
# OBSERVABILITY_RELEASE=
+6
View File
@@ -68,6 +68,12 @@ jobs:
ignore-unfixed: true
format: sarif
output: trivy-results.sarif
# Without this, trivy-action unsets TRIVY_SEVERITY when format is
# sarif (entrypoint.sh: "Building SARIF report with all severities"),
# silently discarding the CRITICAL,HIGH filter above. The exit code
# then fires on any fixable CVE down to LOW, which is not the policy
# documented here and turns routine low-severity noise into a red run.
limit-severities-for-sarif: true
- name: Upload Trivy results to GitHub Security tab
# if: always() so findings still reach the Security tab even though the
+17 -6
View File
@@ -65,12 +65,23 @@ jobs:
# OCI attestations, queryable via `docker buildx imagetools inspect`.
provenance: mode=max
sbom: true
# Per-branch cache scope so a PR branch can't poison main's cache
# layers. Fall back to main's cache on first build of a new branch.
cache-from: |
type=gha,scope=${{ github.ref_name }}
type=gha,scope=main
cache-to: type=gha,scope=${{ github.ref_name }},mode=max
# Layer cache lives in GHCR, not the GitHub Actions cache. Two
# platforms x mode=max is ~7 GB, which alone ate 70% of the repo's
# 10 GB Actions cache quota and, because buildx refreshes every blob's
# access time on each run, never aged out under LRU: it starved the
# other workflows' caches instead. GHCR storage is free for public
# repos and off that quota. mode=max is kept deliberately: mode=min
# would drop the deps/builder stages and make every push redo
# `npm ci` + `next build`.
#
# A single stable cache tag is safe here: this workflow only runs on
# main and v*.*.* tags, and tags are cut from main, so there is no
# untrusted ref that could poison the layers.
#
# image-manifest=true,oci-mediatypes=true is required by GHCR, which
# rejects buildkit's default cache manifest media type.
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max,image-manifest=true,oci-mediatypes=true
- name: Install cosign
uses: sigstore/cosign-installer@v4.1.2
@@ -57,10 +57,23 @@ jobs:
fi
echo "number=$NUM" >> "$GITHUB_OUTPUT"
- name: Install Anthropic Bedrock SDK
# Pinned exact version + --ignore-scripts: this is the privileged job
# (write token in env), so no floating @latest and no dependency
# lifecycle scripts may execute here.
run: npm install --no-save --no-package-lock --ignore-scripts @anthropic-ai/bedrock-sdk@0.31.0
# Installed ONE DIRECTORY ABOVE the checkout on purpose. Running this
# inside the repo makes npm re-resolve the whole dependency tree from
# package.json (--no-package-lock throws away the pinned resolutions),
# and that re-resolution dies on an unrelated floating peer conflict
# (@hookform/resolvers -> valibot), which silently killed this review on
# every PR. Out of tree npm resolves this one package and nothing else,
# so an unrelated peer conflict can never take the compliance gate down
# again. Node still finds it: ESM bare specifiers walk up the parent
# directories' node_modules. NODE_PATH is not an alternative here, the
# ESM loader ignores it (CommonJS only).
# Version tracks the exact package.json pin: 0.32.0 broke Bedrock
# streaming in prod (DECISIONS.md 2026-07-08) and check:guards enforces
# 0.29.1 repo-wide, so this job must not be the one place running an
# unvetted build. Pinned exact + --ignore-scripts because this is the
# privileged job (write token in env): no floating @latest, and no
# dependency lifecycle scripts may execute here.
run: npm install --prefix "$GITHUB_WORKSPACE/.." --no-save --no-package-lock --ignore-scripts @anthropic-ai/bedrock-sdk@0.29.1
- name: Run compliance review
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+7 -1
View File
@@ -52,10 +52,16 @@ jobs:
steps:
- uses: actions/checkout@v7
# Deliberately no `cache: npm` here. This workflow runs on pull_request
# only, so the cache is never written on main, and GitHub scopes caches
# by ref: every PR uploaded its own 284 MB copy under an identical key
# that no other PR could ever restore. Fourteen dead copies (4 GB, 40% of
# the repo quota) accumulated in a single day. If this is ever worth
# caching again, it has to be actions/cache/save on main plus
# actions/cache/restore here, which is the only shape that gets hits.
- uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
- run: npm ci
+168
View File
@@ -384,8 +384,176 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-25] Declined the review suggestion to 200-ack the Resend delivery webhook when RESEND_DELIVERY_WEBHOOK_SECRET is unset; kept 503. The endpoint is only ever called because an operator pointed Resend at it, so a missing secret at that moment is a live misconfiguration: Svix retry then endpoint-disable is a visible signal, whereas a silent 200 loses every delivery outcome with only a log line. The "optional" wording in docs/WHITELABEL.md describes not wiring the webhook at all, not wiring it half way.
[2026-07-25] Reverted the settings panel-sheet redesign on bug/resend-and-invoices back to main: Emil prefers the settings UI as it stands on main. The routed sheet, the sheet/ primitives (SettingsMasterDetail, SettingsAccordion, SettingsFieldRow), the *Subsections.tsx decompositions, the cold-load sheet and the settings_sheet i18n namespace were removed; every app/(dashboard)/settings/* page, components/settings/** file and MainContainer scroll exception now matches origin/main byte for byte. Unrelated branch work (invoice delivery outcomes, Stripe feed-only, article currency/deactivation, PDF logo) is untouched.
[2026-07-25] Settings UI on bug/resend-and-invoices now comes from feat/settings-fonster-redesign (dbae8792, Jakob) instead of the panel-sheet work reverted earlier the same day: Emil chose the Fonster concept (flat hairline rows, help behind "?", sticky dirty-only save bar, 920x680 modal, switches instead of checkboxes). Applied as a patch rather than a merge because the redesign branch forks from b5e3c476 and merging would have dragged that older main in; every file applied cleanly since no settings file changed on main since that fork point. The 10 settings_payments keys the redesign still carries (needs_review_*, reason_*, sync_done_description/transactions) were deliberately NOT restored: the Stripe feed-only commit on this branch deleted both them and their call sites.
[2026-07-25] Scheduled Image Vulnerability Scan has been red since 2026-07-22 08:45 UTC (last green 07-21 22:45, same code): Vercel published 7 CVEs against next 16.2.10 on 07-22, two HIGH SSRF (CVE-2026-64649 Server Actions on custom servers, CVE-2026-64645 rewrites destination hostname) plus five MEDIUM, all fixed in 16.2.11. Bumped next and eslint-config-next to 16.2.12 (latest 16.2.x, no advisories against it) rather than the minimum 16.2.11, since both are patch-level and 16.2.12 avoids an immediate second bump. Nothing auto-caught this because Dependabot was removed in #1084 and Dependabot alerts are off on the repo, so npm CVE bumps are now a manual chore.
[2026-07-25] Added limit-severities-for-sarif: true to docker-image-scan.yml. trivy-action v0.36.0 entrypoint.sh unsets TRIVY_SEVERITY whenever format is sarif unless that input is true, so the workflow's documented "block on fixable CRITICAL/HIGH" policy was silently running as "block on any fixable CVE down to LOW". Kept format: sarif (the Security-tab upload depends on it) and narrowed the gate instead. Side effect: the Security tab now receives only CRITICAL/HIGH findings from this workflow; docker-publish.yml is untouched and still uploads all severities, so nothing is lost.
[2026-07-26] GitHub Actions cache hit the repo's 10 GB quota (11.09 GB across 272 entries). Two causes, two fixes. (1) docker-publish.yml's buildx layer cache was 6.89 GB of type=gha blobs, and since buildx refreshes every blob's access time on each run it never aged out under LRU, starving the other workflows instead. Moved to type=registry in GHCR (free for public repos, off the quota) rather than dropping to mode=min, which would have dropped the deps/builder stages and made every push redo npm ci + next build. Needs image-manifest=true,oci-mediatypes=true, which GHCR requires. Per-branch scope dropped at the same time: the workflow only runs on main and v*.*.* tags, so there was no untrusted ref to isolate, and per-tag scopes were cutting a fresh cache per release. (2) test-pg-real.yml's setup-node cache: npm produced 3.98 GB as 14 byte-identical 284 MB copies in one day: the workflow is pull_request-only, so the cache was never written on main, and GitHub scopes caches by ref, meaning no PR could ever restore another PR's copy. Removed outright; re-adding requires actions/cache/save on main plus restore here. The 6.89 GB of stale gha blobs stay until one post-merge main build populates ghcr.io/erp-mafia/gnubok:buildcache, since until then they are the only layer cache.
[2026-07-26] swedish-compliance-review.yml now installs the Bedrock SDK OUT OF TREE (npm install --prefix "$GITHUB_WORKSPACE/..") instead of adding --legacy-peer-deps to the in-repo install: with --no-package-lock npm discarded the lockfile and re-resolved the whole tree from package.json, floating @hookform/resolvers to 5.4.3 whose valibot ^1 peer conflicts with the pinned valibot 0.39.0, so the Swedish accounting compliance gate had posted nothing for 10 consecutive runs. Out of tree npm resolves only that one package, so an unrelated peer conflict can never take the gate down again, and Node still finds it because ESM bare specifiers walk up parent node_modules (NODE_PATH is CommonJS-only, so it is not an option); --legacy-peer-deps was rejected because it would mask future genuine peer conflicts and still install the full tree. Same step's SDK version aligned from 0.31.0 (undocumented drift) to the 0.29.1 that package.json and check:guards enforce after the 0.32.0 streaming outage. docker-image-scan.yml deliberately left unchanged: its red runs are the next 16.2.10 CVEs already fixed by the 16.2.12 bump on this branch, not an unfixable advisory, so a suppression would only hide a real patchable HIGH.
[2026-07-26] replace_sie_import got the undo_sie_import owner/admin guard (COALESCE(p_user_id, auth.uid()), fail closed when the role is NULL) plus REVOKE FROM PUBLIC, anon: prod held EXECUTE for anon on a SECURITY DEFINER function with no authorization check at all that sets gnubok.allow_delete, so any caller with a company_id and an import id could hard delete another tenant's verifikationer past the BFL triggers. p_user_id is optional rather than required because app/api/import/sie/[id]/replace/route.ts passes only two args and the session client resolves the actor via auth.uid(); EXECUTE is granted to authenticated as well as service_role because rpcClientForBulkDelete falls back to the caller's session client when SUPABASE_SERVICE_ROLE_KEY is unset, and the fail closed guard makes that safe.
[2026-07-26] arcim-migration OAuth reuses the existing provider_otc table as its state store instead of a new one: the table was created in 20260402010000 for exactly this ("One-time codes for OAuth callback validation") and never wired up, with 139 rows in prod and zero ever consumed. State is now an opaque randomBytes(32) pointer consumed by a single atomic UPDATE ... WHERE used_at IS NULL AND expires_at > now() RETURNING, provider is read from provider_consents rather than trusted from the client, and no grandfathering window was added for legacy base64url state because accepting it for even a minute keeps the critical forgery exploitable.
[2026-07-26] v1 idempotency hash folds dry_run only when true, not as an unconditional boolean: including dryRun:false would change the hash of every ordinary write, and with a 24h idempotency TTL any key in flight across the deploy would fail the request_hash comparison and 409 on a legitimate retry. Both hash call sites now go through one shared buildRequestHash so they cannot drift into a permanent cache miss.
[2026-07-26] documents storage policies compare the company path segment as text instead of casting ::uuid the way the sie-files precedent does: the bucket holds keys whose second segment is not a UUID (MCP audit packages write {userId}/audit-packages/...), and Postgres does not guarantee the bucket prefix qual is evaluated before the cast, so a planner reordering would raise 22P02 and fail the whole query instead of filtering the row out. INSERT is scoped to company membership only, not additionally pinned to auth.uid(), because e-post inbox, bank sync and Bolagsverket legitimately upload on behalf of another member.
[2026-07-26] The journal_entries!inner sweep left the two free text ilike legs of the MCP DISPLAY_SELECT on the embed: each is capped at legLimit and that cap drives the legCapHit/truncated contract, and fetchEntryLines has no limit, so converting would turn a bounded fetch into an unbounded one. app/api/bookkeeping/accounts/[number] moved to the existing get_account_usage_counts RPC rather than the helper, because the embed there was a head:true count and the helper returns rows.
[2026-07-26] Error tracking landed as a provider agnostic sink with a no-op default and zero dependencies rather than a vendor SDK: no dependency was approved and an AGPL-3.0 project audits its surface, and lib/logger.ts already emits structured JSON so a Vercel log drain or a 60 line fetch adapter may make the dependency unnecessary. Redaction moved from lib/logger.ts to lib/observability/redact.ts so logger and sink share one denylist and no path to a third party can skip the personnummer regex; cron failure reporting uses threshold 1 and a 15 minute throttle rather than backup-alert's 3 and 7 days, because suppressing the first occurrence of a failure is the exact bug this work exists to fix.
[2026-07-26] buildInvoicePaymentClearingLines now throws InvoiceBookingRateMissingError (new code MATCH_INVOICE_BOOKING_RATE_MISSING) instead of defaulting a missing invoice.exchange_rate to 1 on the cross-currency path: `?? 1` valued a 1000 EUR receivable at 1000 kr, so an 11 496,70 kr settlement posted 10 496,70 kr to 3960 as a phantom kursvinst with revenue understated by the same amount, and the verifikat balanced so no trigger fired. Kursvinst/kursförlust is by definition the difference between the settlement value and the booked value (ML 8 kap 21-23§, K3 kap 30), so with no booking rate there is no second number and nothing may be posted. A new code was added rather than reusing BATCH_FX_RATE_MISSING, whose Swedish text says "innan du fördelar" and belongs to the batch-allocation flow; the guard bounds (null, <=0, >=100000) deliberately mirror that RPC. "Currency is SEK" stays a separate condition from "rate is missing": a SEK invoice never consults the rate and cannot throw.
[2026-07-26] verifikation.draft's period gate now calls resolvePeriodStatusForDate instead of an inline fiscal_periods query: it had selected fiscal_periods.status and locked_through, neither of which exists, so the whole select errored, period_status was always null and the BFL lock guard never once fired. The shared helper is the same two layer source (company_settings.bookkeeping_locked_through, then is_closed/locked_at) the DB triggers, the MCP server and the v1 REST gate use, so this surface cannot drift from them. A local 'unknown' status was added rather than widening the helper: the helper swallows PostgREST errors and returns 'open', and this intent must fail closed, so a thrown lookup now renders an explicit "do not assume the period is open" instruction plus, for a posted entry, the storno only rule from BFL 5 kap 5 §.
[2026-07-26] Supplier-invoice booking now refuses a foreign-currency invoice with no exchange rate (SI_FX_RATE_MISSING) instead of inheriting resolveSekAmount's "return the raw foreign amount" fallback: the fallback scaled every leg by the same wrong factor, so a 1000 EUR EU reverse-charge invoice booked 250,00 kr of fiktiv moms on 2614/2645 instead of 2875,00 kr at 11,50 SEK/EUR, the entry still balanced, no trigger fired, and rutorna 20/21 + 30 of the momsdeklaration were understated (oriktig uppgift, SFL 49 kap 4 §). Precedent: the match_batch_allocate RPC hard-fails with BATCH_FX_RATE_MISSING and lib/reports/supplier-ledger.ts skips such invoices rather than faking 1:1. The guard was applied to every conversion in the file, not only the three VAT/basis helpers, so no leg (expense, moms, basbelopp, 2440) can post at a fabricated rate; resolveSekAmount itself was deliberately left alone as a separate root fix. SEK invoices and any invoice with a positive stored or settlement-derived rate are byte-identical to before.
[2026-07-26] voucher_gap_explanations inserts are centralized in recordVoucherGapExplanation (lib/bookkeeping/cancel-orphaned-entry.ts): the four copies all named nonexistent gap_number/created_by and omitted the NOT NULL user_id/gap_start/gap_end, so no CAS-race gap has ever been documented. A single stranded voucher N is written as the closed range gap_start=gap_end=N because every reader (voucher-gaps UI, gnubok_list_voucher_gaps, checkYearEndReadiness) keys on voucher_series:gap_start:gap_end. A failed insert is logged at error level with the full payload rather than failing the request: the caller is already returning the correct CAS-conflict response and a 500 would be a worse answer; 23505 counts as already-documented. The two v1 routes keep their "skip the gap row when the orphan has no voucher_series" policy and cancelOrphanedPaymentEntry keeps its 'A' fallback; only the insert was unified.
[2026-07-26] Declined to promote the "Aktiekapitalnoten saknas" source warning to a filing-stage error in lib/bokslut/arsredovisning/completeness.ts: the swedish-financial-reporting skill puts antal aktier/kvotvärde under ÅRL 5:34 § (större företag only) and does NOT list a share-capital note among the K2 mandatory noter (BFNAR 2016:10 kap. 18-19); ÅRL 5 kap 14 §, which build-data.ts:646/662/935 cites for that note, is Ställda säkerheter, a different note the builder always emits and completeness.ts already gates with AR-NOTE-SECURITIES-UNCONFIRMED (error). ok:true is only reachable for K2 + aktiebolag + size_classification 'smaller' (K3 always errors via AR-K3-DRAFT-ONLY, larger/unknown via AR-K2-LARGE/AR-K2-SIZE-UNKNOWN), i.e. exactly the mindre-företag population ÅRL 5:34 § does not reach, so no mandatory note is in fact missing on a passing filing and promoting it would over-block filings against a hard 7-month Bolagsverket deadline. Separately noted, not fixed: report.warnings reaches completeness.ts as untyped string[], so per-warning severity would require coded warnings from build-data.ts (not owned in this pass).
[2026-07-26] lockPeriod's pre-lock guard now counts the canonical worklist "att bokföra" predicate (is_business IS NULL AND is_ignored = false) instead of journal_entry_id IS NULL AND is_business = true: triage is what sets is_business, so the old pair could never describe an untriaged row, and prod confirmed it (18 342 untriaged transactions, none matched; 74 of them are already stranded behind 3 locked periods where enforce_period_lock now makes them unbookable in place, a BFL 5 kap 2 § problem). The journal_entry_id clause was dropped rather than kept alongside, because lib/transactions/is-booked.ts documents that bulk-booked and multi-allocated transactions keep journal_entry_id NULL while anchored to a verifikat, so it blocked on already-booked rows (1 756 matches in prod, mostly sandbox-seed fixtures) while missing every real one. Kept as a hard block, not the bypassable-dialog pattern from the soft-guard rule: that rule covers advisory heuristics whose premise can be genuinely false, whereas "these rows are untriaged" is a deterministic fact, the two triage outs (mark private, ignore) are one click each and both clear the guard, and unlockPeriod already provides an audited escape, so it is not a dead end. Message now names the count and points at Transaktioner, keeping the words "saknar bokföring" the lock route regex-maps to PERIOD_HAS_UNBOOKED_TRANSACTIONS. Not fixed: the byte-identical dead guard in extensions/general/mcp-server/server.ts gnubok_lock_period (not owned in this pass), so the MCP lock path still stages a lock over untriaged rows.
[2026-07-26] bulk-book's mixed-currency guard was hoisted above the branch split in app/api/transactions/bulk-book/route.ts rather than copied into each branch: the check only existed on the template branch, so manual_lines and existing_journal_entry_id reached the RPC, which summed v_tx.amount across currencies and turned 100 EUR + 100 SEK into the scalar 200 on a samlingsverifikat whose belopp matched no affärshändelse. Legal basis cited as BFL 4 kap 6 § (one redovisningsvaluta) plus BFL 5 kap 7 § ("belopp"), NOT the BFL 5 kap 2 § the MCP twin cites: 5 kap 2 § is the bokföringstidpunkt rule (kontant senast påföljande arbetsdag, övrigt så snart det kan ske) and says nothing about currency. Kept as a hard block with no "book anyway" escape, unlike the soft-guard-dialog rule: that rule covers advisory heuristics whose premise can be false, whereas there is no correct amount to bypass to here, so the dialog shows honest per-currency subtotals instead of one summed scalar and never renders the booking UI. The same guard was added to the SQL RPC (20260726100000) even though the route now blocks it, because EXECUTE is granted to authenticated (a browser session can call the RPC straight over PostgREST) and lib/pending-operations/commit.ts commitBulkBookTransactions is a thin pass-through with no currency check. NULL currency is COALESCEd to 'SEK' in all three layers, since transactions.currency is `text default 'SEK'` and nullable; the MCP twin's un-normalized `new Set(txs.map(t => t.currency))` therefore still false-positives on a legacy NULL + SEK batch (server.ts not owned in this pass).
[2026-07-26] Finished the half-applied match-invoice FX conversion instead of reverting it: the v1 route's switch to buildInvoicePaymentClearingLines was already complete end to end (guard, Riksbanken/manual rate, shared line builder, invoice_payments amount+currency+exchange_rate, match-log provenance), and only the test's engine mock had never been updated, so the two red tests were a stale mock (createInvoicePaymentJournalEntry) and not a broken route. The mock now exports createJournalEntry + findFiscalPeriod and the assertions check the emitted clearing lines (bank leg on the transaction's own resolved account, 1510 credit) rather than a delegate call that no longer happens. Also fixed as part of making the two doors agree: (a) the dashboard match-invoice route soft-failed the new InvoiceBookingRateMissingError, which would have marked an invoice paid with no verifikat while 1510 still carried the receivable, an unrecoverable half-state since mark-paid rejects 'paid' invoices; it is now fatal and returns the registered MATCH_INVOICE_BOOKING_RATE_MISSING 400, matching v1. (b) the dashboard route now re-propagates invoice.default_dimensions onto the clearing lines, which createInvoicePaymentJournalEntry did and the shared line builder does not, so a project's kursvinst/kursförlust stays in the project P&L on both surfaces. Both FX-missing dispatches key on the error's `code` string literal, not instanceof, for the same reason the supplier routes do: route tests vi.mock the defining module away, and an imported-but-mocked-away constant would degrade `code === undefined` into a catch-all.
[2026-07-26] Left the six naive Math.round(x*100)/100 calls in the supplier-invoice match preview alone rather than swapping them for roundOre to shrink the check:guards naive-ore-round delta: roundOre applies a Number.EPSILON nudge and therefore disagrees with the naive form at exact-half öre, and the POST handler plus createSupplierInvoicePaymentEntry both use the naive form. Converting only the preview would reintroduce the exact bug the preview rewrite exists to fix (user approves one number, a different one is booked). Converting both sides is a wider money-math change than this pass owns; the honest fix is a shared amount-resolution helper for preview and POST.
[2026-07-26] AGI filing state now resolves per salary run in lib/salary/agi-submission-state.ts, not per period, and the fix is application-code only: no migration. The extension_data cache key agi_submission_{period} really is period-scoped by design (see the 2026-07-13 entry) and agi_declarations really is UNIQUE per company+period, but salary_runs is not: migration 20260414130000 replaced the period unique constraint with a partial index WHERE status != 'corrected', so a correction run legitimately coexists with the run it corrects in the same month and the two share one cache record. Consequence before the fix: deriveAgiFilingState returned 'signed' for a correction run off the ORIGINAL run's record, so the rail rendered the AGI step done and displayed the original's kvittensnummer while nothing had been filed for the correction. Per swedish-payroll references/agi-filing.md a correction is a complete resubmission for the same redovisningsperiod with the same specifikationsnummer, i.e. its own submission with its own kvittens, so run-scoping is the correct data model and no schema change is needed to express it. The record is now matched to the run by salaryRunId when the extension recorded one (/agi/submit and the MCP submit path do), else by signeradTid == run.agi_submitted_at for signed records on an already-filed run, else by refusing any record older than the run's own agi_generated_at (and any record at all when the run has never generated XML). Declined to widen the agi_declarations unique index to one row per salary run: it would need generate-declaration.ts to insert per run instead of upserting per period (not owned in this pass) and the reported bug is fully fixed in derivation. Known residual, not owned here: the original run's kvittensnummer is genuinely overwritten in agi_declarations when the correction is filed (one row per period), so a superseded run renders as filed without a receipt number rather than with the wrong one.
[2026-07-26] previewCurrencyRevaluation now prices each currency through fetchExchangeRate (per currency, with the supabase exchange_rates cache) instead of fetchMultipleRates, and executeCurrencyRevaluation throws ClosingRateUnavailableError before posting when any balansdagen rate is missing: fetchMultipleRates pads its Map with getFallbackRate, whose EUR 11.5 / USD 10.5 / GBP 13.5 constants riksbanken.ts:211-214 documents as display-only and "the booking path never returns them", yet the revaluation posted a real 3960/7960 verifikat computed from them, so a Riksbanken 429 at 05:00 silently produced an orealiserad kursvinst off a hardcoded number. The permitted rate sources are the Nasdaq OMX mid-rate published by Riksbanken or the ECB rate (ML 8 kap 21-23 §), and monetary items must be revalued to balansdagskurs (ARL 4 kap 13 §), so a constant is not a rate and refusing is the only correct outcome; fetchExchangeRate returns null rather than a constant, which is why it is the booking path. A new code FX_CLOSING_RATE_UNAVAILABLE (502, retryable) was added rather than reusing SI_FX_RATE_MISSING, MATCH_INVOICE_BOOKING_RATE_MISSING or TX_EXCHANGE_RATE_UNAVAILABLE: the first two are 400s about a missing stored rate on a row that the user fixes by typing it in, whereas this is the market observation not being published, is not user-fixable, and belongs in the existing FX_ namespace next to FX_PERIOD_NOT_FOUND / FX_PERIOD_CLOSED / FX_FAILED. The refusal is all or nothing and is checked ahead of the empty-preview null shortcut, because a partial post would understate the FX result while looking like a complete balansdagen valuation, and returning null when every currency lacks a rate would report "nothing to revalue" for "we could not value it". Separately, the `.not('exchange_rate','is',null)` filter was dropped from both fetchers: it hid the rows with the largest unmeasured exposure, which are now partitioned out and returned as unconvertedFx / unconvertedFxCount exactly as lib/reports/supplier-ledger.ts surfaces unconverted_fx_count (same "present and > 0" rule, so a 0 rate no longer values the whole receivable as a kursvinst). Those rows report but do not block; only a missing closing rate blocks. Preview never throws so the year-end readiness page still renders. Known residuals, not owned in this pass: errorResponse dispatches this class on path 4, which passes no details, so the REST envelope carries the registry's generic Swedish sentence and the per-currency naming only appears where getErrorMessage sees the instance (MCP getStructuredError) or the envelope carries details.missingRates; and the v1 operation result plus lib/pending-operations/commit.ts commitRunCurrencyRevaluation drop unconvertedFxCount from their payloads.
[2026-07-26] lib/invoices/supplier-invoice-matching.ts now refuses to compare amounts across currencies in passes 2-4: same currency compares raw magnitudes, different currencies compare stored SEK values only, and an invoice with no stored conversion is skipped as a candidate rather than scored. Without this a 1000 EUR supplier invoice "exact matched" a -1000 SEK bank debit at 0.85 confidence on every bank import, offering the user a confident match between amounts differing by a factor of about 11. Two deliberate deviations: (a) the local sekValue helper is lib/bookkeeping/currency-utils.ts#resolveSekAmount minus its final "no conversion info, return the amount as-is" fallback, because that fallback is safe for booking legacy rows but here would hand the matcher a raw EUR total dressed up as kronor, i.e. the exact false match being fixed; (b) an excluded candidate is dropped silently instead of being surfaced with a reason, because findSupplierInvoiceMatch returns a single `SupplierInvoiceMatch | null` and both callers (lib/transactions/ingest.ts, lib/bookkeeping/handlers/supplier-invoice-handler.ts) only ever write a potential_supplier_invoice_id, so there is nowhere to carry a reason without a breaking signature change. Pass 1 (OCR/payment reference) stays currency-agnostic on purpose: the reference identifies the invoice on its own and no amount is involved. Pass 4's 5-unit fuzzy tolerance is expressed in whatever unit the comparison settled on (SEK when cross-currency, the shared currency when both sides already agree), which preserves the pre-existing behaviour for same-currency rows. SEK-only companies take the same-currency path and are byte-for-byte unaffected.
[2026-07-26] The ingest content-dedup currency fix was applied as a per-entry guard inside consumeBridgingTwin (lib/transactions/ingest.ts), NOT by adding currency to contentBucketKey: the bucket keys on (date, öre) only, so a stored 250,00 SEK row and an incoming 250,00 EUR row shared a bucket and the text bridge or the cross-channel mirror could consume either for the other, dropping a real affärshändelse that is then never bokförd (BFL 5 kap 2 §) with only an aggregate duplicate count as evidence. Only the booked-hand-entered mirror had ever carried a currency check; that check was hoisted to the loop so all three match paths (text bridge, cross-channel mirror, hand mirror) share it. Widening the key was rejected for two reasons: contentBucketKey lives in lib/transactions/external-id.ts next to buildStableExternalIds, whose `eb_{iban}_{date}_{öre}_{n}` template is a frozen persisted key, and a currency component in the key would send legacy currency-NULL rows to a different bucket than incoming SEK rows and stop deduping them, re-opening the fleet-wide re-import the June 2026 incident was about. The guard keeps the established null-tolerance (null on either side is compatible), so SEK-only and legacy companies are byte-for-byte unaffected. The cross-channel mirror's count maps were deliberately left currency-blind: a coarse count can only turn the mirror ON, after which the per-entry guard still refuses the match and the row inserts, so the failure direction is a visible deletable duplicate rather than a silent drop. A log.info was added on the content-bridge drop because result.duplicates reaches the API response and bank_file_imports.duplicate_count but no import UI renders it and it cannot distinguish this judgement call from an exact Layer-1 id collision; surfacing skipped rows in BankFileResultStep and the Enable Banking sync toast is left as a follow-up (not owned in this pass).
[2026-07-26] Removed the surviving 6-month first-year floor from lib/onboarding-journey/fiscal-options.ts (abFirstYearEndOptions) and rewrote the four copy sites that still stated it as law. PR #1165 (2026-07-25) already deleted the same invented floor from validatePeriodDuration: BFL 3 kap sets no minimum for a first räkenskapsår, only the 18-month maximum, so an autumn-registered AB may end its first year at 31 Dec. The onboarding journey is the surface a brand new AB actually walks through, and it silently withheld exactly those short options while efFirstYearEndOptions offered them, so AB and EF disagreed for no stated reason. The floor became `months >= 1`, a structural guard (it drops end months that fall before the start month, which would otherwise produce zero or negative spans), not a legal one. journey_fyend_ab_info and journey_fy_info are the worst of the copy: they cited "enligt bokföringslagen" / "under the Bookkeeping Act" for a constraint that does not exist. They now cite the Act only for the 18-month cap, which is real, and state plainly that there is no minimum. The 18-month maximum is enforced twice on this path (option generation never emits > 18, and computeFiscalPeriod runs validatePeriodDuration before submit), so removing the fake floor left the real ceiling intact.
[2026-07-26] lib/reconciliation/bank-reconciliation.ts now resolves every ledger amount through one local helper, ledgerLineAmountIn(line, currency), shared by the matcher, the status card and the auto-link path, rather than each site re-deriving debit/credit. journal_entry_lines.currency labels the DOCUMENT, not the debit/credit columns: lib/bookkeeping/currency-utils.ts converts a foreign amount to SEK for debit_amount/credit_amount and then stamps currency + amount_in_currency onto the same line, so every "the currencies match, so these amounts are comparable" guard passed on exactly the FX rows it existed to catch. The helper returns debit - credit on SEK (never null, so SEK-only companies are byte-for-byte unaffected), and on a foreign currency takes the magnitude from amount_in_currency and the direction from the debit/credit side (some rows store a negatively signed amount_in_currency; the ledger side is authoritative), returning null when the row carries no amount in that currency. Null is reported, never converted with an invented rate: getReconciliationStatus counts those rows as unconvertible_gl_line_count and sets not_reconcilable_reason = 'gl_lines_missing_currency_amount' instead of publishing a difference computed over a subset, and autoReconcileTransactionForLinkedVoucher (which PERSISTS a link) returns null and leaves the row for manual matching. Separately, is_reconciled now requires BOTH a zero net difference AND unmatched_transaction_count === 0: a net-zero difference alone made two offsetting unmatched rows read as avstämt while both were still unbooked affärshändelser each owing its own verifikation identifying belopp and motpart (BFL 5 kap 1-2 §, 6-7 §), and ÅRL 2 kap's individuell värdering and bruttoredovisning forbid treating two offsetting unknowns as knowledge. unmatched_gl_line_count is deliberately NOT in that condition: it is scoped and windowed differently (it counts vouchers not settled on THIS account, so the far leg of an own-account transfer belongs there by design). Declined to extend get_unlinked_gl_lines / get_account_gl_lines_for_matching with currency + amount_in_currency: that needs a migration this pass does not own, so on a foreign account the batch matcher now proposes NO candidates instead of wrong ones (fail safe), while getReconciliationStatus reads journal_entry_lines directly and does reconcile in EUR. Follow-ups not owned here: those two RPCs, a UI surface for not_reconcilable_reason (needs sv+en strings), and lib/bokslut/readiness-aggregator.ts, whose "differens på X kr" fallback message reads oddly when the reason code is set.
[2026-07-26] customers_personal_number_check was re-pointed at the ciphertext shape (migration 20260726110000) instead of moving the encrypted value into a new differently-constrained column. The column was added as plaintext by 20260522130000 with a CHECK on the personnummer format; since the 2026-07-15 encryption change the write path stores AES-256-GCM hex (76-82 chars), which no personnummer regex can ever match, so every write was rejected and prod holds 0 non-null personal_number rows across 4957 customers (1355 of them individuals). Widening is honest rather than a loosening: the format guarantee was destroyed by the encryption change, not by this migration, and a CHECK cannot validate a personnummer it can no longer read; plaintext format validation stays in CreateCustomerSchema/UpdateCustomerSchema, which run before the cipher. The constraint now enforces the opposite and still-enforceable guarantee, that the column must never hold a bare personnummer: lowercase hex only, 76 to 255 chars (255 is the highest repetition count a Postgres POSIX regex accepts), so every plaintext form is too short and/or carries a separator and an unencrypted write fails loudly instead of persisting PII. A separate personal_number_encrypted column was rejected: it would add a third storage shape for the same data next to employees.personnummer and invoices.deduction_personnummer_encrypted, which are both encrypted TEXT with no format CHECK, and would touch every read site for zero rows of benefit. Separately, the "a masked value means leave it alone" rule moved from components/customers/CustomerForm.tsx into the PATCH write path, which required accepting the '********-1234' sentinel in UpdateCustomerSchema (the only edit to lib/api/schemas.ts) so the route can see and ignore it; CreateCustomerSchema stays strict because on create there is no stored value to preserve. Known residual, not owned in this pass: app/api/export/customers/route.ts writes c.org_number ?? c.personal_number into the "Org-/personnummer" column of the kundregister export, which will emit raw ciphertext now that rows can exist, and lib/customers/protect-personal-number.ts lets a decrypt failure throw out of maskCustomerRow, which would 500 the whole customer list.
[2026-07-26] The three inline underlag-link loops (JournalEntryForm handleConfirm and handleSaveDraft, QuickReviewDialog handleConfirm) now go through lib/documents/link-documents.ts#linkDocuments, which checks res.ok and returns a per-document verdict; all three previously counted only thrown errors, so any 4xx/5xx from POST /api/documents/{id}/link (PERIOD_LOCKED, DOC_LINK_ENTRY_NOT_FOUND, a 500) left the verifikat without the underlag BFL 5 kap 7 § requires it to reference and BFL 7 kap requires archived with it, while the user got a green "Verifikation skapad" and the files were cleared. The success toast is now branched rather than preceded by a separate warning toast: use-toast has TOAST_LIMIT 1, so the destructive toast the two counting sites did emit was immediately evicted by the success toast added right after it, meaning the warning was never actually visible. Files that failed to link are kept in the upload zone (the failed ones only; the linked ones are dropped) because clearing them erases the user's only pointer to the underlag they believed was filed; the accepted residual is that a leftover file would attach to the NEXT entry booked from the same open form, which is loud and visible rather than silent. No new retry feature was invented: /bookkeeping/{id} already renders JournalEntryAttachments (upload plus "Välj från inkorgen") for both posted entries and drafts, so the toast names the missing files and carries a ToastAction that navigates there. TransactionBookingDialog was left untouched as the correct reference (it already checked res.ok); it still clears its files unconditionally, which is the one behaviour the new sites deliberately do not copy.
[2026-07-26] extensions/general/arcim-migration/lib/entity-mapper.ts now resolves a real SEK conversion for migrated invoices instead of the dead ternary `exchange_rate: dto.currencyCode === 'SEK' ? null : null`, which returned null on BOTH branches and left every foreign-currency invoice the migration imported unconverted, alongside null subtotal_sek / vat_amount_sek / total_sek. The provider DTOs (lib/providers/dto.ts) offer NO rate and NO SEK amount: a SalesInvoiceDto/SupplierInvoiceDto carries only currencyCode plus amounts already expressed in that currency, so the rate had to be fetched. It is fetched for the invoice's OWN issueDate, never today, via the in-house pattern from lib/transactions/ingest.ts: fetchExchangeRate(currency, new Date(date), supabase) with the supabase client so the shared exchange_rates cache absorbs repeat dates, pre-resolved once per unique (currency, date) pair at concurrency 4 so a wide historical backfill is not rate-limited by Riksbanken. An imported invoice is rakenskapsinformation (BFL 7 kap) and its SEK value is part of the record, so stamping it with an import-day rate would misstate it. Three deliberate choices: (a) an invoice whose rate cannot be established (currency outside Riksbanken's series, or no observation for that date) is still IMPORTED, not skipped, because dropping it would lose rakenskapsinformation; it keeps exchange_rate = null so lib/bookkeeping/supplier-invoice-entries.ts and lib/bookkeeping/invoice-payment-lines.ts refuse it loudly (SupplierInvoiceFxRateMissingError / InvoiceBookingRateMissingError) rather than posting at a fabricated 1:1, and it is counted into MigrationResults.{salesInvoices,supplierInvoices}.fxUnresolved plus a per-invoice console.error so the migration reports it instead of passing it off as an ordinary import; (b) the "needs attention" marker was NOT written into invoice.notes, which was the obvious in-product surface, because lib/invoices/pdf-template.tsx renders notes on the customer-facing PDF and a Swedish exchange-rate warning would be printed on a real faktura; (c) the fxRates parameter is optional rather than required so the existing entity-mapper tests and any other caller keep compiling, and the failure mode when it is omitted is a reported rate_unavailable, never a silent 1:1. The SEK branch now writes a plain commented null for exchange_rate (a domestic invoice has no rate to record) while still filling the *_sek columns via a sekFactor of 1, and `dto.currencyCode` undefined now normalises to SEK consistently, which also fixes the old mismatch where currency was written as 'SEK' but subtotal_sek came out null. Follow-ups not owned in this pass: components/extensions/general/ArcimMigrationWorkspace.tsx duplicates the MigrationResults type and does not render fxUnresolved, so the count reaches the API response but no UI yet; mapSalesInvoice still writes your_reference/our_reference as null although SalesInvoiceDto carries buyerReference/orderReference; mapSupplier hardcodes default_currency 'SEK'; and mapSalesInvoice's paid_amount lacks the Math.max(0, ...) floor its supplier-invoice counterpart has.
[2026-07-26] The momsdeklaration import pair (IMPORT_BASE_WITHOUT_OUTPUT / IMPORT_OUTPUT_WITHOUT_BASE) was converted from a binary presence test to the proportional form PR #1164 gave the RC pair, comparing ruta 50 against ruta60/0.25 + ruta61/0.12 + ruta62/0.06 with the same max(1 kr, 0.5%) tolerance; the sales pair (TAXABLE_SALES_WITHOUT_OUTPUT / OUTPUT_VAT_WITHOUT_SALES_BASE) was deliberately LEFT binary. Binary was unsound on both: a period where one import voucher carries both halves cleared the check while the rest shipped tullvardesunderlag with no utgaende importmoms, i.e. undeclared moms and skattetillagg 20 % under SFL 49 kap 4 §. The import implied base is exact because ACCOUNT_RUTA feeds ruta 50 only from 4545/4546/4547 (Import av ravaror och material, 25/12/6 % moms) and rutorna 60/61/62 only from 2615/2625/2635, a closed one-to-one set at exactly those three rates, with VAT-free import on the unmapped 4540. The same arithmetic is NOT exact on the sales side, which is why it was not applied there: rutorna 07 (VMB) and 08 (frivillig uthyrning) have no source accounts in ACCOUNT_RUTA at all while their output moms (2616/2626/2636, 2613/2623/2633) does feed rutorna 10-12; ruta 05 recognises only 3001/3002/3003 while lib/bookkeeping/invoice-entries.ts lets any per-line revenue_account override land the moms on 2611/2621/2631 anyway; and periodiserade lines credit 29xx with the moms left undeferred on 2611, then credit 3001 with no moms on dissolution, so the two sides drift in both directions by design. Any of those would make a proportional sales check a permanent filing-blocking ERROR (isFilingBlocked) on a correct declaration. Mapping rutorna 07/08 and reconciling the revenue_account override is the prerequisite for converting the sales pair.
[2026-07-26] Addendum to the currency-revaluation entry above: the `remaining_amount <= 0` skip for payables was moved out of the item loop and into the new partition loop, so a fully settled foreign-currency leverantorsskuld is now dropped before its currency is collected. Consequence, and the reason for the move: such a row has no exposure to revalue, so it must neither be counted into unconvertedFx (it is not unmeasured exposure, it is nothing) nor pull a closing rate whose absence would then refuse the whole balansdagen posting. Keeping the old order would have let a settled row block a valuation it cannot affect.
[2026-07-26] components/salary/AGIPanel.tsx narrows the AGI submission record for the FILING RECEIPT only (a locally derived runSubmission = resolveRunAgiSubmission({ id: salaryRunId, agi_generated_at, agi_submitted_at }, submission)) instead of receiving an already-narrowed record as its `submission` prop, and app/(dashboard)/salary/runs/[id]/page.tsx therefore keeps passing the raw period record down while routing the progress rail through resolveRunAgiKvittensnummer. The panel computed isSigned = submission?.status === 'signed' || !!agiSubmittedAt independently of deriveAgiFilingState, so on a corrected month (salary_runs is unique per period only for non-corrected runs, migration 20260414130000, while the cache key is agi_submission_{YYYYMM}) the correction run read the original's signed record, rendered as already filed, printed the superseded declaration's kvittensnummer, and hid every filing action behind its `!isSigned` gate: the correction could not be filed from the UI at all. Swapping the whole prop for the narrowed record was rejected because the in-flight machine is genuinely period-scoped: Skatteverket's granskningsunderlag lock and lasUpp address a redovisningsperiod, not a run, and the signing-link card, the stale-draft warning and the "Lås upp" button all hang off awaitingSigning. Narrowing that too would delete the only recovery path in a reachable state, since lib/salary/agi/generate-declaration.ts re-stamps agi_generated_at on every call, so one click on "Ladda ner AGI-fil" while a draft is locked pushes the run's XML past the record and resolveRunAgiSubmission stops claiming it; it would also strand a correction whose period is still locked by the original's draft, which is exactly the case where the unlock button is the required next step. A correction is a complete replacement declaration for the same period (same specifikationsnummer per employee, references/agi-filing.md) filed on its own and receiving its own kvittens, so an original whose cached receipt a later correction has replaced now renders as filed WITHOUT a number rather than with the successor's. No AGIPanel props changed and no new UI strings were needed. Not owned in this pass: the gnubok_agi_status MCP tool (extensions/general/mcp-server/server.ts) returns the same raw period record as local_state keyed by salary_run_id and has the identical bug.
[2026-07-26] The momsdeklaration filing gate was extracted into lib/reports/vat-filing-gate.ts (withRcBasisGapFindings + isFilingBlocked) and the per-verifikat rc-basis-gaps scan is now folded into the SAME check array the "Kontroll av underlaget" banner, the stegen counters and "Skicka till Skatteverket" all read, because checksBlocked previously derived from runVatDeclarationChecks alone: that aggregate compares period totals against max(1 kr, 0.5 % of the implied basis), so at a 400 000 kr RC basis up to 2 000 kr of missing basbelopp hides inside the tolerance and the green "Inga fel hittades i underlaget for perioden" rendered directly above the worklist of the very verifikationer that made the declaration wrong, with Skicka enabled. The per-voucher scan is authoritative because each gap is fiktiv moms on 2614/2624/2634 with no basbelopp on 44xx/45xx, which understates rutorna 20-24; vid omvand skattskyldighet ska bade beskattningsunderlaget och den fiktiva momsen redovisas (tyst kvittning ar inte tillaten), so the declaration is incomplete, not merely suspect, and the finding blocks exactly like the aggregate ERROR it stands in for. Blocking rather than warning is not a dead end: the one-click Korrigera worklist sits under the finding and VatManualFilingCard (eSKD-XML + PDF, and the /api/reports/vat-declaration/eskd and /pdf routes behind them) is deliberately ungated, so only the direct SKV submission is gated. The 0.5 % tolerance itself was left untouched in vat-declaration-checks.ts. Three scan states are kept apart: only a settled count of zero means "inga brister", a FAILED scan adds a non-blocking WARNING row (a network hiccup must not lock a user out of a statutory deadline, but an empty list would render as an all-clear the scan never earned), and an in-flight scan says nothing. Because "says nothing" is only safe while the card is unmounted, the scan result is tagged with a PERIOD key rather than the retryKey-bearing fetchKey: steg 1 stays mounted across a korrigering, so re-tagging on retryKey dropped the gate open for one round trip after every Korrigera click and re-exposed the exact banner-above-worklist regression. Stale-but-closed is the safe direction and matches the declaration, which stays on screen dimmed while the next one loads.
[2026-07-26] Both mark-paid surfaces (app/api/invoices/[id]/mark-paid/route.ts and app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts) now resolve paymentAmountInInvoiceCurrency BEFORE the duplicate-payment guard instead of after it, and refuse a non-SEK invoice that carries no usable exchange_rate with 400 MATCH_INVOICE_BOOKING_RATE_MISSING instead of dividing by a `?? 1` fallback. Two separate defects sat in the old shape. First, the guard compared the raw SEK sum of the custom lines against remaining_amount, which is stored in the INVOICE currency: total/paid_amount/remaining_amount have no _sek twins (core_schema 20240101000001 gives subtotal/vat_amount/total a _sek column each but paid_amount none, 20260323120001 backfills remaining_amount as GREATEST(0, total - COALESCE(paid_amount,0)), and match_batch_allocate 20260531120000 writes exactly v_paid_in_inv_currency into both), so a 5 748,35 kr payment on a 1 000 EUR invoice at 11,4967 read as a full settlement, ran the advisory on a genuine 500 EUR partial, and 409'd INVOICE_PAID_LIKELY_DUPLICATE on the matching bank row. Second, the `?? 1` fallback made an 11 496,70 kr payment on a rate-less 1 000 EUR invoice arrive at planInvoicePayment as 11 496,70 EUR, i.e. remaining 1 000 - 11 496,70 = -10 496,70, which only survived as a MATCH_AMOUNT_EXCEEDS_REMAINING that blamed the amount rather than the missing rate; below the remaining nothing caught it at all, so 500 kr booked as 500 EUR paid against a customer who had really paid ~43 EUR. MATCH_INVOICE_BOOKING_RATE_MISSING was chosen over BATCH_FX_RATE_MISSING because the latter is the match_batch_allocate RPC's own code (its Swedish text says "innan du fordelar", allocation wording that does not fit a manual mark-paid) while the former is the app-layer code buildInvoicePaymentClearingLines already throws for the identical condition on the bank-match path, and its registry remediation (structured-errors.ts:467) already tells the user to set invoice.exchange_rate: one condition, one code across every invoice-settlement surface. The gate is deliberately narrowed to `isForeignCurrency && customLines !== undefined`: the default no-lines path pays remaining_amount, which is already in invoice currency, so a rate-less foreign invoice can still be settled in full and needs no rate at all, and a SEK invoice never consults exchange_rate so paidRounded stays byte-identical to the old expression. The raw SEK paymentAmount is still what reaches findDuplicatePaymentCandidatesForInvoice, because that helper scans transactions.amount, which is kronor; only the comparison moved into invoice currency. Two pre-existing divergences between the two routes were left as found rather than widened into this diff: v1's guard reads `remaining_amount ?? total` while the dashboard route reads `remaining_amount ?? total - paid_amount` (differs only for legacy rows with a NULL remaining and a non-zero paid), and v1 validates custom-line balance before the FX gate while the dashboard route validates it inside settleInvoicePayment after the gate, so a request that is both unbalanced and rate-less gets INVOICE_PAID_LINES_UNBALANCED from one and MATCH_INVOICE_BOOKING_RATE_MISSING from the other (both 400, neither books). Not adopted: buildInvoicePaymentClearingLines' MAX_PLAUSIBLE_FX_RATE upper bound (rate >= 100000), so an absurd stored rate still divides the payment down to a near-zero partial on both routes; the shared error message already claims to cover "out of range", so closing that is a follow-up. Parity is guaranteed structurally, not by convention: both routes feed the converted amount into the same planInvoicePaymentForLines (lib/invoices/apply-invoice-payment.ts, FX-agnostic by contract), v1 directly and the dashboard route via settleInvoicePayment.
[2026-07-26] lib/invoices/rot-rut-file.ts now converts every belopp to SEK with the invoice's booking rate before rounding to whole kronor, and blocks a foreign-currency invoice that has no usable rate with a new MISSING_EXCHANGE_RATE blocker instead of emitting a guessed figure. Begaran.xsd V6 (vendored, dev_docs/skatteverket/husavdrag/V6/BegaranCOMPONENT.xsd) types BetaltBelopp and BegartBelopp as BeloppTYPE = xs:long 0..99999999999 and PrisForArbete as its own xs:long with minInclusive 2, and carries no currency attribute anywhere, so the begaran is a kronor document by construction; the file previously emitted raw invoice-currency amounts while the ledger leg (generateRotRutLines, lib/bookkeeping/invoice-entries.ts) already debited BAS 1513 in SEK, so a 625 EUR avdrag at 11,40 stood as 7 125 kr on 1513 while the begaran asked Skatteverket for 625 and the receivable could never clear. The conversion is single-sourced through deductionSekConverter() in rot-rut-rules.ts, which reproduces the ledger's per-amount Math.round(amount * rate * 100) / 100 exactly, so the two can only ever disagree by the whole-kronor rounding the XSD forces. Three deliberate choices: (a) a new per-invoice blocker code rather than reusing SI_FX_RATE_MISSING or MATCH_INVOICE_BOOKING_RATE_MISSING from structured-errors.ts, because this surface returns RotRutBlocker[] (code plus Swedish message) through ROT_RUT_INVOICES_BLOCKED and the eligible-list API, never a structured envelope, and both existing codes name a different document (supplier invoice, payment matching); (b) validateInvoice() takes an OPTIONAL fourth DeductionCurrencyContext argument so every existing caller keeps compiling and the SEK path stays byte-identical, with lib/invoices/build-invoice-write.ts passing only { currency: input.currency } because the Riksbanken rate is fetched further down the same function (the write needs the totals first) and reordering a shared write-builder for a warning string was not worth the risk; a foreign invoice therefore reports "kan inte stammas av mot arsmaximum, fakturan saknar vaxelkurs" rather than measuring a foreign figure against the 50 000 kr ceiling; (c) the missing-rate case is a WARNING in validateInvoice and a BLOCKER in rot-rut-file, because invoicing ROT work in a foreign currency is legal (ML lets any currency be used) and must stay creatable, while the statutory filing must not go out with a number nobody can defend. requested_total is now safe to sum because no unconvertible invoice can reach it. Left alone on purpose: the MIXED_DEDUCTION_TYPES rule at rot-rut-file.ts:177, whose remedy ("dela upp i separata fakturor") is impossible for an issued and paid invoice, stays UNRESOLVED pending a reading of whether two begaran may reference one fakturanummer; and computeDeduction() still takes 30 percent of the line total EXCLUDING moms while HUSFL and the skill both say ROT is 30 percent of arbetskostnaden INKLUSIVE moms, a separate under-deduction that predates this pass.
[2026-07-26] Supplier-invoice creation now REFUSES a foreign-currency invoice it cannot translate (400 SI_FX_RATE_MISSING) instead of persisting exchange_rate = NULL. All three writers (POST /api/supplier-invoices, POST /api/v1/companies/:id/supplier-invoices, invoice-inbox /items/:id/convert) previously took body.exchange_rate ?? null with no server-side fetch, which is what produced the permanently unconverted rows the booking path now rejects. Storing NULL only relocates the same failure to the booking step, where the user no longer has the invoice in hand, so the refusal happens at creation, before the ankomstnummer sequence is touched. The three surfaces share ONE resolver (lib/currency/supplier-invoice-rate.ts) rather than three copies of the policy: that shared function, not a convention, is what keeps them from drifting apart again.
[2026-07-26] FX rate for a supplier invoice is fetched for the INVOICE DATE, not delivery_date. ML 8 kap 21-23 § anchors the translation at the taxable event, which is delivery_date when it differs, so this is knowingly the looser of the two anchors; chosen because invoice_date is already the date the registration verifikat is posted on and the date the period-lock check runs against, and splitting the money anchor from the verifikat anchor for the minority of rows that carry a delivery_date is a bigger change than this fix. Flagged as an open refinement rather than changed silently. Source stays Riksbanken's SWEA series (the Nasdaq OMX mid-rate), the first of the two sources ML 8 kap 21 § permits.
[2026-07-26] supplier_invoices.total_sek is now written for SEK invoices too (rate 1, so total_sek = total). The old writers gated all three _sek columns on an exchange rate existing, and a SEK invoice never has one, so 1 238 of 1 595 prod supplier invoices across 540 companies had total_sek NULL. Migration 20260726120000 backfills the SEK rows and any foreign row that already carries a rate; the 9 foreign rows with no rate are left NULL on purpose because inventing a rate after the fact lands in rutorna 20-24 + 30-32 of the momsdeklaration. That migration is written but NOT applied.
[2026-07-26] Reminder paminnelseavgift is quoted in SEK on foreign-currency invoices instead of being converted or relabelled: Lag (1981:739) 2 § plus forordning (1981:1057) fix the fee at 60 kronor with no conversion rule, and Accounted books it 1510/3990 in SEK, so a converted figure would demand money the books do not record. The swedish-invoice-compliance skill only maps the fee to BAS 3930 and does not settle the foreign-invoice question, so the conservative option was taken. Reminder emails and the public /invoice-action page now show "1 010,00 EUR + 60 kr" rather than one mixed-currency scalar.
[2026-07-26] The KPI reader now resolves the SEK amount per supplier invoice instead of reading supplier_invoices.total_sek: for a SEK invoice the SEK total IS total (exact, not an approximation), so "Storsta leverantorer" no longer depends on the writer fix or on the 20260726120000 backfill landing. Prod evidence: 1 238 of 1 547 SEK rows and 9 of 48 foreign rows had total_sek NULL, and only 23 of 552 companies with supplier invoices had a single row where it was set, so the panel and the "Topp leverantorer" xlsx sheet were empty for essentially every company. The conversion goes through resolveSekAmount (lib/bookkeeping/currency-utils.ts) for parity with the ledger, EXCEPT its last branch: that branch returns the raw foreign amount as if it were SEK, which is exactly the silent wrongness this sweep is about, so a foreign invoice with neither total_sek nor an exchange_rate is counted in topSuppliersUnconvertedFxCount / the "Ej omraknade valutafakturor" xlsx row instead, mirroring unconverted_fx_count in lib/reports/supplier-ledger.ts. Both the query and the aggregation live in ONE place (topSupplierInvoicesQuery + aggregateTopSuppliers in lib/reports/kpi.ts, the module both routes already imported): a shared function, not a convention, is what stops the JSON route and the xlsx export from disagreeing about the same company, and it is asserted by a cross-route test that runs both handlers over identical rows.
[2026-07-26] Both worked-hours writers now persist start_time/end_time and the GET selects them back. UpsertWorkedDaySchema and BatchUpsertWorkedDaysSchema validated and documented the shift window, salary_worked_days.start_time/end_time exist in prod (added by 20260526120900_ob_overtime_premiums.sql, verified via information_schema, so no migration was needed), run-calculation.ts already SELECTs both and feeds them to computePremiumLines, and no other code path writes the table: the two API routes simply never included the fields in their INSERT, so every row in production carries NULL times. shift-premium-engine.ts then falls back to an assumed 08:00-17:00 day, which gives a pure-night OB rule (22:00-06:00) zero overlap and prices weekend work on nine fabricated daytime hours instead of the hours actually worked; a night shift was paid as office hours with no OB-tillagg at all. The batch route additionally reads the rows it is about to replace BEFORE deleting them and carries forward every per-day value the body omits (notes, salary_run_employee_id, the shift window), because the batch has ONE shared body for N dates and no way to express per-day values: the UI sends notes as `notes.trim() || undefined`, so "mark Mon-Fri as 8 h" wiped every note the user had written on those days. The single-day route deliberately keeps full-replace semantics rather than adopting the same preserve-on-omit rule: it addresses exactly one day and the caller can describe every field of it, so an omitted field there genuinely means "not set", and preserve-on-omit would make the window unclearable given the schema accepts optional-but-not-null (lib/api/schemas.ts was out of scope to change). NOT settled: whether the 08:00-17:00 fallback is defensible for the legacy NULL-time rows. .claude/skills/swedish-payroll/references/ob-overtime.md is a byte-identical copy of sick-pay.md (md5 68e7aba1..., wrong since the file was added in #202), so the skill carries no OB-tillagg or Arbetstidslagen content at all and the question was left unanswered rather than guessed from training data. Note for whoever fixes that file: the engine treats WorkedDayShift.hours as a documented "sanity cap" but never applies it (the only use is `hours <= 0`), so premium hours come purely from the window and a 4 h shift stored with a 12 h window would be over-paid.
[2026-07-26] The supplier_invoice.review "KANDA FAKTA ... fraga INTE om dessa" block no longer carries an AI-extracted amount whose currency is unknown or disagrees with the registered invoice: those values move to a separate "EJ BEKRAFTADE BELOPP" block that says the currency is missing (or names the conflict) and tells the agent to read the underlag before attesting. A number without a unit is half a fact, and half a fact under a "do not ask about this" header reads to the model as confirmation of the registered amount, which is how a 1 200 EUR invoice was being attested on the number 1 200. The alternative (keep the amount in the known-facts block and merely append a warning) was rejected: the header's whole function is to suppress questions, so the fix has to be removal from that block, not annotation inside it. The supplier name / org.nr / VAT-nr stay known facts, since they need no unit. Registered amounts now print their own currency plus the SEK leg from total_sek/exchange_rate when one exists, and an explicit "SEK-BELOPP SAKNAS" when it does not: the same shape inbox.bulk-book uses for the bank leg, and deliberately WITHOUT resolveSekAmount's last branch, which returns the foreign face value as if it were SEK.
[2026-07-26] The inbox "Bokfor valda" dialog (components/extensions/general/BulkBookInboxDialog.tsx) now shows per-currency subtotals instead of one scalar, but deliberately does NOT block submit on a mixed-currency selection, diverging from the sibling fix in components/transactions/BulkBookDialog.tsx. That dialog builds ONE samlingsverifikation, which must sit in a single redovisningsvaluta (BFL 4 kap 6 §), so refusing is the only correct outcome there. This dialog posts to POST /api/extensions/ext/invoice-inbox/items/bulk-book, which runs bulkBookMatchedInboxItems -> categorizeMatchedTransaction per item: ONE verifikat per underlag, and the booked belopp is read off that item's matched bank transaction, never off extracted_data.totals.total (lib/transactions/categorize-core.ts header: "Booking is always in SEK off the bank transaction's own amount"). A EUR invoice settled by the bank in SEK is therefore booked correctly, and a mixed selection yields a set of individually correct SEK verifikat, so hard-blocking would refuse a legal everyday batch (a receipt run where some suppliers invoice in EUR) and would also contradict the decision already documented in that same file, that currency must not drive behaviour because a Swedish seller can invoice in EUR and still debit 25% moms. The real defect was display-only and is fixed: the old `totalSek` summed totals.total across currencies AND rendered it through formatCurrency()'s SEK default, so a mixed batch added 100 EUR to 100 SEK and stamped "kr" on it, and even a homogeneous EUR batch read as kronor. Currency normalisation (missing/blank -> SEK, matching pickCurrency() and emptyExtraction()) lives in the pure helper components/extensions/general/bulk-book-inbox-totals.ts so a legacy null-currency underlag is not split away from its SEK siblings into a phantom currency mix; a malformed code also falls back to SEK because the value comes from AI extraction and Intl.NumberFormat throws RangeError on a bad code. NOT done: no server-side guard was added, correctly, since the server has nothing to refuse here.
[2026-07-26] buildInvoiceWriteData now fetches the customer-invoice rate for delivery_date ?? invoice_date and passes the supabase client, where it previously called fetchExchangeRate(currency) with neither. Two separate defects sat in that one call. The missing date stamped TODAY's rate on every invoice, so a back-dated or historical invoice booked 1510 and 2611 at the wrong SEK value; the swedish-invoice-compliance reference (ML 8 kap 21-23 §) is explicit that the rate to use is the one "at time of taxable event (delivery/supply date or advance payment date, not invoice date unless same)", and delivery_date is exactly that date whenever it is set, because ML 17 kap 24 § p.7 requires the delivery date on the invoice precisely when the two differ. The missing client meant the shared exchange_rates cache was consulted on NEITHER leg: not the read-through before Riksbanken, and not the last-cached-observation fallback after a 429, so one transient rate limit during invoice creation left exchange_rate permanently NULL and resolveSekAmount() then books the raw foreign number as if it were kronor (1 000 EUR posted as 1 000 kr). lib/transactions/ingest.ts has always passed the client; this is the invoice path catching up. Knowingly DIVERGES from the supplier-invoice entry above, which anchors on invoice_date and flags the taxable-event anchor as an open refinement: on the customer side the anchor was the finding under repair and the skill quote settles it, so the looser choice was not carried over. The verifikat's entry_date stays invoice_date on both sides, so a customer invoice with a delivery_date in a different month now carries a rate from a different day than its verifikat date; that is what the law asks for, and it is the same split every Swedish system has.
[2026-07-26] POST /api/invoices/[id]/refresh-exchange-rate REFUSES a booked invoice (409 INVOICE_FX_REFRESH_BOOKED) rather than routing the re-rate through storno or the inline rattelse RPCs. The endpoint exists because a sent invoice is no longer a draft, so PATCH /api/invoices/[id] cannot reach it and several structured errors (MATCH_INVOICE_BOOKING_RATE_MISSING, BATCH_FX_RATE_MISSING) told the user to "komplettera fakturans exchange_rate" via an endpoint that did not exist; both remediations now name it. Refusing beats auto-correcting for three reasons: once the invoice has a verifikat the SEK amounts ARE bokforda poster, and BFL 5 kap 5 § allows exactly two tracks to change those, both of which are decisions about the ledger that the user must make deliberately (a storno consumes a voucher number and an inline rattelse writes an immutable who/when row); the correct rebooking is not mechanical, since a changed rate on a settled invoice also moves the kursvinst/kursforlust leg on 3960/7960 that the payment entry already posted; and a silent UPDATE of the invoice row behind a committed entry would desync the invoice from the ledger it produced, leaving the reports showing the old wrong value while the invoice claims the new one. The booked check is deliberately belt-and-braces (invoice.journal_entry_id, which was backfilled late, PLUS a journal_entries lookup by source_id, PLUS an .is('journal_entry_id', null) guard on the UPDATE for the TOCTOU window). Period locks are checked against invoice_date, the date the verifikat would carry, through resolvePeriodStatusForDate, which fails closed. The route writes ONLY exchange_rate, exchange_rate_date and the three _sek columns: subtotal / vat_amount / total / remaining_amount are denominated in the invoice currency and re-rating them would silently reprice a sent invoice. No manual-rate override was added: ML 8 kap 21-23 § permits only the Nasdaq OMX mid-rate via Riksbanken or the latest ECB rate, so an unreachable Riksbanken returns 502 and stays retryable instead of accepting a typed-in number. NOT wired to any UI yet: the transaction twin is reachable from QuickReviewDialog, the invoice one is API/agent only.
[2026-07-26] The customer-invoice matcher's currency guard stays exactly as strict, but stops being silent: findInvoiceMatchCandidates() is a new sibling of findMatchingInvoices() that returns { matches, unconvertedFxCount, unconvertedFxInvoices } so a caller can say WHY the EUR invoice that obviously pays the bank row is missing from the suggestions, and point at POST /api/invoices/{id}/refresh-exchange-rate. Shape copied from unconverted_fx_count in lib/reports/supplier-ledger.ts. A sibling function was chosen over changing findMatchingInvoices' return type because three callers consume the array (lib/transactions/ingest.ts via getBestInvoiceMatch, app/api/transactions/batch-match-invoices, and the gnubok_auto_match_period MCP tool) and only the MCP tool has an output schema that could render the reason today; a breaking change to all three to serve one is not worth it. getBestInvoiceMatch keeps returning InvoiceMatch | null: same "no room for a reason" constraint the supplier-side twin reported, and widening it is left to whoever has a surface to render it on. Also fixed while in there: the conversion fell through to `return invoiceAmount` when total_sek was missing, handing a raw EUR figure to a SEK comparison. It was reachable, not theoretical, via total_sek = 0, which passes the `!= null` currency guard and then fails the `total_sek && total` truthiness check: a 1 000 EUR invoice matched a 1 000 kr receipt at 0.80 confidence, asserted by a test that fails against HEAD. It now returns null. DIVERGES from lib/invoices/supplier-invoice-matching.ts in two known ways, both deliberate because the fix is about visibility and must not make the matcher match more: the supplier side falls back to the invoice's stored exchange_rate when the _sek column is absent and normalises currency codes (case, and NULL meaning SEK), the customer side reads only total_sek and compares codes raw, so a legacy transaction with a NULL currency is still silently excluded against a SEK invoice.
[2026-07-26] lockPeriod's unbooked-transaction guard now blocks on TWO predicates, not one, and the shared resolvePeriodStatusForDate fails closed. The old guard was `journal_entry_id IS NULL AND is_business = true`, which is close to backwards: triage is what sets is_business, so it could not describe an untriaged row at all (prod: 18 289 rows with is_business IS NULL and is_ignored = false, exactly ONE of which has a journal_entry_id), while the journal_entry_id leg falsely matched already-booked rows whose verifikat lives in transaction_voucher_links or the two payment tables. Locking therefore succeeded on periods full of untriaged transactions and enforce_period_lock then froze them unbookable in place, a BFL 5 kap 2 § problem with no exit but an unlock or a rattelse. Leg 1 is now the canonical worklist predicate (is_business IS NULL AND is_ignored = false) so the number in the error reconciles with the "N st att bokfora" badge. Leg 2 was added after prod evidence contradicted the first draft of this fix: 1 758 rows have is_business = true with journal_entry_id NULL, and only 5 of them are genuinely booked per is_transaction_booked(), so dropping the old leg entirely would have silently stopped guarding ~1 750 rows (about 136 outside the sandbox seed) that the user has already CONFIRMED are affarshandelser and that still have no verifikat: the stronger stranding case, not a weaker one. Leg 2 therefore reproduces all three anchoring locations from lib/transactions/is-booked.ts rather than trusting journal_entry_id, which is what keeps it from over-blocking the bulk-booked and multi-allocated rows the old guard tripped on. Hard block, no bypass: locking is a voluntary internal control with no legal deadline, the affarshandelser it strands do have one, and all three escape hatches (bokfor, mark privat, mark ignored) are exits from the predicate rather than overrides of it, so a "lock anyway" button would only add an audit_log control override. is_business = false and is_ignored = true are triaged-and-excluded and never block. Message keeps the two load-bearing phrases: "saknar bokforing" for both lock routes' 400 mapping, and an unconditional "affarstransaktioner" for inferCode()'s /Kan inte lasa period:.*affarstransaktion/ regex, which the per-leg breakdown alone does not satisfy in the untriaged-only case; the infra-failure message deliberately matches NEITHER so an unreachable DB does not send an agent off remediating transactions. Separately, resolvePeriodStatusForDate swallowed PostgREST errors and returned 'open' with period_id null, a fail-open in a helper that six write paths consult; it now reports status 'locked' with an additive optional lookup_failed: true, kept distinguishable from the legitimate "verified, no covering period" case (open + period_id null). lookup_failed is a new optional FIELD rather than a fourth PeriodStatusValue because lib/agent/intents/bokslut-step.ts consumes the union as an exhaustive Record<PeriodStatusValue, string> and widening it would break an unrelated caller at compile time. NOT fixed, both out of the owned diff: extensions/general/mcp-server/server.ts (~line 11133) carries a verbatim copy of the old dead guard as gnubok_lock_period's staging-time pre-check (harmless to the legal guarantee, since commit goes through the fixed lockPeriod, but it lets an agent stage a lock that then fails at approval), and lib/api/v1/check-period-lock.ts, the documented mirror of resolvePeriodStatusForDate, still drops both query errors and returns locked: false.
[2026-07-26] The composer's banking and SIE counterparty summaries now carry a CurrencyMagnitude (SEK total of the rows whose SEK value is actually known + native total per currency + count of rows with no SEK equivalent) instead of one `abs_amount` number, and lib/agent/composer/atom-selection.ts renders that magnitude per currency rather than stamping "kr" on it. loadBankingSummary() selected only `description, amount, date, journal_entry_id`, but transactions.amount is denominated in transactions.currency, so a 30 000 EUR row was summed with SEK rows and printed to the model as "30 000 kr". That text is the INPUT to the call that writes the company's standing agent profile, so a wrong magnitude is baked into durable instructions rather than misleading one answer, which is why this is rendered honestly instead of approximately. Chosen: per-currency rendering with an explicit unknown, over three alternatives. Not a single SEK total (that is the bug), not a live FX lookup at compose time (the composer must stay a cheap read of stored data, and Riksbanken's rate today is not the rate at transaction date), and not silently excluding foreign rows (that would understate a EUR-heavy consultancy and read as "small" rather than "unmeasured"). Resolution order per row mirrors lib/bookkeeping/currency-utils.ts resolveSekAmount() but deliberately DROPS its final fallback, which returns the raw foreign amount when no rate is stored: correct for booking legacy data, fatal here, since it is exactly what turns 500 EUR into "500 kr". Same choice lib/agent/intents/inbox-bulk-book.ts txSek() already made. Rows with no stored rate are surfaced twice: inline as "SEK-motsvarighet OKÄND", and, when the SEK ranking would have dropped them below the top-20 slice (they rank as 0 kr), in a separate `unconvertible_counterparties` block labelled as omatt, not small. monthly_volume counts only convertible rows and is labelled GOLV when any row is missing, or OKÄND when none convert, instead of the old total that silently added foreign amounts. Uncertainty is flagged deterministically, not left to the model: buildCurrencyUncertaintyNotes() appends the caveats to uncertainty_notes inside selectAtoms() after Zod validation, the same belt-and-braces pattern already used for hallucinated atom ids, so the durable profile carries the caveat even when the model ignored the prompt rule. The field is still named `abs_amount` rather than `abs_amount_sek` on purpose: SourceSignalsSchema in lib/agent/composer/schemas.ts persists this shape into agent_profiles.source_signals, and renaming it would change what already-written snapshots mean, so schemas.ts was left untouched and the richer object rides along as extra JSONB keys. SIE top_accounts keeps its bare kr rendering, correctly: journal_entry_lines.debit/credit are booked SEK per BFL 4 kap 6 §, with any foreign original kept separately in currency/amount_in_currency. NOT done: no per-company redovisningsvaluta support (BFL 4 kap 6 § allows EUR bookkeeping; the whole codebase assumes SEK and this fix does not change that), and no backfill of exchange_rate on existing rate-less transactions.
[2026-07-26] The four auth redirects in lib/supabase/middleware.ts now go through one bounceToAuth(request, target) helper that records the destination, and safeReturnTo was HARDENED in the same change because preserving a destination without it would have shipped a live open redirect. Three of the four sites dropped the destination although the pages they land on read it, and the protected-route bounce cloned request.nextUrl and overwrote only pathname, so /settings/billing?success=1 arrived as /login?success=1: the stray parameter leaked while the thing worth keeping was thrown away. The helper builds the URL fresh from the request origin (an absolute-path reference clears query and fragment), which fixes the leak structurally rather than by deleting known keys. Parameter name is per target, not global: /login reads next (app/(auth)/login/page.tsx) and the MFA pages read returnTo, so AUTH_DESTINATION_PARAM maps it explicitly instead of guessing, since sending the wrong name is a silent no-op. safeReturnTo re-checked: its prefix guards and sentinel-origin check both PASS /..//evil.com, /.//evil.com and /%2e%2e//evil.com, all of which URL normalisation collapses to //evil.com, and the login page then does window.location.assign on it. That was already exploitable at /login?next=/..//evil.com before this change; the fix re-validates the NORMALISED path, which cannot be re-normalised around, and a middleware test proves the new site-4 code emits Location: http://evil.com/ without it. Site 4 (authenticated user on an auth page) deliberately honours next for /login and /register ONLY: the same block also covers /auth and /sandbox, and /auth/callback is the PKCE exchange, where honouring a destination would skip the code exchange, so those keep bouncing to / unchanged. /register does not itself read next today (it reads invite), so consuming it there is symmetry, not a live path. MFA semantics untouched: same conditions, same targets, only a query parameter added, and the pages navigate to the destination only after the step-up succeeds.
[2026-07-26] The two booking-time duplicate guards (lib/transactions/booking-duplicate-detection.ts, lib/invoices/duplicate-payment-detection.ts) now compare in SEK and say so, instead of putting a raw transactions.amount next to an always-SEK journal_entry_lines.debit_amount/credit_amount. Basis chosen: SEK, resolved by a single new exported helper resolveTransactionAmountSek() (currency SEK/null -> abs(amount); else amount_sek; else amount * exchange_rate; else NULL). Deliberately the OPPOSITE choice from ledgerLineAmountIn() in lib/reconciliation/bank-reconciliation.ts, which resolves ledger lines in the ACCOUNT's currency: there the whole statement being reconciled is foreign, whereas the twin these guards hunt is usually an ordinary SEK verifikat (invoice markera som betald, salary net-wage payout, a hand-posted entry) that carries no amount_in_currency at all, so comparing in EUR would resolve every one of those to null and reopen the double-booking hole. line.currency is never read as evidence of comparability, because currency-utils.ts stamps it onto lines whose debit/credit is SEK. Null (never the raw foreign number, which is what currency-utils' resolveSekAmount() falls back to) is the point: guessing the unit IS the bug. When the bank line has no SEK value the amount test is SKIPPED rather than failed, and the surviving candidate is returned with amount_verified: false + unverified_reason: 'transaction_missing_sek_value'. Returning null there would read as "no duplicate, go ahead" and mint a second verifikat for one affarshandelse (BFL 5 kap 1-2 §); hard-blocking would refuse a booking the software cannot judge, and an unbooked affarshandelse breaks lopande bokforing just as surely, so the guard warns and leaves the call to the user via the existing force / allow_duplicate bypass. The existence half of the question stays unit-free (direction + settlement account + date window + unlinked + not storno), so an empty candidate list is still a genuine verified pass. Both entry points take currency as a REQUIRED field rather than an optional one: optional reads as undefined for any caller projecting a narrow column list, undefined would have to default to SEK, and that default silently disables the FX half of the guard: required turns the mistake into a compile error, which is what caught app/api/transactions/[id]/duplicate-payment-check/route.ts (narrow GDPR-minimal select) and the gnubok_categorize_transaction MCP tool (select had currency but not amount_sek/exchange_rate). Also fixed in the sibling-transaction half: it compared transactions.amount to transactions.amount with no currency check, so 100 EUR matched 100 SEK on the same date; it now requires the two currency labels to agree and compares in that shared currency, which is exact, rather than routing both through amount_sek. lib/invoices/duplicate-payment-detection.ts imports the helper from the booking module rather than duplicating it, accepting the cross-directory import so the two documented mirrors cannot drift. NOT done: the client dialogs (DuplicateBookingDialog, InvoiceMatchDialog) still render copy asserting the amounts matched; amount_verified/unverified_reason ride along in the candidate payload and the server-generated Swedish messages (categorize-core, MCP) were made honest, but the two dialogs need an i18n string pair before an unverified candidate reads correctly in the UI.
[2026-07-26] The invite cookie is now consumed through one lib/auth/consume-invite-cookie.ts and is deleted ONLY on a definitive outcome, replacing four hand-rolled copies (login BankID + login password + register BankID signup + mfa/verify) that cleared it on the `!res.ok` fall-through AND on the throw. A network blip or a 500 from /api/team/accept therefore destroyed the browser's only copy of a token whose invitation was still `pending`, and stranded the invitee in the app with no company, no message, and no recovery short of the inviter re-sending the invitation. classifyInviteAcceptStatus() reads the definitive/transient split straight off app/api/team/accept/route.ts: 2xx and 409 ("Du är redan medlem.") are `accepted` because the invitee IS in the company; 400 ("Inbjudan är ogiltig.", covers unknown and non-pending), 404 and 410 ("Inbjudan har gått ut.", which the route also marks expired) are `spent`; everything else, notably 401 (the session cookie had not propagated yet), 429 and 5xx, is `retryable`. 403 email-mismatch is its own disposition and deliberately RETAINS the token: the invitation is untouched and still pending server-side, so it is not spent, and the user's only remedy ("log out, log in as the invited address") requires the token to survive the logout, which is the same reasoning app/invite/[token]/page.tsx handleSignOutAndRetry already encodes by re-writing the cookie for exactly this case. Retention is a real recovery path rather than a deferral, which is why keeping it beats clearing-plus-apologising: /onboarding and /select-company both re-run acceptance server-side from the surviving cookie via acceptPendingInviteByToken(), and the dashboard root redirects a company-less user to /onboarding, so a retained token is retried on the very next page load. No widening of the token's exposure: the cookie's max-age, path, samesite and secure flags are set at write time in the invite page and are untouched here (the 3600s-vs-7-day-TTL mismatch is a separate queued finding, deliberately not adjusted in either direction), its authority is bounded server-side by company_invitations.expires_at plus the single-use status transition, and acceptance is re-authorized on every POST by requireAuth() plus an email equality check, so a surviving cookie confers nothing on its own. Non-definitive outcomes now toast a Swedish message (invite.accept_retry_*, accept_wrong_email_*, accept_spent_*); the Toaster is mounted in the root layout with TOAST_REMOVE_DELAY 1000000, so it survives the router.push that follows, though NOT the window.location.assign taken when ?next= / ?returnTo= is set, which the invite flow never sets. NOT converted, and not part of this finding: the fifth copy at app/(auth)/register/page.tsx ~:259 (auto-confirmed local-dev signup) already clears only on success and hard-navigates afterwards, so a toast there would be destroyed on the spot; app/invite/[token]/page.tsx handleJoinNow (token comes from the route param, which the helper's `options.token` covers when someone converts it); and app/(auth)/auth/callback/route.ts, which accepts server-side against the DB rather than over fetch and can reuse the classification but not the transport.
[2026-07-26] The invoice download on app/(dashboard)/invoices/[id]/page.tsx now distinguishes three states instead of two, via the new pure helper lib/invoices/invoice-pdf-source.ts. A failed /api/invoices/[id]/deliveries read used to collapse to [], which is byte-identical to "nothing was ever sent", and the download then silently switched from the ARCHIVED PDF the customer received (invoice_deliveries.document_attachment_id, WORM, rakenskapsinformation kept 7 years per BFL 7 kap) to a freshly re-rendered one built from today's invoice row, customer row, company_settings and logo, under the same unqualified "nedladdad" toast. The three states are now: archived (serve it, plain toast), unavailable (delivery history unreadable: nothing is downloaded, a dialog offers Forsok igen / Ladda ner nyskapad PDF anda / Avbryt), and rerender (no archived copy can exist: served, but the toast title says "Nyskapad PDF" and names the reason). Chosen over the simpler alternative of just retrying the deliveries fetch, because a retry that also fails lands back in the same ambiguity; and over blocking the re-render entirely, because for invoices predating invoice_deliveries (2026-07-22) and for manually marked-sent invoices the re-render is the only document that exists and refusing it would be a dead end. The consent path deliberately uses ONE dialog and ONE toast per download: components/ui/use-toast.tsx has TOAST_LIMIT = 1, so a warning toast emitted next to a success toast would be evicted and never render. invoiceStatus gates ONLY the unreadable case, so a draft is never blocked by an unrelated fetch failure; when the history did load it is the data that decides, because "cancelled" does not prove nothing was sent (a proforma can be emailed and makulerad afterwards, and its archived copy is still what the recipient got).
[2026-07-26] Every async handler on app/(dashboard)/salary/runs/[id]/page.tsx now releases actionLoading on a throw, and the bulk payslip ZIP reports partial failures by name through the new pure helper lib/salary/payslip-zip-report.ts: actionLoading gates the header button, the progress rail AND the employee table, so the one handler with no try/catch (Ladda ner AGI) froze the entire page on any rejected fetch until a reload, and the ZIP loop did if (!res.ok) continue and only warned when the counter reached zero, so 17 of 20 payslips downloaded under a plain success toast. The partial archive is still handed over rather than withheld: 17 real lonespecifikationer are worth more than none, and the employer can fetch the rest individually. It is reported with ONE destructive toast naming who is missing, never success-plus-warning, because components/ui/use-toast.tsx has TOAST_LIMIT = 1 and ADD_TOAST does [newToast, ...state.toasts].slice(0, 1), so the last dispatch wins and a warning emitted before a success is evicted unrendered (same reasoning as the invoice-download entry above). handleDelete and handleCorrect deliberately keep no finally: their success path navigates away and the buttons should stay disabled across the route change, so only the paths that stay on the page release. The verdict logic was extracted to a pure module rather than tested through the component because the suite is node-env with no component tests (CLAUDE.md Testing), and the extraction is what makes never-call-a-partial-archive-complete an assertion instead of a comment. Not owned in this pass, same TOAST_LIMIT eviction: handleCalculate loops toast() over every warning and handleSendPayslips emits a success toast followed by one toast per failed recipient, so in both only the last dispatched toast is ever seen. Also unresolved: .claude/skills/swedish-payroll documents the AGI individuppgift and its 12th-of-month deadline but says nothing about what the employer owes the employee on a lonespecifikation or when, so the loudness of the ZIP fix was argued from the artefact (the employer hands out what the archive contains and has no other signal that someone is missing) rather than guessed from training data; note references/ob-overtime.md and references/sick-pay.md are still byte-identical (md5 68e7aba1), so that skill has known holes.
[2026-07-26] The PUT upsert in app/api/import/sie/mappings/route.ts now writes source_name, resolved as (client-supplied sourceName if a non-empty string) else (the value already stored for that company_id + source_account) else null, and its onConflict target was corrected from the dropped 'user_id,source_account' constraint to the one that actually exists, 'company_id,source_account'. source_name is the label the exporting system wrote into the SIE file's #KONTO record ('#KONTO 1910 "Kassa"'), it is per-file and cannot be recomputed from the BAS chart, and lib/import/account-mapper.ts ~:144 reads it straight back for display during the mapping review, so omitting it from the INSERT path left the reviewer a bare 4-digit number with no context on the one screen where the whole migration's account mapping is approved. Chosen: read-then-carry-forward over requiring the client to resend the field, because a client that does not know about the field is exactly how it got erased; the extra round trip is acceptable on a single-mapping override endpoint. Re-reading the label from the archived SIE file was rejected: sie_account_mappings is company-scoped and outlives any one file (unique on company_id + source_account), the PUT payload carries no import id, and recovering it would mean fetching from the sie-files bucket and re-running encoding detection (CP437/UTF-8) plus parsing for one text field, with no defined answer when two imported files label the same account differently. The onConflict fix is bundled deliberately rather than as scope creep: prod has no unique index on (user_id, source_account) (dropped by the multi-tenant refactor 0dd1f5eb, which repointed saveMappings() but not this route), so the statement could only raise 42P10 and the source_name fix would have been inert; prod has 7323 mapping rows and zero with match_type='manual', which is what this endpoint writes, corroborating that it has never once succeeded. NOT fixed, same file, separate finding: POST passes user.id into saveMappings(supabase, companyId, mappings), writing a user UUID into company_id and failing the FK, so POST 500s too; left alone to keep the diff on one finding, and its existing test asserts the broken call.
[2026-07-26] Verified the SEK-basis duplicate-guard sweep (entry above) and declined to widen it into the two client dialogs, which is now the only gap left: components/transactions/InvoiceMatchDialog.tsx renders formatCurrency(candidate.amount, transaction.currency), and since candidate.amount is deliberately the 19xx leg's SEK figure, a EUR bank line makes it print a kronor number under a EUR symbol. That mislabel predates this sweep (it is byte-identical at HEAD), it needs an i18n string pair in messages/sv.json + messages/en.json to fix properly alongside the missing amount_verified: false copy, and both dialogs are outside the two files this pass owns, so the fix is left as the follow-up the sweep entry already names rather than smuggled in as a drive-by. Also confirmed not to be a regression: with a non-finite transactionAmount, detectDuplicatePaymentVoucher now warns (unverified) where it previously returned null, because resolveTransactionAmountSek rejects NaN before the zero-guard can; transactions.amount is NOT NULL numeric so no production row reaches it, and warning is the safe direction anyway.
[2026-07-26] data/<child>.json files in the full archive now carry the parent row's currency (and exchange_rate where the parent has one) as invoice_currency / supplier_invoice_currency / receipt_currency, via a declarative denormalize: { prefix, columns } on MasterDataTableSpec, rather than leaving the unit recoverable only by joining data/invoices.json. invoice_items, supplier_invoice_items and receipt_line_items carry money (unit_price, line_total, vat_amount) and no currency column of their own, and their "unit" column is the quantity unit ("st"), so a revisor opening one file alone could not tell 1000 EUR from 1000 SEK; the parent dumps were never affected because select(*) already carries currency, exchange_rate and the *_sek twins side by side. BFL 7 kap does not literally require each archived file to be self-describing (it requires varaktigt lasbart skick, preserved structure/metadata and a producible whole, with BFNAR 2013:2 kap 8 putting traceability from verifikation to bokslut in systemdokumentation), so a documented join would have been defensible; the denormalisation was chosen anyway because it is strictly additive, costs one extra column per parent row on a query that already runs, and removes a reading that depends on the reader still having the sibling file. Deliberately NOT applied to rot_rut_payout_request_items (parent rot_rut_payout_requests has no currency column; HUS-avdrag is SEK by statute, and asserting a unit that no column holds would fabricate rakenskapsinformation) and not needed for journal_entry_lines (its own table carries currency, exchange_rate and amount_in_currency, and grundbok/SIE amounts are SEK by construction). Only the conversion basis is copied, never the parent totals: a line's SEK value is not the invoice's total_sek. Collisions are avoided by never overwriting an existing child key, and the "?? null" fallback is load-bearing because JSON.stringify drops undefined keys. Three new assertions in tests/pg/full-archive-coverage.pg.test.ts keep it from rotting (parent columns exist, no collision with a real child column, every child of a currency-carrying parent denormalises it). Known gap left open: recurring_invoice_schedule_items is not in MASTER_DATA_DUMP_TABLES at all (it has no company_id, so the coverage contract cannot see it) even though its parent recurring_invoice_schedules is dumped and does have a currency column; it holds invoice TEMPLATES for affarshandelser that have not occurred, so it is arguably not rakenskapsinformation, but the exclusion is undocumented.
[2026-07-26] app/api/bookkeeping/fiscal-periods POST no longer refuses to create the next rakenskapsar while a prior period is still fully open (the PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS 409 is gone); the same detection now rides along as a non-blocking `warnings: [{ code: 'PRIOR_FISCAL_YEAR_STILL_OPEN', message }]` on the 200, matching the {code,message} warning shape already used by app/api/invoices/[id]/book. The old gate had no support in BFL and inverted two rules that bind at once: BFL 5 kap 2 § requires the new year's affarshandelser to be bokforda "sa snart det kan ske" (per BFNAR 2013:2 senast manaden efter), which needs a rakenskapsar covering January within weeks, while BFL 6 kap gives the arsbokslut 6 months (AB filing 7), so the prior year is legitimately unfinished and must stay UNLOCKED for bokslutsposter. Concretely: one untriaged December bank transaction makes lockPeriod refuse (correctly, lib/core/bookkeeping/period-service.ts), and the route then refused to create the new year, so all bookkeeping stopped; with that guard now failing closed on a lookup error, the pair was a hard deadlock no user action could clear. The gate also pushed users into locking the prior year before bokslut, and every later unlock lands in audit_log as a control override. What still blocks is unchanged and narrower: the 18-month BFL 3 kap duration cap, predecessor/successor date adjacency (BFNAR 2013:2 continuity chain), and the overlap 409 backed by the no_overlapping_fiscal_periods exclusion constraint. Prior-year protection was never this route's job: it lives in fiscal_periods.locked_at (enforce_period_lock) and company_settings.bookkeeping_locked_through (enforce_company_lock_date), and creating the next period writes no row into the prior one. Opening balances are not made worse: executeYearEndClosing already reuses a pre-existing next period and posts the IB verifikat (entry_date = nextPeriod.period_start) into it, refusing only when opening_balance_entry_id is already set, so the IB lands by itself later either way. Follow-up left open (not this route's files): components/bookkeeping/CreatePeriodDialog.tsx still carries the now-unreachable "Las aret och skapa" branch keyed on the retired code, and does not surface the new warnings.
[2026-07-26] validatePersonnummer (lib/salary/personnummer.ts) now accepts samordningsnummer by stripping the +60 day offset before the 1-31 range check, and the v1 REST employee create route runs it (it previously parsed only CreateEmployeeSchema's `^\d{12}$`, so a check-digit-invalid number entered payroll and surfaced weeks later as a Skatteverket AGI rejection). The offset is stripped into a local birthDay only: the Luhn check still runs over the printed digits including the +60, which is load-bearing rather than cosmetic, since the check digit for day D and day D+60 differ (verified: day 01 -> 2, day 61 -> 9). The agreement test in lib/salary/__tests__/personnummer.test.ts reads IDENTITET_PATTERN out of lib/salary/agi/xml-generator.ts at test time with readFileSync and lifts the regex literal, rather than copying the pattern or exporting it: the const is module-private, and a copy would keep passing after the generator changed, which is the exact drift the test exists to catch (the loader throws if the const is renamed, so it cannot silently degrade into testing nothing). Two divergences from Skatteverket's pattern are asserted rather than fixed, so whichever side moves fails loudly: the generator also accepts day 60 (unknown day of month) and month 00 with day 60-99 (unknown birth date entirely), which the employee validator rejects and which registering an employee has never supported; and the validator's plain 1-31 range is looser than the generator for short months (April 31, February 30), pre-existing and untouched. MCP gnubok_create_employee (extensions/general/mcp-server/server.ts ~:10669) still encrypts and stages without any validation and remains the one surface out of step, queued separately. Report-only, not fixed: CreateCustomerSchema.personal_number is format-only (`^(\d{6}|\d{8})[-+]?\d{4}$`, no Luhn, no date check, 10-digit short form allowed), unlike the ROT/RUT invoice path in lib/invoices/build-invoice-write.ts ~:358 which runs the full validatePersonnummer.
[2026-07-26] components/bookkeeping/AccrualPeriodControl.tsx compared the K2 5 000 kr vasentlighetsgrans against a line amount that is denominated in the invoice's currency, so a 500 EUR line (about 5 750 kr, i.e. ABOVE the limit) rendered "Belopp under 5 000 kr behover normalt inte periodiseras (K2)" and a 6 000 NOK line (about 4 800 kr, BELOW it) rendered nothing. The threshold is now applied to a SEK value resolved by a new pure helper, components/bookkeeping/accrual-k2-hint.ts, and the hint is suppressed entirely when that value cannot be determined. Suppression, not a guess, was chosen because the K2 rule (BFNAR 2016:10) is a simplification the company MAY use and not an obligation: "Individual recurring costs below 5,000 SEK (not fluctuating >20%) need not be accrued (except personnel costs)". Nothing is blocked or booked differently by the hint, so an advisory nudge measured in the wrong unit is strictly worse than no nudge, and the proportionate fix is to stay silent. Deliberately NOT reusing resolveSekAmount() from lib/bookkeeping/currency-utils.ts: its documented fallback returns the foreign amount untouched, which is exactly the silent wrong-unit branch this fix exists to remove; the helper returns null instead. The two call sites differ and the asymmetry is real, not an oversight: components/supplier-invoices/NewSupplierInvoiceForm.tsx carries a Riksbanken-fetched or hand-typed exchange_rate on the form, so its hint is now correct in every currency, while components/invoices/InvoiceEditor.tsx has no exchange_rate field anywhere in its zod schema, defaultValues or submit payload (invoices.exchange_rate exists as a column and the edit page selects * , but the editor never reads or writes it), so its foreign-currency lines get currency only and the hint stays hidden until the customer-invoice editor grows a rate. SEK behaviour is byte-for-byte unchanged, including the amount > 0 guard that keeps discount lines silent. Also fixed in the same prop: the "N manader x X" preview called formatCurrency(amounts[0]) with no currency and therefore printed a kronor symbol on a EUR installment; it now passes the line currency. No new i18n keys were needed, and all nine keys the component already references exist in both messages/sv.json and messages/en.json.
[2026-07-26] POST app/api/import/sie/mappings now passes the withRouteContext-resolved companyId into saveMappings(supabase, companyId, mappings) instead of user.id, which had been writing a user UUID into sie_account_mappings.company_id (NOT NULL, FK to companies). Both are strings, so TypeScript could not see it; the fix that would prevent recurrence is a branded CompanyId/UserId pair or a named-argument object on saveMappings, which lives in lib/import/sie-import.ts and is outside this pass's files, so it is reported rather than implemented. Correction to the finding as filed: the endpoint did not 500. supabase-js returns {data, error} and only throws on transport failure, and saveMappings discards the return value entirely, so the FK violation was swallowed and POST answered 200 {success:true} while persisting nothing, which is why nobody reported it. The fix is necessary but NOT sufficient and the endpoint is still inert: sie_account_mappings.user_id is NOT NULL with no default and no BEFORE INSERT trigger, and saveMappings never supplies it, so ExecConstraints rejects the INSERT before ON CONFLICT can turn it into an UPDATE. Prod proves the two defects are independent: lib/import/sie-import.ts:2368 inside executeSIEImport has always passed the correct companyId, 834 SIE imports ran between the multi-tenant refactor and today, and sie_account_mappings has zero rows created after 2026-03-29 (6506 exact + 817 bas_range, all pre-refactor). Fixing user_id and surfacing the discarded error belong to lib/import/sie-import.ts; the same wrong-argument call also survives at extensions/general/arcim-migration/index.ts:914. The route-level test that pinned the bug (toHaveBeenCalledWith(supabase, 'user-1', mappings)) was repointed to 'company-1', and because a mocked saveMappings can only show WHICH value was passed and never which COLUMN it reaches, a second test now runs the real saveMappings against a recording Supabase double and asserts the upserted row carries company_id: 'company-1'. Element-level validation of body.mappings (blind-cast to AccountMapping[], so matchType and confidence reach the DB unchecked) was deliberately left alone: the endpoint has no caller anywhere in the repo, so no payload shape could be verified against a real client, and the match_type CHECK constraint is the existing backstop.
[2026-07-26] Two reskontra FX/visibility fixes. (1) app/api/reports/supplier-ledger/supplier/[supplierId]/invoices was the last `remainingSek ?? remaining` in the ledger family: for a foreign-currency supplier invoice with no exchange_rate it put the raw foreign amount into the ReportSourceLine `credit` field, which components/reports/ReportRowExpansion renders under a bare "Kredit" column in kronor, i.e. 1 000 EUR read as 1 000 kr against 2440. `credit` now falls back to 0, never to the foreign number, and the row keeps its own-currency amount in a new `remaining` field alongside the existing `remaining_sek` (mirrors ARInvoiceDetail's outstanding / outstanding_sek pair, so no information is lost by refusing to lie about the unit). A consumer distinguishes the two states on `remaining_sek`: null = SEK value unknown and excluded from every SEK total, 0 = genuinely settled; the response envelope also carries `unconverted_fx_count`, the same contract the ledger reports already expose. Chose 0 over omitting the row: BFL 5 kap 4 § sidoordnad bokföring means the open item must stay visible, it just must not be counted in kronor. (2) lib/reports/ar-ledger.ts's `.filter(total_outstanding !== 0)` conflated two different zeros: a customer settled by credit notes (suppress, that is why the filter exists) and a customer whose open invoices were ALL unconvertible, whose buckets never accumulated so the total is 0 by absence. The second was still counted in unconverted_fx_count, so PDF, XLSX and web view claimed "N fakturor saknar växelkurs" while the rows appeared nowhere, contradicting the "always: even if unconvertible, so it's visible" comment 40 lines above. The filter now keeps an entry when total_outstanding !== 0 OR any of its invoices has outstanding_sek === null. The predicate is deliberately derived from outstanding_sek rather than from a new per-customer counter, because outstanding_sek === null is the exact same condition that increments unconverted_fx_count, so the invariant "every invoice in the count is reachable in entries[].invoices" holds by construction. Not fixed, reported: the supplier side has no such filter (a supplier netting to zero still shows a 0,00 row there, unlike AR), but it hides all-unconvertible suppliers by a different mechanism, the `continue` at lib/reports/supplier-ledger.ts ~:107 fires before the bySupplier entry is created, so the supplier never enters `entries` at all and, having no per-invoice detail array in SupplierLedgerEntry, has nowhere to surface either.
[2026-07-26] SIE import now REFUSES a #RAR longer than 18 months instead of warning and continuing: ensureFiscalPeriod (lib/import/sie-import.ts) validated the #RAR start day and end day but never the BFL 3 kap duration cap, so a 24-month rakenskapsar imported cleanly while every other period-creation path (app/api/bookkeeping/fiscal-periods POST and PATCH, period-service createNextPeriod/createPreviousPeriod, onboarding computeFiscalPeriod) rejected the same span through validatePeriodDuration. Reused monthsBetween() from lib/bookkeeping/validate-period-duration.ts rather than writing a third month-counting variant, so the boundary is bit-identical: an 18-month forlangt rakenskapsar still imports, 19 does not. Applying the 18-month ceiling to EVERY #RAR is the loosest correct bound and blocks no legal import: 18 is the maximum for an extended or re-laid year and 12 is the norm otherwise, so nothing legitimate is longer. No minimum was added (BFL 3 kap sets none, per the 2026-07-25 and 2026-07-26 removals of the invented 6-month floor). Refuse over warn even though refusing a migration import is heavy and the accountant cannot edit the file: no reading of BFL 3 kap permits a rakenskapsar past 18 months, so this is a malformed or merged #RAR rather than historical data booked under older rules, and continuing would stamp every imported voucher with a period that no UI path can repair afterwards (the fiscal-period editor rejects the identical span), leaving undo_sie_import as the only exit. The check sits before the destructive delete of an empty onboarding-seeded period, so a refused import leaves the company untouched, and the Swedish message names the real recovery: re-export from the source system with one rakenskapsar per file, which is the standard SIE migration path anyway. Not fixed, reported: only #RAR 0 reaches ensureFiscalPeriod, so prior-year #RAR -1/-2 records are parsed into header.fiscalYears and never date-validated at all (they create no period, so they are inert today), and sie-parser.ts parses the #RAR year index with a bare parseInt that can yield NaN without complaint.
[2026-07-26] Tax deadline regeneration now inherits the superseded row's user-owned columns (notes, due_time, priority, customer_id) and manually reported status (in_progress/submitted/confirmed), generalizing the old in_progress-only key-match. One rule: the generator owns what the statute decides (title, due_date, deadline_type, linked report), the row owns every mark a person put on it. The statutory columns always take the template value because the schema cannot tell "user edited this" from "template changed under an untouched row" (nothing records the template value at creation, and the deadlines_updated_at trigger fires on every automatic status sweep); a preserved divergent due_date would also make the backfill cron flag the company forever, since its identity is type:period:due_date. An obligation the settings no longer produce is still deleted with its edits: the settings change is the user's own statement that it does not apply.
[2026-07-26] InvoicePicker and SupplierInvoicePicker now rank candidates through the shared pure helper components/transactions/invoice-candidate-ranking.ts instead of `Math.abs((remaining_amount ?? total) - Math.abs(tx.amount))`, which compared two numbers in different units: a 1 000 EUR invoice sorted above the genuine 1 000 SEK invoice for a 1 000 SEK bank row, same number, roughly eleven times the money. Ranking fix, not a filter: the manual pickers deliberately list every open invoice regardless of currency because the user must still be able to settle a foreign invoice by hand, so nothing is ever removed and three tests pin that (list length preserved, unconvertible row present but last). Deviated from BankTransactionPicker, which the finding said to mirror, on one point: it puts same-currency rows strictly first and lets cross-currency rows keep date order, whereas these two rank same-currency and SEK-converted rows against each other by their difference and only tie-break on basis. Justified because the two targets differ in kind. BankTransactionPicker's target is a half-typed invoice form whose amount has no SEK equivalent yet, so its converted tier is unreachable and the tie-break never arises; here both sides are persisted rows carrying their own conversion data, and an 11 496,70 kr deposit settling a 1 000 EUR invoice is the entire reason cross-currency matching exists, so burying that invoice under every wildly-off SEK invoice would have traded one bad ranking for another. The SEK conversion is `remaining * invoices.exchange_rate` guarded by the shared isValidExchangeRate, deliberately identical to match_batch_allocate (20260531120000_match_batch_allocate_cross_currency.sql), which values a cross-currency settlement the same way and refuses it outright with BATCH_FX_RATE_MISSING when the rate is missing: ranking on the same rule means the order the user sees agrees with what the booking path will accept, and a foreign invoice with no booked rate reports `incomparable` rather than being scored on a meaningless number. `exact`/`close` stay false on a converted comparison because that figure values the invoice at its own booking rate, not at what the bank moved, so agreeing to the öre is a coincidence, not a confirmation. Currency is normalized to SEK before every comparison (transactions.currency and invoices.currency are both nullable `text default 'SEK'`; a plain `===` reads NULL as "some other currency" and demotes an ordinary SEK invoice into the unranked tier). The new row indicators are deliberately language-neutral, the ISO code on a deviating currency plus `≈ {formatCurrency(sek)}`, so no i18n keys were added: SupplierInvoicePicker has no useTranslations at all and one t() call amid six hardcoded Swedish strings would render half-localized, while converting the whole file was out of scope. Not fixed because it is not the same bug: components/inbox/TransactionMatchPicker.tsx already scores with receiptCurrency/receiptSek and MatchAllocationDialog sorts by selection then due date, so neither ranks currency-blind.
[2026-07-26] The pre-auth invite cookie (`gnubok-invite-token`, written by app/invite/[token]/page.tsx) now lives 7 days instead of 3600 seconds, matching the invite TTL rather than guessing one: `INVITE_TTL_DAYS = 7` in lib/auth/invite-tokens.ts stamps `company_invitations.expires_at`, prod confirms it (77 invitations, min and modal `expires_at - created_at` = 7 days, no DB default on the column so app code is the only writer), and the max of 63 days is just re-invites refreshing expires_at while created_at stays put. The one-hour cookie could not survive the hop it exists for: an invitee who registers at 17:00 confirms the signup mail the next morning, by which point the cookie, the only copy the browser holds, was six hours dead and acceptance was impossible with no recovery short of the inviter re-sending. Widening a non-httpOnly cookie is a security decision, so it was argued against OWASP ASVS v5.0.0 rather than assumed: the caps that look relevant do not govern (V6.4's 60-minute ceiling is for account-recovery tokens that BYPASS authentication, V9.4's 15 minutes is for access tokens that carry authority on their own, and this token does neither, since POST /api/team/accept re-authorizes every attempt with `requireAuth()` plus an email equality check and the `status` transition is single-use). What does govern is V7.3's rule that the client-side lifetime is compared against the documented server-side bound, whose failure pattern is a client lifetime EXCEEDING it; 7 days against 7 days is equality, and the test asserts `<= inviteTtlSeconds()` so over-widening fails too. The old 3600 bought nothing anyway: by V7.4.1's logic it was client-side expiry over a server-side record that stayed `pending` for the full week, so it shortened the invitee's window without shortening the attacker's. Chose widening over the alternatives (keeping it short and leaning on the `?invite=` query parameter, which only survives the register hop and not the mail round-trip, or on the `/onboarding` + `/select-company` server-side healing path, which reads the very cookie that had expired) because those signpost the cookie-less class rather than removing it. The value is one constant feeding one `buildInviteCookie()` builder, replacing three duplicated literals, and the flags are untouched (`path=/`, `samesite=lax`, `secure` only on https, still not httpOnly since it is written from `document.cookie`). Shared-device exposure is bounded by the same email equality check: a later user on the same browser cannot redeem the surviving token, they get 403 `wrong_email`, which the consume side deliberately retains rather than clears; residual and accepted is that they learn an invitation exists for the other address, which was equally true at one hour. Tests read the TTL back out of `getInviteExpiry()` instead of restating 604800, so raising INVITE_TTL_DAYS without moving the cookie fails CI.
[2026-07-26] Replaced the credit-note COUNT cap with an AMOUNT cap. 20260715120000's uq_invoices_company_credited_invoice (UNIQUE on company_id, credited_invoice_id) permitted at most one kreditfaktura per invoice ever, which forbids the partial andringsfaktura that ML (2023:200) 17 kap 22-23 SS explicitly allows ("Partial credits: Fully permitted", swedish-invoice-compliance references/invoice-rules.md:77-79) and that nothing in ML caps in number. The concern the index encoded is real but different: two credit notes that each mirror the full original would post two reversing verifikat and double-reverse revenue plus utgaende moms, since createCreditNoteJournalEntry derives its lines from the credit note's OWN items. Migration 20260726130000 therefore enforces the actual rule, sum(ABS(total)) over all non-cancelled credit notes for one original <= ABS(original.total), which still refuses two full mirrors while allowing several partials that fit. Chose a trigger over an index or CHECK because the invariant is a cross-row aggregate: an index cannot express it and a CHECK may not read other rows, so a trigger is the honest option rather than a workaround. The trigger takes FOR UPDATE on the original invoice before summing (without it two concurrent inserts under READ COMMITTED both read the pre-insert sum and both pass) and skips that locking read entirely for updates that cannot move the sum (journal_entry_id link, creation_complete flip, draft->sent), so ordinary credit-note traffic is not serialized. Cancelled credit notes are excluded from the sum, which is what keeps route.ts's "reopen a cancelled unissued draft" path working. A currency-equality assert rides along because an amount cap across two currencies is meaningless; both creation paths already copy the original's currency, so it pins existing behaviour rather than adding a restriction. Deliberately NOT built: the app cannot create a partial credit note today (CreateCreditNoteSchema is {credited_invoice_id, reason} only, createCreditNote mirrors -Math.abs of every original total, and the v1 endpoint documents "To credit only part of an invoice, credit the full invoice first then reissue"), so this is a constraint correction plus headroom, not a partial-credit feature. Two latent blockers for whoever builds that feature: app/api/invoices/route.ts uses .maybeSingle() on the existing-credit-note lookup (throws once two credit notes share an original) and its 23505 race-recovery branch no longer fires now that the unique index is gone. The status guard in app/api/invoices/route.ts was briefly widened to accept status='partially_paid' and then REVERTED in the same pass, along with the INVOICE_CREDIT_NOT_SENT wording that had been changed to promise "delvis betalda ... kan krediteras". The premise is right (whether the customer paid nothing, part or all of an issued invoice has no bearing on the right to issue an andringsfaktura, and part-paid is where a credit note is most often needed) but that door is not where the flow ends. lib/invoices/issue-credit-note.ts ~:253 flips the original to 'credited' with .in('status', ['sent','paid','overdue']), and it runs AFTER createCreditNoteJournalEntry has posted the reversing verifikat: on a part-paid original the voucher is committed, the status flip matches zero rows, and mark-sent/send return INVOICE_CREDIT_REPAIR_REQUIRED with a fully credited invoice left at 'partially_paid', open in the AR ledger and still chased by the reminder processor. An immutable voucher in a half-state is worse than the clean 400 it replaced, and the widened error text promised a capability no surface delivered (app/(dashboard)/invoices/[id]/credit/page.tsx ~:76 pre-gates on the same three statuses, so the dashboard never reached the widened route guard at all; only a hand-rolled authenticated POST did). Lifting the gap is one coordinated change over six sites, issue-credit-note.ts first, then the dashboard credit page, app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts ~:211 (plus its OpenAPI description), lib/pending-operations/commit.ts ~:3104, extensions/general/mcp-server/server.ts ~:12380 and its kreditfaktura-process skill text. A route test now pins the refusal and names the reason, so lifting it in one place fails loudly instead of passing.
[2026-07-26] Three plus-minus DUPLICATE_AMOUNT_TOLERANCE_PCT bands were built in one currency and applied, in SQL, to a column denominated in another: findDuplicatePaymentCandidatesForInvoice and POST /api/supplier-invoices/[id]/mark-paid banded the invoice-currency payment against transactions.amount, and POST /api/transactions/[id]/categorize banded transactions.amount against supplier_invoices.remaining_amount / invoices.remaining_amount. At roughly 11,50 SEK/EUR a 2 % band is off by a factor of eleven, so each guard either selected nothing (a second verifikat for one affarshandelse then posts unopposed, BFL 5 kap 1-2 paragraf) or selected an unrelated row of the wrong magnitude. All three now route through the new lib/invoices/duplicate-guard-currency.ts: planAmountSweeps issues ONE SQL sweep per currency (each sweep restricts rows to a single currency via .or('currency.is.null,currency.eq.SEK') / .or('currency.eq.XXX') and carries the band expressed in that same currency) and magnitudesWithinTolerance re-checks every returned row in a shared unit. Chosen over the alternatives: banding on the SEK column instead was rejected because transactions.amount_sek and a SEK-derived predicate on invoices would need a second column in the same range filter and would drop legacy rows where amount_sek is null (4 of 695 foreign transactions in prod); widening the band by the max plausible rate was rejected because a band wide enough for JPY matches everything. Same-currency compares raw, cross-currency compares only STORED conversions (amount_sek / total_sek pro-rated to the remaining amount / exchange_rate), and no stored conversion means the candidate is EXCLUDED, never compared as a raw number: guessing the unit is the bug. That exclusion is the common case for foreign customer invoices in prod (257 of 285 have total_sek null-or-zero and no exchange_rate), so all three sites log it as a warn rather than let an unevaluated candidate set pass as a clean "no duplicate" (BFNAR 2013:2 kap 8); the customer-side lookup got a module-level logger for that purpose since it takes no ctx logger. A SEK reference yields exactly one sweep whose band values are unchanged, and prod carries zero non-uppercase and zero NULL currency codes in transactions / invoices / supplier_invoices, so a SEK-only company runs the identical single query. Band arithmetic moved from the naive Math.round(x * 100) / 100 to roundOre() per CLAUDE.md guard rail 9. Verified against HEAD: the currency tests fail there at all three sites. The voucher-gap-explanation refactor that shares app/api/transactions/[id]/categorize/route.ts (cancelOrphanedPaymentEntry) is a separate agent's change and was left untouched.
[2026-07-26] Split "the VAT rate the picker defaults to" from "the VAT rate an invoice line may lawfully carry". lib/invoices/vat-rules.ts getAvailableVatRates() returned a single locked 0% option for a VAT-validated eu_business and for non_eu_business, and six server-side gates plus the invoice form treated that one option as the only permitted rate. The 0% default is right: huvudregeln (ML 6 kap. 34 SS, Article 44 VAT Directive) taxes a B2B service where the buyer is established, so a Swedish consultant invoicing a German or a US company charges no Swedish VAT and reverse-charges instead. The refusal was not right: the swedish-vat reference lists five exceptions "(taxed where performed)" that carry Swedish VAT no matter where the buyer sits, being fastighetstjanster (property location), persontransporter (where transport occurs), korttidsuthyrning of transport vehicles (pickup location), restaurang/catering (where performed) and admission to cultural/sports events (event location). A Stockholm hotel invoicing a German company (12%) or a conference organiser selling a ticket to a US company (6%) was refused outright with INVOICE_CREATE_VAT_RULE_VIOLATION and had no workaround. Added getPermittedVatRates(), which appends 25/12/6 after the customer's 0% option for those two customer types, and pointed the build-invoice-write.ts gate at it. Chose widening the PERMITTED set over widening getAvailableVatRates() because the invoice form derives three behaviours from that array being length 1 (picker disabled, new-line default availableRates[0].rate, article-rate adoption suppressed) plus a snap-all-lines-to-0% effect on customer change; widening it in place would have silently left a stale 25% line at 25% after switching to an EU customer, which books the opposite error. Also rejected adding an explicit per-line "taxed where performed" flag: nothing on invoice_items distinguishes consulting for a German company from a hotel night sold to one, so a flag is the honest long-term model, but it needs a migration plus schema, form, MCP and v1 surfaces and cannot be set anywhere today. Because no single non-zero rate covers the exceptions (they span 25%, 12% and 6%) nothing narrower than the full Swedish set would do. The default is untouched on purpose: getVatRules().rate stays 0 and is still the fallback for a line that omits vat_rate, and the 0% option stays first in the permitted array, so a Swedish rate reaches such an invoice only when set explicitly on that line. Reverse-charge notation now follows the lines rather than the customer: when a special-treatment customer's invoice has no zero-rated line at all, the header takes the domestic treatment/ruta and drops reverse_charge_text, because "Omvand betalningsskyldighet" (ML 17 kap 24 SS p.11) printed next to charged Swedish VAT tells the buyer to self-assess tax the seller already collected and blocks their deduction. Mixed invoices keep the notation, since their zero-rated lines genuinely are reverse-charged and generatePerRateLines already applies the invoice-level treatment only to rate-0 lines, so 3308 and 3002/2621 land in the right ruta on their own. Text-only documents count as zero-rated so their header is not restamped. Not fixed here (same invented rule, other owners): lib/pending-operations/commit.ts:1063, extensions/general/mcp-server/server.ts:4475, app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts:185, lib/invoices/recurring-schedule-service.ts:199, lib/invoices/self-billed-sale.ts:123, and components/invoices/InvoiceEditor.tsx:718/790/792 where the picker must render getPermittedVatRates() while keeping getAvailableVatRates() for the default and the snap.
[2026-07-26] lib/api/validate.ts validateBody() now builds the top-level `error` from the first three Zod issues ("Valideringsfel: <field>: <message>. ... (+N till)") instead of the constant 'Validation failed'. `errors[]` already carried the actionable detail and lib/errors/get-error-message.ts:223-234 already knows that shape, but the many clients that forward only `body.error` collapsed it: a raw English "Validation failed" in a Swedish payroll UI (components/salary/SalaryOverridePanel.tsx:78) or, once through getErrorMessage, the generic "Nagot gick fel. Forsok igen." fallback, because the constant matches no pattern the function knows. The 'Valideringsfel' lead-in is load-bearing, not decoration: it is what makes isSwedishUserMessage() recognize the sentence as an already-Swedish user message and pass it through verbatim, so no per-call-site change was needed anywhere. Fixed centrally in validate.ts rather than in get-error-message.ts (adding a 'Validation failed' -> Swedish entry to ERROR_PATTERN_MAP would have produced one generic sentence for every field error, losing the field name, and would not help the sites that render data.error raw without going through getErrorMessage at all) and rather than at the three reported pages (the same collapse exists at 8+ call sites). The machine-readable contract is untouched: `type: 'validation_error'` stays the discriminator and `errors[].code` stays the per-field code, so components/settings/SettingsFormWrapper.tsx:73 and every other consumer branches on structure, never on prose; a repo-wide search found zero consumers comparing against the literal string, only producers emitting it. The v1 REST envelope is a separate shape (`{ error: { code, message } }` via v1ErrorResponseFromCode) and no route under app/api/v1/** calls validateBody, so public API clients see no change. Deliberately left English and out of scope, same bug class, different constants: validateQuery()'s 'Invalid query parameters', validateBody()'s 'Invalid JSON in request body', and the nine hand-rolled `error: 'Validation failed'` responses that bypass the helper (app/api/agent/composer/route.ts:47, app/api/agent/profile/verify/route.ts:25, app/api/invoices/route.ts:73/88, app/api/invoices/recurring/route.ts:50, app/api/invoices/recurring/[id]/route.ts:57, app/api/invoices/self-billed/route.ts:97, app/api/onboarding/state/route.ts:74, app/api/tax-assessment-notices/[id]/route.ts:65). The summary is Swedish-only in both locales while the Zod field messages behind it are still mostly English (lib/api/schemas.ts), so an English-locale user now sees one Swedish word plus English field detail instead of a fully English but contentless sentence: accepted as strictly more information, and consistent with .claude/rules/i18n.md keeping regulatory error text Swedish in both locales. Translating lib/api/schemas.ts is the real fix and is not owned here.
[2026-07-26] Declined to route gnubok_set_employee_opening_balances through the shared lib/api/sparse-patch.ts helper; kept the inline whitelist merge instead. sparsePatch() parses the caller's body against the full schema and THEN narrows the write set to the keys literally present, which is right for the `UPDATE ... SET col = $1` sinks it was written for but wrong here for two reasons. First, the sink is setOpeningBalancesBulk()'s 9-column upsert shared with the v1 REST and internal UI routes, so "absent" is not expressible at the write: the stored values must be materialized into the item before it reaches the service, which is a merge, not a narrowing. Second, openingBalancesShape's refinement is cross-field (ytd_tax <= ytd_gross, sparade-dagar origin years validated against cutover_date), so parsing a sparse patch on its own sees the .default() zeros and gets both directions wrong: `{ytd_gross: 1000}` against a stored ytd_tax of 48000 passes when the merged row is illegal, and `{ytd_tax: 50000}` against a stored ytd_gross of 210000 is rejected because the default ytd_gross is 0. The tool therefore builds the patch from an explicit MERGEABLE_FIELDS whitelist, layers it over the stored row, and validates the MERGED item, which is what the four new tests in extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts pin. Empirically reconfirmed on zod 4.4.3 that .partial() does not strip .default() (`z.object({amount: z.number(), is_taxable: z.boolean().default(true)}).partial().parse({amount: 5500})` yields `{amount: 5500, is_taxable: true}`, byte-identical to the non-partial parse), so this is the fourth site of the same trap rather than a new one. Omission versus explicit clear is distinguished by `item[field] !== undefined` over the raw MCP args, before Zod runs: absent means carry the stored value, present-with-0 (or `{}` for vacation_saved_days_by_year) means clear, and the staging preview reports fields_provided/new_rows/updated_rows so the approver can see which columns the caller actually described. cutover_date was deliberately left in the inputSchema's `required` even though every other field became optional-by-merge: it carries no .default() so it cannot be silently zeroed, and forcing the caller to restate which cutover it is amending is worth more than the convenience. The real remaining gap is that no MCP tool reads employee_opening_balances back (gnubok_get_employee omits it, gnubok_get_vacation_balance reads the ledger), so an agent must already know the stored cutover_date; the staged params are the only read-back today.
[2026-07-26] Finished the components/bookkeeping/CreatePeriodDialog.tsx half of the fiscal-period 409 retirement, the follow-up the entry two above left open. The unreachable PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS branch and its "Las aret och skapa" button are gone together with handleLockAndRetry, so the dialog no longer POSTs to /fiscal-periods/{id}/lock at all and cannot push a user into locking a rakenskapsar before the bokslut. The route's new non-blocking `warnings` array is surfaced as ONE ochre AttnLine (UI-migration convention 6: 12.5px, --attn tone, single line, max one per surface) inside a done-state titled "Rakenskapsar skapat" with the created range under it, never a banner and never a toast: the period WAS created, and a toast fired while the dialog closes flashes past the one sentence that explains why the ingaende balanser are still pending. Reading the warnings off the response lives in the new pure lib/bookkeeping/fiscal-period-warnings.ts (extractFiscalPeriodWarnings + fiscalPeriodAdvisoryText) rather than inline in the component, because the repo has no component tests and lib/ is the scope vitest actually covers; it collapses a missing key, a non-array value and malformed entries to [] so a shape change can never render a blank ochre line, and joins several messages into one sentence run instead of stacking attn lines. onCreated() is deliberately deferred to the close handler on the advisory path: it refetches `periods` in both call sites (JournalEntryForm, FiscalYearsManager), which changes the suggested period and resets the form, which would pull the advisory off screen before it was read. Deleted the orphaned registry entry in lib/errors/structured-errors.ts outright instead of keeping it with a corrected comment: nothing emits the code (a repo-wide grep including ignored files finds only the retirement comment, this log, and one regression test asserting the legacy 409 envelope yields no advisory), StructuredErrorEntry has no deprecation field, and a live entry keeps the code selectable by errorResponseFromCode; a comment in its place records the retired "prior year must at least be locked" theory as wrong so it does not come back. The advisory renders as the route's own Swedish and is not keyed: the route emits no message_en, and bokslut/rakenskapsar domain copy stays Swedish in both locales per .claude/rules/i18n.md. Only the added chrome is keyed (bookkeeping.period_created_title, bookkeeping.period_created_range, common.close, all present in sv+en); the dialog's pre-existing hardcoded Swedish form copy was left untouched as out of scope.
[2026-07-26] enforce_credit_note_total_within_original() (migration 20260726130000) is NULL-safe at every term rather than only at the sibling SUM, because invoices.status and invoices.currency are both nullable columns with a default and no NOT NULL, and every NULL in this trigger fails in the permissive direction: a sibling that drops out of the sum hands out credit capacity that has already been spent. Concretely: COALESCE(c.status, '') <> 'cancelled' counts a NULL-status sibling (a bare <> is NULL and excludes it); c.id IS DISTINCT FROM NEW.id instead of <> so a NULL NEW.id could not exclude every sibling at once and make the cap unconditionally pass (column defaults fire before BEFORE-INSERT triggers, so NEW.id is never actually NULL: this removes the dependency on that reasoning rather than restating it); COALESCE inside ABS and around SUM so an empty sibling set and an all-NULL total read as 0 instead of NULL. The currency assert was changed from "skip when either side IS NULL" to COALESCE(currency, 'SEK') on both sides: opting out on NULL let a NULL-currency credit note be capped against a EUR original in a unit nobody had agreed on, and 'SEK' is what the column default and every read path already assume. Two comparisons are left deliberately three-valued in the SAFE direction and commented as such: NEW.status = 'cancelled' (a NULL status falls through and IS capped, because treating an unknown status as cancelled would be free capacity), and the sibling sum is NOT scoped by company_id even though the index it replaces was, since credited_invoice_id is a globally unique FK and counting any cross-company row is the conservative side.
[2026-07-26] LedgerGraph now weights the account ring through the pure helper components/agent-knowledge/ledger-graph-magnitude.ts, which measures the WHOLE ring in one unit (kronor if every account has a usable amount, booking volume otherwise) instead of the old per-group `spend > 0 ? spend : occurrences` fallback whose results were then summed into one denominator: 250 000 kr plus 4 bookings gave the amount-less account a wedge of 0,0016 percent of the circle and ranked it last, so the top-9 cap dropped it from the map entirely. Verified against HEAD: replaying HEAD selection verbatim reproduces both the sliver and the silent drop, and the new tests fail there. Deliberately did NOT reproduce the sibling resolution order from lib/agent/composer/inputs.ts (SEK row short-circuits, else amount_sek, else amount * exchange_rate, else null): the component cannot, because get_ledger_deep_context (20260708130000) already collapses each entity to one scalar as `abs(coalesce(t.amount_sek, t.amount))` and `coalesce(si.total_sek, si.total, 0)` and projects no currency column, so a 500 EUR invoice with no stored rate arrives as the number 500, indistinguishable from 500 kr. Forming a per-currency total on the client would mean inventing one. Refused to paper over it: the raw-foreign fallback stays reachable IN THE RPC and the real fix is a migration projecting total_amount_sek plus per-currency totals plus a count of rows with no SEK equivalent; entityMagnitude() is written as the single place that has to learn about them. Accepted the exposure because this surface is display-only (deep context is read solely by GET /api/agent/knowledge, rendered by AgentKnowledgePanel, never cached, never persisted, and lib/agent/** does not import lib/agent-context at all), so a wrong wedge misleads a human reading "Vad din agent vet" and cannot reach a verifikat or the standing agent instructions. The legend switches to a new key legend_size_volume when the ring is volume-weighted rather than claiming "Storlek = belopp" for a unit nothing is drawn in, and the detail card got its own card_amount label since legend_size no longer names the amount; both keys added to sv.json and en.json. Nothing is dropped from the ring: an account with no usable amount keeps a proportional wedge, which is what the truncation test pins.
[2026-07-26] Handelsbanken + Länsförsäkringar CSV parsers switched from the transaction date (Transaktionsdatum / Datum) to the booking date (Reskontradatum / Bokföringsdag), making them agree with every other parser (SEB, Swedbank, Nordea Företag, Northmill, camt053 BookgDt) and with the PSD2 feed. Why: the emitted date feeds generateExternalId AND the exact-date contentBucketKey, and enable-banking/lib/sync.ts keys every row on the ASPSP booking_date and cannot move (Berlin Group has no stable transaction date; the value_date path caused the June 2026 fleet-wide re-import). A card purchase swiped the 14th and booked the 16th therefore landed in two different dedup buckets and inserted twice, since the plus-minus-one-day drift bridge is shadow-only. Accepted cost, stated not incidental: rows where the two dates DIFFER get a new external_id, so a user who re-uploads an overlapping (not byte-identical) export can get one visible duplicate; rows where the dates are equal (the large majority) keep byte-identical ids and are not re-orphaned. Bounded by the hard (company_id, file_hash) block on the dashboard parse route, by CSV import being a manual action with no cron, and by the rows being user-visible; the v1 REST import has no such hard block. Chose this over adding date tolerance to the dedup bucket (would need the shadow drift bridge promoted to enforcing, which is a separate fleet-validated decision) and over moving the PSD2 side (impossible). Not applied: generic-csv.ts pickDateHeader still ranks transaktionsdatum above reskontradatum for the manual "Annan CSV" mapping, same bug, left to the owning change.
[2026-07-26] Manual-entry FX metadata (JournalEntryForm) now resolves its carrier line through a pure pre-pass, lib/bookkeeping/fx-line-slot.ts#resolveFxLineSlot, instead of the inline "first line whose account starts with 19, unless a latch already closed" rule. Three defects in one expression: (a) the 19xx prefix is not the rule the generators follow (invoice-entries.ts stamps 1510, supplier-invoice-entries.ts stamps 2440, transaction-entries.ts stamps the settlement leg), so a SEK/EUR växling booked 1930 then 1932 stamped the SEK leg and every consumer that looks the metadata up BY ACCOUNT then read the wrong one: bank-reconciliation ledgerLineAmountIn() reconciles a foreign cash account against amount_in_currency on that account's own lines, and voucher-matching narrows on journal_entry_lines.currency under the 1510/2440 prefix; (b) the latch closed only when the map reached a hydrated FX line, so an agent-created EUR draft with the bank leg first got the entry-level metadata stamped onto a SECOND line and two lines claimed the same foreign amount; (c) with no 19xx leg at all (a EUR supplier invoice booked 4010/2641/2440) the rate was dropped silently, and journal_entries has no currency or exchange_rate column, so it was unrecoverable. The rule adopted is the generators' rule stated explicitly: the monetary leg (ÅRL 4 kap. 13 § fordringar/skulder/kassa, so 15/16/17/19/23/24/25/27/28/29 and deliberately NOT 10-14 lager och anläggningstillgångar, 18 placeringar, 26 moms) whose SEK amount equals foreignAmount × rate, tie-broken by the account's own conventional denomination (1932 for EUR) and never by position. Chose to REFUSE (three new sv+en strings, thrown before the POST) over inventing a journal_entries.currency/exchange_rate column: a migration would be the honest place for an entry-level rate but the metadata is per-line by design and the schema change is not this pass's to make, and refusing loses nothing because the form still holds the values. Accepted cost: an entry whose 19xx leg is only PART of the foreign amount (two partial bank legs) is now refused where it previously stamped the full foreign amount onto a partial SEK leg, which was a mislabel rather than a feature. Falsification-checked rather than assumed: a faithful copy of the pre-fix rule was run against the new test file, 13 of 18 expectations fail against it, and the 5 that pass are labelled in the test as no-regression guards (SEK entry, missing rate, hydrated-line preservation, the SEK-cash-leg common case, the rounding tolerance the old rule "passed" by never comparing amounts). The two order-independence tests were each rewritten to assert BOTH orders in one test, because the reversed order alone also passed the old rule and therefore pinned nothing.
[2026-07-26] get_ledger_deep_context (migration 20260726150000) stopped counting foreign amounts as kronor, closing the RPC-side half of the LedgerGraph entry above. Both amount expressions fell back to the row own-currency column when no SEK value was recorded: abs(coalesce(t.amount_sek, t.amount)) and coalesce(si.total_sek, si.total, 0). Correct for SEK rows, where amount_sek/total_sek are NULL by design and the invoice-currency column already IS kronor; wrong for a non-SEK row with no stored SEK value, where the same expression yields the raw foreign magnitude, which was then summed into total_amount AND used as the total_amount sort key, so a 500 EUR invoice ranked as 500 kr instead of ~5750 kr and displaced a genuinely smaller counterparty. AP side is the worse one: total_sek is NULL whenever the caller merely omitted exchange_rate, the exact cohort 20260726120000 deliberately left NULL. Adopted the TypeScript rule verbatim (resolveSekAmountOrNull in mapping-engine.ts, resolveTransactionAmountSek in booking-duplicate-detection.ts, the unconverted_fx_count contract in ar-ledger.ts): SEK -> the existing coalesce untouched; non-SEK with a stored SEK value -> use it; non-SEK with a rate on the row -> multiply (deterministic arithmetic, statement 2 of the backfill); non-SEK with neither -> NULL, so sum() skips it. The SEK-vs-foreign discriminator is upper(coalesce(nullif(btrim(currency), empty-string), literal SEK)) = SEK, tested FIRST, which is what makes SEK rows byte-identical rather than merely equivalent. Chose per-entity unconverted_fx_count over a NULL total_amount: the field is typed number in DeepEntity, and a 0 paired with a nonzero count is the ar-ledger reading of a zero total, unknown rather than nothing. Deliberately did NOT exclude the rows from occurrences, cadence_days or the dominant-account pick: which account a booking went to is currency-free and is the part of this payload the agent leans on hardest, so only the money magnitude is withheld. Not yet rendered: DeepEntity in lib/agent-context/ledger-deep.ts does not declare the new field and LedgerGraph.tsx still prints formatCurrency(e.total_amount) unqualified at :268 and :744, so an all-unconvertible entity reads 0 kr with no explanation until that one-line wiring lands; the ring geometry is already honest because ledger-graph-magnitude.ts flips the whole ring to volume basis when any shown account has no usable amount. Migration NOT applied to any database and the pg test is UNRUN (no local Postgres).
[2026-07-26] employee_benefits validity period: the DB CHECK (valid_to IS NULL OR valid_to >= valid_from) is now mirrored in BOTH CreateEmployeeBenefitSchema and UpdateEmployeeBenefitSchema, and the message lives in one exported constant (BENEFIT_PERIOD_ORDER_MESSAGE) so the schema 400 and the route 400 cannot drift. The bound is inclusive and a NULL/omitted valid_to stays legal, matching run-calculation.ts, which selects benefits with valid_from <= payment_date AND (valid_to IS NULL OR valid_to >= payment_date). For the PATCH the schema refine can only fire when the body carries BOTH dates: a single-date PATCH has nothing in-body to compare against, so rather than leave that hole or reject single-date patches, the route recomputes the merged stored+patched pair against the row it already fetches for the bike-benefit check (one extra column on an existing SELECT, no extra round trip). Chose that over a DB-error-to-message mapping because the constraint is unnamed in the migration and matching on the generated employee_benefits_check name is not a deterministic guarantee. Also split the PATCH error mapping: it collapsed every post-update error into 404 'Förmån hittades inte', so a check_violation on the date range sent users hunting for a record that demonstrably exists (the route had just SELECTed it); now PGRST116 alone means not-found, 23514 means 400, everything else is an honest 500, and the same split was applied to the pre-update existence lookup. POST maps 23514 to 400 too: a CHECK violation is bad input, and 500 told the user to retry an insert that can never succeed. NOT fixed because the file was out of scope: components/salary/EmployeeBenefitsPanel.tsx renders the two date inputs with no min/max linkage and no client check, which is why the bad range was trivially reachable.
[2026-07-26] resolveSekAmount() in lib/bookkeeping/currency-utils.ts got a strict sibling, resolveSekAmountOrNull(), instead of having its return type changed to number | null (option b) or made to throw (option c). The root defect: the last resort returned the RAW FOREIGN AMOUNT, so no caller could distinguish 1150 SEK from 100 EUR relabelled, and 58 call sites inherited it. Chose the sibling because it CANNOT silently shift behaviour at a call site nobody read: the lenient function is now a one-line wrapper (resolveSekAmountOrNull(...) ?? amount), so its output is byte-identical to before for every input, and a delegation test pins that the two agree on every resolvable case. Option b would have touched ~58 sites in one pass, including roughly twenty that other agents had already fitted with local honest guards this session (SI_FX_RATE_MISSING, MATCH_INVOICE_BOOKING_RATE_MISSING, FX_CLOSING_RATE_UNAVAILABLE), and would have risked double-guarding or un-guarding them; option c would have broken lib/reports/, which legitimately needs the lenient arithmetic for pre-FX legacy rows. The lenient path is now documented READ-ONLY and every remaining lib/reports/ caller was confirmed to establish convertibility BEFORE calling, so for them the fallback is already unreachable. The pinned test at currency-utils.test.ts:34 was NOT inverted: it still asserts the lenient fallback, now relabelled "legacy readers only" and paired in the same test with the strict sibling returning null for the identical input, so the file states the contract of both rather than leaving it ambiguous.
[2026-07-26] The four duplicated private toSek closures on the sales booking paths (invoice-entries.ts generatePerRateLines + generateRotRutLines, propose-send-lines.ts, propose-payment-lines.ts) now route through resolveSekAmountOrNull() and refuse, via a new INVOICE_FX_RATE_MISSING code registered in structured-errors.ts (sales-side twin of SI_FX_RATE_MISSING). Each closure had its own inline "return amount // fallback for legacy data" branch, and because the 1510/1930 debit on the FX branch is DERIVED from the sum of the credits, every leg was scaled by the same wrong factor: the verifikation balanced, no trigger fired, and a 1 000 EUR sale posted 1 000 kr to 3001 and 250 kr to 2611 instead of 11 500 kr and 2 875 kr at 11,50, understating ruta 05 and ruta 10 (oriktig uppgift, SFL 49 kap 4 §). amountSek is passed as null on purpose: an InvoiceItem has no per-item SEK column, so exchange_rate is the only honest source at item granularity. Deliberate asymmetry in how the refusal surfaces: propose-payment-lines THROWS (PaymentBookingDialog resolves the proposal inside a try/catch and turns it into a translated toast), while propose-send-lines RETURNS [] (proposeSendLines runs inside a useMemo during SendInvoiceDialog's render, where a throw takes out the page; [] is the signal that function already uses for a text-only invoice, and `editable` is SEK-only so an empty proposal cannot disable the submit button). The [] bail is scoped to the item-driven path only: the invoice-level fallback in both propose files reads subtotal_sek / vat_amount_sek and has a genuine second source, so killing it would blank the preview for rows that CAN be expressed in kronor. Deliberately NOT touched, and reported instead: the ~10 invoice-level resolveSekAmount() call sites inside those same four files (invoice-entries.ts:390/402/438/513/653/761/797, propose-payment-lines.ts proposeAccrualLines) all pass a real *_sek second source and changing them would shift the most common FX path (accrual payment of a booked receivable) on a call site this pass did not own.
[2026-07-26] The two voucher-link matchers (lib/invoices/voucher-matching.ts, supplier-voucher-matching.ts) now resolve a ledger line's amount through the shared rule instead of reading debit_amount/credit_amount raw, and ledgerLineAmountIn was PROMOTED out of lib/reconciliation/bank-reconciliation.ts into lib/bookkeeping/ledger-line-amount.ts (which adds ledgerLineSideAmountIn for the one-sided sub-ledger case) with bank-reconciliation.ts re-exporting it unchanged, so the two sides cannot drift; a second implementation was declined for exactly that reason. journal_entry_lines.currency labels the DOCUMENT, so the "currencies match, safe to compare" guard passed on precisely the FX rows it existed to catch. The customer side was worse than a bad score: the amount band is pushed into SQL, so a foreign band bounded the always-SEK credit_amount and the one correct voucher was excluded server-side BEFORE scoring, leaving a same-magnitude decoy as the only candidate (reproduced against HEAD: the assertion fails with "expected [ 'je-decoy' ] to include 'je-correct'"). On a foreign invoice the band therefore moves onto amount_in_currency as |amount| <= ceil (some rows store the foreign figure negatively; direction comes from the debit/credit side) plus an equality filter on the currency label; NULL amount_in_currency drops out of both comparisons by SQL NULL semantics, which is correct because such a row carries no rate. The label guards in scoreCandidate/validate* were KEPT unchanged in shape but re-documented as counterparty discriminators rather than unit checks. Both validators fail CLOSED (CURRENCY_MISMATCH) on a matched-side line that carries no figure in the invoice's currency, rather than summing only the readable lines and understating a voucher that settles more than can be seen. invoice.currency is normalised through a new documentCurrency() because the column is `text default 'SEK'` and therefore NULLABLE while the TS type is a non-null union: a NULL would test `!== 'SEK'` and send a plain domestic invoice down the FX path where nothing converts. Confirmed the fix is NOT inert: both matchers read journal_entry_lines directly (a PostgREST embed on the customer side, fetchEntryLines on the supplier side) and both column lists were extended, so unlike the bank path they do not go through get_unlinked_gl_lines / get_account_gl_lines_for_matching, which project neither FX column. bulk-reconcile-supplier-vouchers.ts needed comments only: remainingOf() and ap_debit_amount are now both in the invoice's currency. A TypeScript-only fix is NOT sufficient: link_invoice_to_voucher and link_supplier_invoice_to_voucher are the authoritative commit path (the web routes and bulk reconcile call them with no TS pre-validation) and both summed the SEK column against a remainder in the invoice's currency, so the correct EUR voucher was rejected as AMOUNT_EXCEEDS_REMAINING and LEAST() wrote a payment_amount in the wrong unit; migration 20260726140000 rewrites only the foreign branch of both, leaving the SEK branch verbatim so SEK-only companies are byte-identical in SQL as well as in TS. Migration NOT applied to any database and its pg test (lib/invoices/__tests__/link-voucher-currency.pg.test.ts) is UNRUN: no local Postgres, and applying it to staging would contradict the instruction to leave it unapplied.
[2026-07-26] Removed the "eu_business suppliers must have a vat_number" rule from lib/pending-operations/schemas/create-supplier.ts instead of relocating it to the point reverse charge is applied. The rule cited ML 17 kap 24 §, which lists what a SELLER must put on an invoice it issues (p.3 the seller VAT id, p.4 the buyer VAT id when reverse charge or intra-EU applies); it says nothing about what a BUYER key supplier register must hold, so it cannot make a register field mandatory. Checked whether the requirement had a real basis under a wrong label: it does not. A Swedish buyer self-assessing omvand betalningsskyldighet on an EU purchase reports the purchase in ruta 21 and the output VAT in ruta 30-32, then deducts it in ruta 48, on the strength of being a taxable person established in Sweden (ML 16 kap 6 § with 6 kap 34 §); the VIES-validated counterparty VAT number is a condition for zero-rating an intra-community SUPPLY, i.e. the seller side, which is why only customers carries vat_number_validated / vat_number_validated_at and suppliers never has, and why lib/reports/periodisk-sammanstallning.ts reads customers only. Nothing downstream consumes supplier.vat_number: lib/bookkeeping/supplier-invoice-entries.ts and generateReverseChargeBasisLines() select 4535/4531/4425 from supplier_type plus invoice.reverse_charge alone, suppliers.vat_number is a nullable text column, and both other create paths (components/suppliers/SupplierForm.tsx and CreateSupplierSchema behind /api/suppliers and the v1 bulk-create) already treat it as optional, so the same EU company could be created through the dashboard and refused through the staged/agent path. An EU supplier below its national registration threshold has no VAT number at all. Also corrected two attribution errors in the same file header: Peppol SE-R-008/009 mandate a 7-8 digit Bankgiro length, not the modulus-10 check digit (that is Bankgirot own spec; SE-R-013 is the Luhn rule, and it is for organisationsnummer), and SE-R-001 only fixes Swedish VAT ids at 14 chars rather than supplying the per-country patterns, which come from lib/vat/vies-client.ts and happen to agree for SE. Left the file "ASVS V4.5" input-validation reference alone despite verifying it is wrong (ASVS v5.0.0 puts centralized validation at V2.2 and uses V4 for API and Web Service; 4.0.3 has no V4.5 either): it is a 12-site repo-wide convention and a one-file correction would only make the set inconsistent.
[2026-07-26] PATCH /api/salary/employees/[id] no longer spreads `personnummer` into its UPDATE payload, and the four internal salary read surfaces (employees list, employee detail GET+PATCH, employee POST, salary run GET, salary run employee GET) now return the mask as `personnummer_masked` with the encrypted column dropped from the payload entirely. Two halves of one hazard. Write half: `{ ...body }` carried personnummer verbatim while the encrypt branch was gated on the truthy `if (body.personnummer)`, so an empty string skipped the encrypt but was already in the payload; the UPDATE would have replaced the AES-256-GCM ciphertext with '' and left personnummer_last4 stale, then 500'd on the response decrypt AFTER the write committed. The only thing preventing that was the /^\d{12}$/ regex in lib/api/schemas.ts, i.e. a guard in another file, and house style there adds `.or(z.literal(''))` to optional string fields (clearing_number, account_number, bankgiro, tax_contact_email), so one routine edit would have destroyed encrypted PII. The guard now lives in the write path: absent/undefined means identity unchanged, '' and null are 400 (the column is NOT NULL and Skatteverket FK215 filing needs the value; omit the key to leave it alone), a masked value is 400 (validatePersonnummer strips non-digits, so a decorated 12-digit value would otherwise sail through), 12 digits validate and re-encrypt. Read half: these salary routes were the ONLY surface returning a masked value under the writable key name, the mirror that lets a read-modify-write client post the mask into the encrypt path; v1 (EmployeeWriteResponse), the MCP tools and lib/salary/employee-commands.ts already use the _masked suffix. Kept the personnummer-update capability rather than rejecting it outright like v1 and MCP do (identity immutable post-create): this route is the only place a mistyped personnummer can be corrected, and removing that silently would be a behaviour change nobody asked for. New exported type EmployeeMasked (Omit<Employee,'personnummer'> & { personnummer_masked: string }) so the six client consumers are typed against what the API actually returns instead of a field that no longer exists. NOT changed: extensions/general/mcp-server/server.ts:9578 and :9609, two MCP RESOURCE readers (not tools) that still mask under the writable `personnummer` key; that file was out of scope this pass.
[2026-07-26] The two arsredovisning persistence services (lib/bokslut/arsredovisning/profile-service.ts, narrative-service.ts) now rethrow the PostgrestError untouched (`if (error) throw error`) instead of re-wrapping it as `new Error("Failed to save ...: " + error.message)`. The re-wrap kept only `.message` and dropped `.code`, `.details` and `.hint`, so errorResponse()'s isPostgresError() branch never fired and every constraint failure on those two tables became INTERNAL_ERROR / 500. Reachable cases, both real caller mistakes: 23514 on annual_report_profiles_parent_consistency (PATCHing parent_group_size or prepares_consolidated_accounts onto a row already stored with is_parent_company = false, since the upsert writes only the columns present in the payload) and 23514 on arsredovisning_narratives_agm_decision_consistency (the route's Zod superRefine only catches it when outcome and decision arrive in the SAME body; a partial save leaves the merged row inconsistent). Chose plain rethrow over wrapping in a typed error that carries `code` because three services already do exactly this and document it (lib/articles/validate-revenue-account.ts, lib/bookkeeping/account-validation.ts, lib/invoices/issue-credit-note.ts): a second convention for the same job is the thing that lets code drift. The lost "which operation" prose is recovered from the bound `operation` on the log line plus PostgREST's own message, which names the relation and the constraint. `!data` was split out and left as a plain Error so the belt-and-braces branch still maps to 500 rather than borrowing a 400. Verified no over-correction: postgresCodeToStructured() maps only 23502/23503/23505/23514/22P02/22003/42501/42P01/40001/40P01, so an unmapped SQLSTATE (XX000 internal_error) and a non-Postgres throw both still produce 500. Also verified no data leak: errorResponse puts only pgCode in the envelope, and redact() reduces an Error to { name, message, stack, code }, so PostgREST's `details` ("Failing row contains ...") reaches neither the client nor the log. DECLINED to build a constraint-name-to-message table for the 163 named CHECKs, on the same reasoning as the employee_benefits entry above: the two constraints here are explicitly named, but the useful ones elsewhere are anonymous column-level CHECKs whose generated names are not a deterministic guarantee, and both constraints reachable here are already mirrored in Zod for the full-payload case, so the DB CHECK is the partial-update backstop rather than the primary user-facing validator.
[2026-07-26] app/(auth)/reset-password/page.tsx now consumes the pre-auth invite cookie, closing the last session-establishing path that never did. It calls the shared consumeInviteCookie() (lib/auth/consume-invite-cookie.ts) through a small co-located module, app/(auth)/reset-password/invite-handoff.ts, rather than inlining an eighth copy of the accept-and-clear logic. Three non-obvious calls. (1) The call site is AFTER POST /api/account/password returns 2xx, not at mode === 'set-password' where the recovery session already exists: that response comes out of requireAuth(), so its success is proof of a server-validated session, and waiting means no membership is ever created off a half-finished credential reset (ASVS v5.0.0 V6.4 account recovery must not weaken the factor, V7.5 sensitive operations need recent authentication). (2) The handoff DEFERS instead of attempting when the session still owes an MFA step-up, mirroring the middleware predicate exactly (shouldEnforceMfa plus aal1-with-aal2-required). POST /api/team/accept is not in apiPathSkipsMfaGate, and a recovery session is AAL1, so on hosted the exact user shape the finding is about (a consultant who already has a company, and whom middleware therefore forced to enroll TOTP) would have received a bare 403 from the middleware, which classifyInviteAcceptStatus reads as 'wrong_email': a legitimate invitee told their invitation belongs to someone else. Deferring leaves the cookie untouched and the same predicate bounces them to /mfa/verify, which consumes it after the second factor. BankID-linked and self-hosted users are deliberately never deferred, because nothing would bounce them and the token would sit unused. (3) A token that survives a transient failure routes to /select-company, not '/': it re-runs acceptPendingInviteByToken() server-side on every load and, unlike /onboarding, is reachable for a user who already has a company, which is precisely why the existing safety nets did not cover this shape. 'wrong_email' and 'spent' deliberately do NOT take that detour (the email equality check is deterministic for the same account, and a spent token is gone), so they just toast and keep the normal navigation. The flow lives in a sibling module rather than page.tsx because a Next.js page file may not export anything but the component and the route segment config (tsconfig includes .next/types), which would have left it untestable in a repo with no component harness; app/(dashboard)/request-context.ts and app/api/sandbox/seed/customers.ts are the existing precedent. No new i18n keys: the toast reuses INVITE_PROBLEM_MESSAGE_KEYS so all five surfaces word the same failure identically.
[2026-07-26] The three staged-operation FX writers in lib/pending-operations/commit.ts (create_transaction, create_invoice, create_supplier_invoice_from_inbox) now REFUSE at commit rather than persisting a foreign-currency row with a NULL rate, and all three anchor the Riksbanken fetch on the row's own date with the supabase client passed so exchange_rates is the read-through cache (the lib/transactions/ingest.ts call shape). ingest.ts is allowed to store NULL because it is a bulk bank feed where one unreachable rate must not abort the batch and its rows stay repairable via /api/transactions/[id]/refresh-exchange-rate; a staged operation is a single row with the approver present, and the categorization path does NOT refuse a rateless row (it books through the lenient resolveSekAmount, so a 1500 USD transaction debited 1500 kr while buildCurrencyMetadata stamped the same line currency USD / amount_in_currency 1500). Commit is therefore the last boundary where the contradiction is still visible to a human, so it is where the refusal belongs. create_supplier_invoice_from_inbox reuses resolveSupplierInvoiceExchangeRate + supplierInvoiceSekAmounts rather than a fourth private copy, and resolves the rate BEFORE get_next_arrival_number so a refusal never burns an ankomstnummer; create_transaction likewise resolves before ensureManualCashAccount. A caller-supplied positive rate is still trusted verbatim on the supplier-invoice path, which is what makes the number the approver saw in the staged preview the number written. Both invoice paths also stopped gating the *_sek columns on `exchangeRate ? ... : null`: a SEK invoice resolves to rate 1 so total_sek === total, because that guard left total_sek NULL on every ordinary Swedish invoice. lib/invoices/build-invoice-write.ts (the web/REST customer-invoice path) still leaves the SEK columns NULL for SEK invoices and on a failed fetch: not changed here because it is outside this fix's scope, so the two customer-invoice writers are knowingly divergent until it is aligned. No new structured-error code was added for the transaction refusal (SI_FX_RATE_MISSING and INVOICE_FX_RATE_MISSING already cover the two invoice cases): a literal Swedish message matching the register's wording keeps the diff inside the one file this fix owns.
[2026-07-26] generic-csv.ts pickDateHeader (the auto-guess that seeds the manual "Annan CSV" column-mapping UI) re-ordered from transaktionsdatum-first to booking-date-first, closing the third site of the CSV date finding logged above. Tiers are now bokföringsdag/bokföringsdatum/bokfdag, then reskontradatum, then a bare datum/date, then transaktionsdatum/transdag; every tier still skips headers containing 'valuta' so Valutadag and a plain Valuta column can never be picked. transaktionsdatum was DEMOTED, not removed: a file carrying only a transaction date must still map without user input, which is now pinned by a test. Two non-obvious calls. (1) A bare 'datum' was placed ABOVE transaktionsdatum rather than below: in every single-date export in this directory (Nordea, Skandia, Nordea Business format D) the bare Datum column is the posting date the Saldo ties to, while transaktionsdatum is explicitly labelled the swipe date, and keeping bare datum high also leaves behavior byte-identical for the large majority of files that carry exactly one date column. Files carrying both plus a real booking column resolve at tier 1 or 2 before either is reached. (2) The booking regex was widened from /bokf(ö|o)ringsda(g|tum)/ to /bokf((ö|o)rings)?da(g|tum)/ so Swedbank's abbreviated Bokfdag matches by LABEL. Swedbank is the decisive precedent (Transdag is present in the file and formats/swedbank.ts deliberately picks Bokfdag), and at HEAD a Swedbank file forced through this path only landed on Bokfdag by accident: no tier matched, so the value-based fallback took the leftmost date-shaped column, which an exactly-8-digit Kontonr would have stolen (SUGGEST_DATE_PATTERNS accepts /^\d{8}$/). Severity is deliberately stated as a bad DEFAULT, not a correctness gate: suggestColumnMapping is reachable only from components/import/BankFileColumnMappingStep.tsx, the "Datum *" dropdown lists every column with its own header label, and the live preview renders the chosen date column, so the user can see and change the guess before confirming. The v1 REST import cannot reach it at all (parseBankFile with format generic_csv uses genericCSVFormat.parse's fixed date-0/description-1/amount-2 mapping). external_id consequence is the same bounded one as the parsers: the date is an input to generateExternalId's composite, so only rows where the two dates DIFFER get a new id; the (company_id, file_hash) block at app/api/import/bank-file/parse/route.ts:49 stops a byte-identical re-upload from reaching the parser, but it does not cover an overlapping non-identical export, and the v1 route has no such block (it upserts on the same key and parses regardless). NOT fixed, reported instead: parseGenericCSV hardcodes currency 'SEK' and GenericCSVColumnMapping has no currency field at all, so a foreign-currency row imported through "Annan CSV" is booked as SEK where nordea-business.ts honors its Valuta column and wise.ts refuses a row with no currency; fixing that needs a type, mapping and UI change, which is a different class from the date tier list this pass owns.
[2026-07-26] The three payment-file handlers in components/salary/PaymentFilePanel.tsx and components/salary/TaxPaymentPanel.tsx (LB / pain.001 salary payment file, skattekonto payment file, mark-as-paid) had try/finally with no catch, so a fetch that threw escaped as an unhandled rejection and the user was told nothing at all on the one screen where "nothing happened" means wages or the skattekonto payment do not move. All three now report exactly one toast per outcome. Four non-obvious calls. (1) The two download handlers adopt downloadFile() unchanged at its default 15s deadline (these routes are 3 to 4 indexed queries plus in-memory string generation, so the deadline is many times the realistic cold-start-plus-one-eu-north-1-round-trip worst case), but mark-paid is a POST and downloadFile exists to save a blob, so it got a sibling, lib/browser/post-action.ts, with the same discriminated union and the same bounding. POST_ACTION_TIMEOUT_MS is deliberately not shorter than DOWNLOAD_TIMEOUT_MS: aborting a mutation early reports failure for a write that may have landed, and that ambiguity is worse than a few extra seconds of spinner. postAction does not parse a success body, following download-file.ts's rejection of body-shape validation as coupling the client to the route's payload. (2) The timeout and network copy is supplied by the call site through lib/browser/action-failure.ts failureDescription(), while a 'server' failure keeps the route's own sentence: the routes name the exact missing field ("Bankgironummer saknas i företagsinställningar"), which beats any panel copy, whereas getErrorMessage has only the generic "Något gick fel" for a fetch that produced no response, and a user cannot act on that. mark-paid's timeout copy says to reload and check rather than claiming the change failed, because on a mutation a timeout genuinely does not know. (3) Moving into downloadFile/postAction drops the old context: 'salary' argument, since neither helper takes one. This changes nothing for any status in HTTP_STATUS_MAP (400/401/403/404/409/422/429/500/502/503 all answered before the context fallback was ever consulted) and only swaps "Kunde inte hantera löneuppgifterna" for "Något gick fel" on statuses outside that map, e.g. a raw 504; both are generic and the toast title is more specific than either. The three *_failed_fallback keys are now unreferenced but were left in place: this pass owned only the keys it added. (4) NOT fixed, reported instead: nothing in the codebase stops a second payment file being generated for the same salary run. Both salary routes accept status in (approved, paid, booked) and only overwrite payment_file_format / payment_file_generated_at, the tax route does not consult tax_paid_at, and the LB format carries no batch identifier at all (TK 11 has sender bankgiro plus a YYMMDD creation date, so two files made the same day are byte-identical), so cross-format bg_lb-plus-pain001 duplicates for one run share no id any dedup could correlate. pain.001 is the partial exception: MsgId is ACCOUNTED-{orgdigits}-{YYYY-MM} and every InstrId/EndToEndId derives from it, so a regenerated file repeats them, which is the key a bank duplicate-file check uses, but that control is bank-side and not ours to rely on. Inside these panels the fix is the click-race guard (if (downloading) return, plus the existing disabled state) and the catch itself, since a silent failure is what invites the human-speed retry that produces the second file; a real regeneration guard is a server change to routes this pass did not own.
[2026-07-26] lib/api/sparse-patch.ts sparsePatchBody forwards zod issues by SPREAD (`ctx.addIssue({ ...issue })`) rather than through a double cast, and declares its return as `z.ZodType<Partial<z.infer<S>>, unknown>` rather than casting the pipe. Both casts turned out to be avoidable on zod 4.4.3. `addIssue` is typed against `$ZodSuperRefineIssue`, whose members flatten to `{ [k: string]: unknown } & <the raw fields>`; a finalized `$ZodIssue` is declared as an interface, so it gets no implicit index signature and is rejected, but a spread produces an object-literal type, which does get one, so the same value passes with no cast. Verified that the forwarded issues are byte-identical to what a plain `validateBody(request, schema)` produces (same `code`, same `path`, same `expected`, same `message`, all issues, not just the first), pinned by three tests in lib/api/__tests__/sparse-patch.test.ts including a direct equality assertion against the bare-schema 400 body: flattening to `code: 'custom'` would have been a silent API break for clients that branch on it. The return-type change works because ZodType's Input parameter is declared `out`, so the pipe's `unknown` input does not have to be laundered to satisfy `validateBody`'s `z.ZodType<T>`. Also made the contract total by throwing a TypeError when the schema's output is not an object, instead of letting `Object.entries(null)` surface as an opaque "Cannot convert undefined or null to object" 500 from a misuse like `sparsePatchBody(Schema.transform(() => null))`. DECLINED to add a check:guards rule for this antipattern. A syntactic rule can only see "validateBody called with an Update*-shaped schema" (19 sites), which cannot distinguish the three cases that need three DIFFERENT fixes: narrow-after-parse is right for a `SET col = $1` sink, wrong for a fixed-column upsert (which needs a stored-row merge, as app/api/kpi/preferences/route.ts now does), and wrong again for a cross-field refinement (which needs a defaults-stripped patch base, as EmployeeSchemaPatchBase does). A ratchet that pushed all 19 toward sparsePatchBody would therefore manufacture bugs in the second and third class, which is worse than noise; the trap is pinned by an executable test of the zod behavior itself instead.
[2026-07-26] components/extensions/general/InvoiceInboxWorkspace.tsx: the inbox-address copy button now awaits the clipboard write through a new copyInboxAddress() (components/extensions/general/inbox-address-copy.ts, wrapping lib/browser/copy-to-clipboard.ts) and shows an icon-swap plus one ochre AttnLine, and the "Adress kopierad" toast was REMOVED rather than made conditional. Two reasons: TOAST_LIMIT is 1 (components/ui/use-toast.tsx ADD_TOAST slices to one), so a warning emitted next to any other toast is evicted and never rendered, and the CopyBlock in components/settings/ApiKeysPanel.tsx already established the inline idle/copied/failed treatment for exactly this problem; a second, toast-shaped idiom for the same event would have to be learned separately. On failure the address drops its `truncate` for `break-all` and keeps `select-all`, because with no clipboard, reading it off the screen is the only way to get it. The document-preview pane grew a four-state DocumentLoadState because "Inget underlag bifogat" was being rendered for rows that DO carry a document_id: both the swallowed catch and the missing !res.ok branch left docUrl null, and so did the entire in-flight window on the happy path. `none` is now the only state allowed to claim nothing is attached; the metadata fetch also moved to fetchWithTimeout at 15s so the new loading state cannot spin forever. Third fix, same class, different surface: a non-404 answer from /inbox/address (500, 503 with no RESEND_INBOUND_DOMAIN, an HTML error page, offline) previously left inboxAddress null and offered "Aktivera inkorgsadress", and handleRotateAddress skips its confirm() precisely when inboxAddress is null, so one click silently retired a live address that suppliers and forwarding rules point at; a 404 still means "none provisioned" and still offers activation, anything else now shows a retry line, suppresses the EmptyPreview activation CTA, and forces the confirm dialog. A failed items list read no longer renders "Inkorgen är tom" or the "ladda upp ditt första underlag" onboarding step. NOT fixed, reported instead: FieldsRail's handleRetry (retry-extraction) has try/finally with no catch, so a throwing fetch clears the spinner and says nothing at all; that is a missing-handler bug rather than a swallow-and-claim, and it belongs with the other salary/payment no-catch handlers fixed the same day.
[2026-07-26] The invoice editor's per-line Moms picker now RENDERS getPermittedVatRates() while the DEFAULT, the article-rate adoption test and the customer-switch snap all keep reading getAvailableVatRates(). The picker previously rendered the default set, which for a VAT-validated EU business or a non-EU business is a single locked 0% option, so a Stockholm hotel could not put 12% on a hotel night sold to a German company even though that supply is taxed where it is performed (ML 6 kap.) and carries Swedish VAT. Widening getAvailableVatRates() itself was rejected (again): it would leave a stale 25% line at 25% after switching to an EU customer, and it would make the new-line default and the snap follow the widest lawful rate instead of the lawful DEFAULT (0% under huvudregeln, ML 6 kap. 34 §). The two sets therefore live in one plain module, components/invoices/line-vat-rates.ts, so the four behaviours are named and unit-testable (the repo renders no components in tests). Three non-obvious calls. (1) The customer-switch snap moves ONLY the lines still sitting on the previous customer's default rate, instead of every line. Snapping all of them destroys the lawful case the widening exists for (a deliberate 12% hotel line becomes 0% the moment the customer is picked), while snapping none of them keeps both stale-rate bugs. An inherited rate was never chosen by the user, so following the new customer is safe; a rate the user moved off the default is the only signal that a taxed-where-performed supply is on the line, and overwriting it would be guessing. A prompt was rejected as a dialog on every customer change for a case that is almost always a no-op. The rule is symmetric, which also fixes the reverse direction the old code never handled: 0% lines inherited from an EU customer now follow to 25% when the customer becomes Swedish, where before they silently under-charged VAT. Free-text rows are excluded (no amounts, never book). Edit and copy mode only RECORD the baseline on the first customer resolution and never snap, since the pre-filled lines already belong to that customer. (2) Article-rate adoption still gates on the DEFAULT set, so a foreign business customer keeps the line's rate when an article is picked. An article's stored vat_rate is its domestic rate and nothing on it says the supply is taxed where performed, so adopting 25% from the article would put Swedish VAT on a reverse-charge invoice by itself; the hotel picks the article for name/price/unit and sets 12% explicitly. (3) Attention is one ochre AttnLine sentence (UI convention 6) that renders only once a non-zero rate is actually selected for a customer whose default is 0%, never a banner and never for the normal 0% case. The four remaining server gates (lib/pending-operations/commit.ts, lib/invoices/self-billed-sale.ts, lib/invoices/recurring-schedule-service.ts, app/api/v1/.../invoices/bulk-create/route.ts) were pointed at getPermittedVatRates so every write surface accepts exactly what buildInvoiceWriteData accepts, pinned by lib/invoices/__tests__/vat-rate-gate-parity.test.ts. NOT fixed, reported instead: extensions/general/mcp-server/server.ts around line 4475 still builds its allowedRates from getAvailableVatRates, so gnubok_create_invoice still refuses a 12% line to an EU business; that file is owned elsewhere.
[2026-07-26] The sharpened RC_INPUT_VAT_MISMATCH is now WIRED on both surfaces; before this it was dead code, because `runVatDeclarationChecks(rutor, accountTotals?)` had no caller passing the second argument and every caller therefore ran the weak `ruta48 + 0.5 < rcOutput` fallback. MCP: computeVatReportWithRutor returns the `accountTotals` map it already builds, runVatCompletenessChecks threads it, and both callers pass it (gnubok_vat_close_check from the report aggregate, gnubok_vat_declaration_validate from the declaration). Web UI: calculateVatDeclaration now carries ONLY the 2645/2647 debit/credit pair on the response as `VatDeclaration.rcInputAccountTotals`, and components/reports/views/index.tsx rebuilds a 2-entry Map through the new rcInputTotalsFromDeclaration(). Four non-obvious calls. (1) The pair, not the whole totals map: the check reads nothing but 2645 and 2647, so shipping every VAT account balance would grow the response (and the v1 REST body, which returns the declaration verbatim) for no behavioural gain; lib/reports/__tests__/vat-declaration.test.ts asserts the 2-entry projection and the full map produce identical findings, on a fixture that carries a balance on every OTHER ruta 48 account, which is also what pins the mirrored RC_INPUT_VAT_ACCOUNTS list to the private RC_INPUT_ACCOUNTS inside vat-declaration-checks.ts. (2) The field is OPTIONAL and rcInputTotalsFromDeclaration returns undefined (never an empty Map) when it is absent: an empty map reads as "0 kr beräknad ingående moms" and would turn a correct declaration from an older deploy into a false warning, whereas undefined falls back to the ruta 48 form. (3) The severity stays WARNING on both surfaces: limited avdragsrätt (blandad verksamhet, ML 13 kap 18/24-25) makes a shortfall legally correct for some filers and no SKV gateway rule rejects it, so blocking would invent a rule and risk a 625 kr forseningsavgift for a legal filer. isFilingBlocked reads ERROR only, so the sharper check cannot disable Skicka. (4) The `noInputVatAtAll` escalation in computeVatCloseCheck was KEPT even though its stated reason ("cannot isolate the RC share of ruta 48") no longer holds: its import half (rutor 60-62 against ruta 48) is coverage the shared checks still do not have, since they compare import output only against the tullvärdesunderlag in ruta 50. Comment corrected in place instead of deleting the block. No MCP schema changed, so the tools/list payload stays at 57 475 of 57 500 tokens.
[2026-07-26] The phantom-column net (tests/schema/no-phantom-columns.test.ts + tests/schema/schema-guard.ts) takes its ground truth from REPLAYING supabase/migrations/*.sql, not from information_schema in a pg-real test and not from a checked-in schema snapshot. pg-real is authoritative but only runs in the test-pg-real CI job, so a developer's `npm test` would never see the guard that exists precisely because mocked tests cannot see this class; a snapshot rots and needs a generator to keep honest. Replaying 523 migrations costs ~250ms, has no artifact that can go stale, and the migration files are already the repo's contract with prod ("never leave a remote DB ahead of the repo"). The cost is parser fidelity, so every unparsed construct degrades to "unresolved" rather than to an accusation, and fidelity is pinned three ways: the tables that deliberately lack company_id, the tables that got company_id from an `EXECUTE format('ALTER TABLE %I ADD COLUMN ...')` loop, and full agreement with MASTER_DATA_DUMP_TABLES in lib/reports/full-archive-export.ts, whose own contract is asserted against a live database by tests/pg/full-archive-coverage.pg.test.ts. Two parser features exist only because the repo needs them and their absence produced 1 554 false accusations on the first run: dynamic ADD COLUMN inside `DO $$` blocks over an `ARRAY[...]` table list (40 tables got company_id that way), and plain ALTER TABLE inside `DO $$` idempotency guards (supplier_invoice_payments.user_id, and the automation_webhooks -> webhooks rename, without which 14 v1 webhook routes read a table the model denied existed). Failure mode is split deliberately: named columns, tables, CHECK values and onConflict targets that resolve confidently are hard assertions minus a 24-entry documented baseline of pre-existing breakage, while expressions the scanner cannot resolve (145 runtime payloads, 116 interpolated selects, 48 dynamic or-strings, 32 spreads) are counted against a ceiling of 420 instead of failing, because failing on every unresolvable expression is the noise that gets a guard disabled. A floor of 12 000 resolved references guards the guard: if the scanner stops following builder chains the test fails rather than going quietly slack. Test files are OUT of scope (a phantom column in a mock is not a production bug) and scripts/ is IN (those run against prod). DECLINED to also ship a pg-real companion that diffs the replayed model against information_schema: it is the obvious next step and would close the fidelity loop permanently, but with no local Postgres it could not be run in-session, and shipping an unverified test is how a green tree becomes a red one.
[2026-07-26] A blocked OAuth popup in ArcimMigrationWorkspace now falls back to a full-page flow instead of getting a toast, matching SkatteverketConnectPanel.tsx:205-213 rather than inventing a second treatment. The return value of `window.open` was discarded at both provider-auth sites (the first-connect button and the retry arm of handleReconnect), so a blocked popup looked exactly like a successful one: nothing opened and nothing was said. A toast was rejected because the flow is recoverable: the callback route already handles a missing window.opener by redirecting to `/import?migration=connected&consentId=...` (extensions/general/arcim-migration/index.ts:527-535, and the mirror error arm at :452-475), `?migration=` sets mode='migration' on the import page, and handleOAuthReturn resumes the wizard at the preview step, so the user finishes the migration instead of being told it failed. That also keeps the fix free of a new i18n key and free of the TOAST_LIMIT=1 eviction trap. lib/browser/deferred-tab.ts is deliberately NOT used here: it severs `tab.opener` to preserve noopener semantics, and this flow's success signal IS `window.opener.postMessage`. No activation problem exists at either site (both opens are synchronous in the click, or already pre-opened), so no pre-open indirection was added. Not testable as a component: the repo has no jsdom/testing-library/E2E and vitest runs node-env over lib/ + app/api/, and the only extractable seam would be `if (!popup) navigate(url)`, a test of itself. Instead the fallback's precondition is pinned, since this change promotes the callback's previously near-dead no-opener arm into the only path a popup-blocked user has: two cases in extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts assert the exact `/import` URL and params for both success and failure. Those pass at HEAD by design; they are a contract guard, not proof of the fix.
[2026-07-26] The "Ångra" ToastAction on app/(dashboard)/deadlines/page.tsx now goes through a real handler (handleUndoComplete) and the /api/deadlines/[id]/complete route honours an explicit `is_completed` boolean instead of blindly toggling. The finding was that the inline onClick had no res.ok check and no refetch, so a successful undo of a Skatteverket deadline looked exactly like a failed one. Two non-obvious calls. (1) DECLINED lib/browser/post-action.ts even though it exists for precisely this shape: postAction sends `{ method: 'POST' }` with no body, and this fix needs a body, because the honest undo is an explicit target state rather than a second toggle. Editing post-action.ts to grow a `body` option was off-limits this session, so the handler hand-rolls the fetch in the same shape as the five sibling handlers already in the file, using getErrorMessage(body, { statusCode }) for the server arm (envelope-aware, unlike the siblings' `new Error(result.error)` which renders "[object Object]" when the canonical envelope is returned) and the existing `load_failed_description` key for the never-completed arm. No new i18n keys. (2) The route change is load-bearing, not scope creep: the client picks its toast from the state it asked for, so a route that ignores the body can persist the opposite of what the toast claims, and an undo click landing after the row was already un-ticked in another tab or by an MCP agent would re-complete the deadline. Explicit state makes it idempotent; a body-less or non-boolean body still toggles, so nothing else has to migrate (the only two callers are both on this page). fetchData now returns whether the list was really refreshed, and the success toast is withheld when it was not, because TOAST_LIMIT=1 means a success sentence would evict fetchData's own load-failure sentence. Proven at the route seam: the two idempotency cases in app/api/deadlines/[id]/complete/__tests__/route.test.ts fail against HEAD (expected false, got true). The component itself is untestable here (no jsdom/testing-library, vitest is node-env over lib/ + app/api/).
[2026-07-26] Sweep 5.5's two "weaker" settings toggles split: one fixed, one declined. FIXED `components/settings/sections/AssistantSettingsContent.tsx` FabVisibilityRow, which already checked `res.ok` and already rolled the switch back, so the defect was never the divergence the sweep described (UI new, DB old) but pure silence: a switch that flips and flips back reads as a broken control, so the user re-clicks instead of re-authenticating, and 401 on an expired session is the likeliest refusal on a route that is `requireAuth()` plus one upsert. It now emits exactly ONE destructive toast on both failure arms, mirroring the same-directory sibling `DimensionsToggle.tsx:48-55` rather than inventing a treatment. Four non-obvious calls. (1) `lib/browser/post-action.ts` was NOT adopted: it hardcodes `{ method: 'POST' }` with no body, this route is PATCH with a JSON body, and widening the helper was out of bounds. (2) The optimistic flip plus rollback was KEPT instead of moving to DimensionsToggle's derive-only-on-success model; the rollback was already correct and a laggy switch is a worse trade than a responsive one that explains itself. (3) `router.refresh()` moved OUT of the try: inside it, a refresh that threw would roll the switch back and announce a failed save for a write that had already landed. (4) `statusCode: res.status` is passed to `getErrorMessage` on purpose, because the route hand-builds `{ error: 'Could not save preference' }`, un-localized English that `isSwedishUserMessage` correctly declines, so without the status the description would degrade to the generic "Något gick fel"; with it, 401 reads "Din session har gått ut. Logga in igen." One new key pair, `settings_assistant.fab_save_failed` (sv + en). NOT testable: no component or E2E harness exists (vitest is node-env over lib/ + app/api/), and the server half of the contract is already pinned by `app/api/user/preferences/__tests__/route.test.ts:106-110` (500 when the upsert fails), which passes at HEAD, so a test there would assert someone else's correct code, not this change. DECLINED `components/settings/PeriodiseringAutoDetectToggle.tsx:72-79`: the swallowed `setItem` catch cannot produce the lie the sweep alleges, because the switch is `checked={enabled}` off `useSyncExternalStore(subscribe, readStored)`, `components/ui/switch.tsx` is a pure controlled Radix pass-through with no internal state, and a `setItem` that throws leaves `readStored()` returning the old value, so the `notifyChange()` re-read snaps the switch back on the same tick; there is no persisted state to misreport. Its real defect is one the sweep did not see and is a product decision, not a silent-failure fix: `periodisering_autodetect_enabled` has no reader anywhere in the repo, and the wizard consumes `data.autoDetected` unconditionally (`app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx:171,243,457,481`), so the preference is inert and a "storage blocked" toast would be noise attached to a control that changes nothing.
[2026-07-26] The kassaflödesanalys PDF button (`app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx`) now downloads through `lib/browser/download-file.ts` with its own busy state instead of assigning `window.location.href`. Sweep 5.5 called this a missing busy state whose disable tracked the report fetch; the site is worse than that reading and worse than the ten neighbouring download sites it was grouped with. A location assignment has no `res` to check and no promise to catch, so a busy state cannot be bolted on: it has to become a fetch. And where the neighbours save an error envelope to disk and leave the app running, this one navigates the whole SPA to the route's `{ error: string }` body, because the error responses carry no Content-Disposition to cancel the navigation, so a failed statutory report costs the user the page they were on. Three non-obvious calls. (1) The client now names the file itself, duplicating the route's `kassaflodesanalys-<period_start>.pdf`; `downloadFile` has no Content-Disposition seam and `lib/browser/**` was off-limits this session, so the duplication is forced. It is safe because both sides read `period_start` from the same `generateKassaflodesanalys` output (the JSON route returns it verbatim), and it is now pinned by an exact Content-Disposition assertion in the new `app/api/reports/kassaflodesanalys/pdf/__tests__/route.test.ts`. (2) The route's hand-rolled `{ error: 'Företagsinställningar saknas' }` / `'Räkenskapsperioden kunde inte läsas...'` bodies were left alone rather than migrated to the canonical envelope: `getErrorMessage` passes both through on `isSwedishUserMessage`, so the toast already shows the route's own sentence, and rewriting an untouched route's error shape is scope the finding does not ask for. Those two bodies became user-facing with this change, so the same new test asserts they survive `getErrorMessage` instead of degrading to the generic status text. (3) The shared 15s deadline is used unchanged rather than widened: this is one fiscal year of aggregation plus a single-page stock-font render, and the route sets no `maxDuration`, so a longer client deadline would only keep the spinner alive past the point the function itself is killed. No success toast (the saved file is the feedback), which also keeps the handler clear of the TOAST_LIMIT=1 eviction trap. Three new `reports.pdf_download_*` key pairs; the page's Swedish body copy was left hardcoded, since `useLocale()` was needed for `getErrorMessage` anyway and translating the statutory section headings is not this fix's business. The fix itself is NOT testable here: the entire delta is in a `.tsx` client component, and vitest runs node-env over `lib/` + `app/api/` with no component or E2E harness. The new route test passes at HEAD by construction (the route is unchanged); it is a contract guard for the filename and error bodies the client now depends on, not proof of the fix.
[2026-07-26] Deleted lib/core/tax/tax-code-service.ts and its test (phantom `tax_codes` table found by tests/schema/no-phantom-columns.test.ts): the table was never created by any migration, migration 20240101000012 is an explicit placeholder recording that the tax-code engine was "planned but never deployed", the `seed_tax_codes_for_user` RPC it called does not exist either, and the only importer was its own mock-backed test. The need is covered, and covered more completely, by the account-driven route: `ACCOUNT_RUTA` in lib/reports/vat-declaration.ts mirrored by `ACCOUNT_TO_BOX` in lib/vat/moms-box-mapping.ts, which spans every ruta 05-62 with debit/credit sides, matches the swedish-vat reference (which maps rutor to BAS accounts, not to skattekoder; a per-line tax-code dimension is a Fortnox/Visma implementation detail, not a legal requirement), and is already the documented contract in the MCP tool schema ("tax_code: free-text tag, does NOT drive momsdeklaration ruta"). The dead code was also arithmetically wrong: it added the same abs(net) amount to basis and tax boxes alike, so an MP1 line put its VAT amount into ruta 05 as well as ruta 10. Kept: the `TaxCode`/`TaxCodeId` types in types/index.ts and the `makeTaxCode` fixture in tests/helpers.ts, left in place only to stay out of files other agents were editing; both are now orphaned and can go with the next sweep of those files.
[2026-07-26] The MCP staging tool gnubok_create_invoice (extensions/general/mcp-server/server.ts) now gates line VAT rates on getPermittedVatRates instead of getAvailableVatRates, making it the seventh and last write path to agree with the other six; it is pinned in lib/invoices/__tests__/vat-rate-gate-parity.test.ts, whose exclusion note for this file is removed. It gates at STAGING time, so gating on the picker default refused a lawful invoice ("VAT rate 12% is not allowed for customer type eu_business. Allowed rates: 0%") before the executor's own already-widened gate in lib/pending-operations/commit.ts was ever reached. Law verified against the swedish-vat reference, not from memory: huvudregeln (ML 6 kap. 34) taxes a B2B service where the buyer is established, but supplies taxed where they are performed carry Swedish VAT even to a foreign business (hotel/restaurang 12%, persontransport and event admission 6%, fastighetstjanst and korttidsuthyrning 25%), so the lawful set spans 0/25/12/6 and no single non-zero rate can be whitelisted instead. Defaults are untouched: an item that omits vat_rate still falls back to getVatRules().rate. Booking is already correct for the widened case (lib/bookkeeping/invoice-entries.ts keeps reverse_charge/export only for 0% lines, so a 12% line on a reverse-charge invoice books 3002 + 2621 into ruta 05/11 while the 0% lines stay 3308/ruta 39). Non-obvious call on the advertised surface: extensions/general/mcp-server/resources/vat-treatments.ts now publishes BOTH sets per customer type, renamed default_rates (the picker default) alongside permitted_rates (the lawful set), plus a swedish_vat_to_foreign_business note naming the exceptions. Publishing only the default told an agent 0% was the only lawful rate and it would never attempt the hotel invoice; publishing only the lawful set would invite 25% on a plain consulting invoice to the same German company. The pair plus the note is the only variant that makes an agent more likely to be correct in both directions. This costs zero of the tools/list payload budget (57475 of 57500 tokens across 129 tools, 25 tokens of headroom, unchanged by this change) because resources are read on demand via resources/read, not serialized into tools/list. Also corrected the two now-false bullets in the customer-onboarding MCP skill, which documented a refusal message the code can no longer produce and told agents to always use 0% for a validated EU business.
[2026-07-26] The Nyckeltal save (app/(dashboard)/kpi/page.tsx) had an empty catch commented "Silently fail: user can retry" over both the !res.ok throw and every thrown fetch, and components/kpi/KPISettingsDialog.tsx called onSave without awaiting and closed immediately: together, a failed save was pixel-identical to a successful one (dialog gone, grid still rendering the draft) until the next page load read the untouched extension_data row back and reverted the layout. The comment was wrong on its own terms: "the user can retry" presumes the user knows there is something to retry. Five non-obvious calls. (1) The decision moved into components/kpi/save-preferences.ts rather than reusing lib/browser/post-action.ts, which is a bodyless POST that deliberately does not parse the success body, whereas this is a PUT carrying the preferences whose echoed row is what the page renders next; the failure union is still ActionFailure, so the page describes a failed save through the same failureDescription() as a failed download. Colocating the module next to the component (the inbox-address-copy.ts precedent) keeps it inside vitest's include glob, which is how a client-side decision gets tested at all in a repo with no component tests. (2) onSave now returns Promise<boolean> and the dialog closes only on true, so the draft survives a failed save and the parent's saving state finally has a render window; Esc and click-outside are blocked while saving, bounded by the request's own 15s deadline. (3) One destructive toast, never two (TOAST_LIMIT is 1), and a toast rather than an inline AttnLine: the toast viewport is z-[100] over the dialog's z-50, and CreatePeriodDialog already reports a failed create exactly this way while keeping its dialog open. AttnLine in this codebase carries success-path advisories, not failures. (4) A 2xx whose body carries no usable row returns ok with the payload the caller sent, not a failure: the row was written, and claiming "not saved" for a save that landed is the same lie as the silent catch pointing the other way. It also closes a real crash path, since the old code assigned the destructured undefined straight into page state that both the grid and the dialog read .visibleKpis off. (5) The busy state covers the save only; the follow-up report refetch is deliberately not awaited, because it is an unbounded fetch and holding saving across it would lock the dialog on a request that is not the save. NOT fixed, reported instead: the preferences GET at the top of the same page still falls back to defaults silently, so a failed read followed by an open-and-save overwrites the stored layout with defaults, which is the same symptom through a different door and needs a UI decision (block the Anpassa button, or show the read failure) rather than another toast.
[2026-07-26] The ten push-notifications defects found by tests/schema/no-phantom-columns.test.ts were fixed as CODE bugs, not by adding company_id to notification_settings and push_subscriptions: both tables are deliberately user-scoped, and the guard's own root-cause note ("both tables predate multi-tenancy and were left out of the company_id backfill") is wrong. 20260330130000_multi_tenant_company_refactor.sql enumerates 40 tables by hand for its ADD COLUMN, backfill and NOT NULL passes, and that list includes notification_log, the third table created by the very same migration (20240101000008) as the other two: an author who had forgotten push notifications would have dropped notification_log too. 20260415000000_schema_sync.sql then files both under an explicit "User-scoped tables (no company_id, use auth.uid())" heading and gives them DELETE policies on auth.uid() = user_id. The domain agrees: a push endpoint is issued per browser profile per origin (hence `endpoint text unique`, which is also why the only legal onConflict target is `endpoint` alone and the shipped `user_id,endpoint` raised 42P10 on every subscribe call), one human has one set of quiet hours while belonging to several companies, and a company-scoped layer already exists separately as the extension's own extension_data blob behind ctx.settings. Company-scoping would have required one subscription row per (user, company) for the same physical device, which the endpoint unique constraint forbids. So every company_id reference in the extension was the bug, including the DELETE in api-routes.ts that passed a user id into company_id and therefore unsubscribed nothing. The single migration written (20260726174500, NOT APPLIED) adds only notification_settings.missing_underlag_enabled, the sixth per-event toggle whose five siblings 20240101000035 already created and which the code has read and written since the missing_underlag notification type shipped in 20260712090000. Because PostgREST rejects the whole select on one unknown column, that one missing column disabled all six toggles and saveSettings alike: every notification honoured defaults and the settings PUT silently wrote nothing while returning the merged object as if saved. No pg test ships with it, deliberately: adding a column touches no trigger, RPC, RLS policy or DEFERRABLE constraint, and the migration replay in the schema guard already proves both that the column exists and that all ten sites now resolve, which an unrunnable pg test in this session could not. Fixed unasked because it is the same find/replace in the same file: wasNotificationSent() filtered notification_log on company_id while logNotification() writes only user_id, so the duplicate check could never match a row it had itself written and every cron pass re-sent the same notification; that column does exist (nullable, from the refactor's list), so the phantom-column guard was structurally unable to see it. NOT fixed, reported instead: app/api/extensions/push-notifications/cron/route.ts is core code importing from @/extensions/, so this extension's query surface compiles into the core build whether or not the extension is enabled, which is why "the extension is off" was never the containment it looked like.
[2026-07-26] The Stripe settings panel (extensions/general/stripe/components/StripeSettingsPanel.tsx) had try/finally with no catch on "Synka nu" and on Koppla från, so a rejected fetch skipped every toast line and the spinner just started and stopped. Four non-obvious calls. (1) The classification moved into extensions/general/stripe/lib/settings-actions.ts rather than reusing lib/browser/post-action.ts: three of the four calls need a request body or a non-POST method (transaction-sync requires {enabled} or 400s, disconnect is a DELETE carrying connection_id), and the sync arm needs the parsed success body for its counts, all three of which postAction deliberately does not do. The failure union is still ActionFailure, so the panel describes a failed click through the same failureDescription() as the salary payment panels. (2) The sync deadline is STRIPE_SYNC_TIMEOUT_MS = 310s, not the 15s used for the writes, because a first sync backfills 90 days and up to 10000 balance transactions; aborting earlier would report failure for a run that keeps going server-side and advances last_balance_txn_synced_at, so the retry would find nothing. The only honest bound is the route's own maxDuration = 300 plus margin. (3) A non-2xx keeps the route's own Swedish sentence in preference to getErrorMessage's status map: "Inget anslutet Stripe-konto." carries none of the heuristic's trigger words, so routing it through getErrorMessage alone would have downgraded it to "Resursen kunde inte hittas." while fixing a bug about teaching the user nothing. error_en is honoured first for an English UI because capabilityBlockedResponse emits that top-level pair and getErrorMessage only reads message_en inside a structured envelope. (4) Two failures were being delivered inside a 200 and are now reported as failures: a connection Stripe revoked upstream comes back as {revoked: true} with zero counts and was shown as a finished sync that "returned no transactions, check that the right account is connected", and a success body that never parsed became fetched=0 and the same sentence. Also fixed outside the cited handlers: loadStatus swallowed its error into configured=false, which rendered "Stripe-integrationen är inte konfigurerad på den här installationen. Kontakta administratören." on a correctly configured installation; it now has its own load-failed state with a retry.
[2026-07-26] formatCurrency's SEK default (lib/utils.ts) got a RATCHET GUARD, not a signature change: scripts/checks/format-currency-sek-label.mjs, wired as check 6 of npm run check:guards, hard-failing at 0 with no baseline entry. Making the second argument required was rejected outright: .claude/rules/i18n.md fixes formatCurrency(amount) as SEK with sv-SE conventions in BOTH locales because that is a Swedish accounting convention rather than a UI string, and 171 of the 385 call sites are single-argument on values that genuinely ARE kronor (ledger columns, KPI aggregates, salary, tax). The guard therefore judges only the one shape the default cannot defend: a single-argument call whose value is read off an owner the SAME file also reads .currency from (the file demonstrably knows the unit and dropped it), plus any single-argument call on amount_in_currency, which is the foreign figure by definition. Owner paths are matched in full (invoice.currency does not license a verdict on line.amount), *_sek twins and the ledger columns debit_amount/credit_amount are excluded by rule because journal entry line amounts are ALWAYS SEK and line.currency labels the DOCUMENT (structural root 4.1, lib/bookkeeping/ledger-line-amount.ts), and multiplicative expressions are excluded because amount * rate is a conversion INTO kronor. Number()/Math.abs()/unary minus wrappers and both branches of ??/||/?:/+/- are followed, so the total_sek ?? total fallback is caught. Deliberately NOT judged: bare locals like formatCurrency(totalDebit), which would need whole-program dataflow; guessing there is how a guard earns its way onto an ignore list. Re-derived the true current count before baselining: 171 single-argument calls, 96 property-rooted, 5 on a currency-bearing owner, and all 5 format a *_sek twin under a currency !== SEK guard, so the count is 0 and the sweep's latent-not-active reading holds for the call sites. The sweep is nonetheless incomplete on the hazard: lib/transactions/booking-duplicate-detection.ts:291 returns the sibling candidate amount in the TARGET transaction currency while the ledger-voucher branch at :488-496 documents the opposite invariant (always the leg's SEK figure, so the UI never prints a foreign number with kr after it), and components/transactions/DuplicateBookingDialog.tsx:163 prints it single-argument, so a 1 000 EUR duplicate warning reads 1 000,00 kr. No guard of this shape can see that, because the candidate record carries no currency at all: the unit is erased at the producer, one module away from the call site. Reported, not fixed: the fix is a producer change with a decision about the unconvertible case.
[2026-07-26] "Har bolaget anställda" for the agent onboarding review card and the composer now resolves through lib/agent/composer/employee-facts.ts instead of company_settings.employee_count / company_settings.has_employees, neither of which is a column on that table (employee_count is on agi_declarations per 20260414120000; has_employees appears in no migration at all), so PostgREST answered 42703 and rejected BOTH selects whole: the composer's loadCompanySettings returned null for the entire row, silently discarding city, moms_period, fiscal_year_start_month, f_skatt, vat_registered, pays_salaries and accounting_method as well, and the onboarding page lost is_sandbox and the address fields on top. Four non-obvious calls. (1) The replacement ranks a live count of active `employees` rows ABOVE the TIC employeeRange, contrary to the "TIC wins because it is authoritative Bolagsverket data" precedence that page.tsx states for the other fields: that comment is about TIC versus user-entered settings, and an employee register is neither. The count is what a salary run actually enumerates and is current by construction, where the snapshot is only as fresh as the last TIC fetch. In practice the ordering rarely bites at onboarding (a new company has zero rows and falls through to TIC anyway); it matters when the composer re-runs later. (2) A ZERO count is never read as "nej". Every company arriving at agent onboarding has zero employee rows because nobody has opened the payroll module yet, so treating zero as the negative would answer a question nobody was asked. (3) pays_salaries === false is likewise NOT evidence: the column is NOT NULL DEFAULT false (20260401000000), no onboarding step sets it, and its only writer is the tax settings form, so false is indistinguishable from "never answered". Only employer_registered, which is deliberately NULLABLE so that "never attested" is representable (DECISIONS.md 2026-07-17), settles the question in the negative direction. Reading the default as "nej" would have suppressed the composer's "Har bolaget anställda?" verification question for every company that never opened /settings/tax. (4) Being a registered employer and having employees are not the same predicate, since a registered employer still files nil AGI months with nobody on payroll, so the resolver only ever reports a NUMBER when actual employee rows back it and otherwise reports the employer fact as a plain ja/nej. The employees-table derivation is not new: extensions/general/mcp-server/server.ts:2464 already computes its has_employees as count(employees where is_active) > 0, and this aligns the composer with the pattern that already ships. Behaviour is covered by lib/agent/composer/__tests__/employee-facts.test.ts (4 of its cases fail against the old derivation) and the column names by tests/schema/no-phantom-columns.test.ts, whose baseline loses the four company_settings.employee_count / has_employees entries.
[2026-07-26] The deadlines page's overdue-invoice attn line (app/(dashboard)/deadlines/page.tsx) filtered invoices on status IN ('sent', 'unpaid'); 'unpaid' is not in invoices_status_check, whose live value set is draft / sent / paid / partially_paid / overdue / cancelled / credited (20240101000001 plus 20260323120001, the only two migrations that touch that constraint). The filter therefore read as ('sent') alone and dropped exactly the invoices the reminder run had already flipped to 'overdue' (lib/invoices/reminder-processor.ts:393), i.e. the ones most certainly overdue, so the count and the SEK total both under-reported with no error anywhere. Fixed to ('sent', 'overdue'). partially_paid was deliberately NOT added, though a part-paid invoice past its due date is genuinely overdue for its remainder: this query sums total_sek / total, not remaining_amount, so including it would swap an under-count for an over-count by reporting money already received as still owed, and the line's own action link goes to /invoices?status=unpaid whose tab filter is exactly ['sent','overdue'] (app/(dashboard)/invoices/page.tsx:171), so the sentence would stop describing the list it leads to. Counting the open remainder is a real reporting question (it would need remaining_amount, the partially_paid status on the invoices tab, and the same call in lib/reports/ar-ledger.ts:88, which also omits partially_paid today) and is left to the owner of that surface. The credit-note and proforma / delivery_note rows that the same query counts but the destination tab excludes are noted for the same owner and not touched here. Also in the same file: all five sibling mutation handlers did throw new Error(result.error || '...') and then getErrorMessage(error), which stringifies the canonical envelope { error: { code, message } } into "[object Object]" and falls through to the generic "Något gick fel", discarding the route's own Swedish reason, its Zod field list and its 403 read-only refusal. They now share describeFailure(response), which hands the parsed body plus statusCode to the mapper, the shape handleUndoComplete already used. Create and edit still rethrow so a failed submit keeps the form open, but they toast first and the thrown message is a developer signal that is never displayed, so the catch cannot re-toast a generic sentence over the specific one (TOAST_LIMIT is 1). No page-level test exists or was invented: this repo has no component tests, and describeFailure cannot be exported from a Next.js page file; the behaviour it depends on is pinned in lib/errors/__tests__/get-error-message.test.ts and the status literal by tests/schema/no-phantom-columns.test.ts, whose baseline loses the invoices.status = 'unpaid' entry.
[2026-07-26] company_settings.invoice_default_notes was fixed by RESTORING the column via a new migration (20260726181500, ADD COLUMN IF NOT EXISTS, NOT applied to any database in-session), not by dropping it from the InvoiceEditor select, and the guard baseline entry was then removed. The guard's baseline note said "never existed", but git shows the inverse of the orphan case: 20260407120100 added the column, landed on main in #188 (so branching applied it to prod), and #244's migration consolidation then deleted the file without carrying the column into 20260415000000_schema_sync.sql, leaving the REPO behind prod. The feature is whole and live on hosted prod (settings textarea, save payload, UpdateSettingsSchema, CompanySettings type, makeCompanySettings fixture all reference the column at HEAD), so dropping it from the select would have killed a working feature to satisfy the guard. Only databases built from the repo's migrations (self-hosted Docker, pg-real CI, migration-built preview branches) lack the column, and on those PostgREST's 42703 rejects the WHOLE 11-column select whose error the editor discards, so ten real settings silently read as null: hasBankDetails forced false (bank-setup dialog detour before every invoice review plus a standing "bank details missing" bar), accounting_method stuck on 'accrual' (kontantmetoden companies got the accrual review text and accrual periodisering controls; books stayed correct since the server reads settings itself), ore_rounding stuck true (rounding written onto every new invoice against the company setting; display-only but persisted), vat_registered stuck true (non-momsregistrerade companies got the Moms column, 25% line defaults and VAT in totals), dimensions_enabled and invoice_payment_links_enabled stuck off (affordances hidden for companies that opted in), logo_url null (first-invoice logo prompt for companies that have a logo; sent PDFs unaffected, pdf-template reads live), and invoice_default_notes plus default_our_reference never prefilled. IF NOT EXISTS is load-bearing: prod already has the column and a bare ADD COLUMN would 42701-abort the migration batch behind it. No pg test, deliberately: a plain column addition touches no trigger, RPC, RLS policy or DEFERRABLE constraint, and the schema guard's migration replay already proves the select resolves.
[2026-07-26] The push-notifications cron route (app/api/extensions/push-notifications/cron/route.ts) is NOT a core-imports-extensions violation: .github/workflows/core-build.yml's "Check no core imports from extensions" step exempts app/api/extensions/ by design, because Vercel crons and OAuth callbacks need physical file routes the ext/[...path] dispatcher cannot provide. The real defect was that such routes compile into every build while extensions.config.json only edits the runtime registry, so the DISABLED push-notifications extension still exposed a live cron surface. Fixed with a registry gate (loadExtensions() + extensionRegistry.get('push-notifications'), 503 EXTENSION_DISABLED when absent) rather than moving the cron behind the dispatcher, which would break the concrete paths vercel.json crons and generated Docker crontabs address. The pattern is now enforced by scripts/checks/extension-route-guards.mjs in check:guards: cross-extension imports from these routes hard-fail at 0, and the 10 pre-existing ungated routes (cloud-backup, enable-banking, skatteverket, stripe) are a shrink-only allowlist; new physical extension routes must ship the gate. The push cron itself remains unscheduled: it is absent from vercel.json, so nothing invokes it in prod; adding it there is Emil's call.
[2026-07-26] API-gap report triage: five agent-facing gaps were implemented as MCP/v1 surfaces (update_invoice staged tool + v1 items PATCH, widened company-settings tools, invoice-deliveries read tool + service RPC, recurring-schedule tools), but a company_id override on cookie-session routes was REJECTED: the reported blocker ("/api/settings PATCH ignores X-Company-Id") is a wrong-door problem, since both real API surfaces already carry per-request company routing (v1 via the URL path with membership re-check in with-api-v1.ts, MCP via company-routing.ts), and adding an override to 100+ withRouteContext handlers would duplicate the company-authz check per handler instead of keeping it in one wrapper. The Resend deliverability item (report #5) was deliberately NOT built: it is verification work (RESEND_DELIVERY_WEBHOOK_SECRET in Vercel, webhook registration in the Resend dashboard, SPF/DKIM/DMARC alignment for M365) requiring dashboard access only Emil has, and whether "2624" is an invoice number or a mail count is still unconfirmed with the reporter.
[2026-07-26] gnubok_update_company_settings widened to email/phone/website/invoice_email_texts but invoice_email_cc_addresses and invoice_email_bcc_addresses were EXCLUDED: the enforce_invoice_email_recipient_settings_admin trigger (20260723003000) requires owner/admin, so a member-role API key would fail at commit with a raw Postgres exception instead of a clean envelope; exposing them needs tool-level role gating first. Accounting-behaviour and legal-registration flags (defer_invoice_booking, agent_auto_commit_*, default_voucher_series, dimensions_enabled, ai_flow_enabled, vat_registered, moms_period, f_skatt, entity_type, org_number, fiscal_year_start_month) stay out of agent reach by design, and the exclusions are pinned by .strict() schema tests. invoice_email_texts placeholders are validated against INVOICE_EMAIL_PLACEHOLDER_KEYS so agents cannot invent placeholders that render literally in customer mail.
[2026-07-26] gnubok_get_invoice_deliveries required a NEW service-role RPC (list_invoice_delivery_summaries_for_service, migration 20260727100000) rather than reusing list_invoice_delivery_summaries: the existing function rejects when auth.uid() IS NULL and when p_company_id differs from current_active_company_id(), and the MCP server runs on createServiceClientNoCookies() with cross-company routing, so it can never satisfy either check (same trap that broke undo_sie_import). The sibling takes an explicit p_user_id, requires service_role, re-checks company_members, and returns the identical masked shape; the tool never selects invoice_deliveries directly because the masking is the point.
[2026-07-26] Recurring-schedule MCP tools shipped WITHOUT a run-now tool and WITHOUT v1 REST routes, both deliberately deferred: run-now creates and may immediately send a real customer invoice, which should not arrive via a side door; auto_send keeps its schema default of false and must be set explicitly so it appears in the staged approval preview (an auto-sending schedule is recurring outbound customer email that never sees approval again). All four new MCP tools (update_invoice, get_invoice_deliveries, and the three recurring ones minus the read tools where applicable) are catalogVisibility 'search' to stay under the 57500-token payload-size bench ceiling, following the update_customer/update_company_settings precedent, rather than bumping the ceiling.
[2026-07-26] The pending_operations operation-type CHECK gained update_invoice (20260727090000/090001) and create/update_recurring_schedule (20260727110000/110001) in two paired add+validate migrations; because the constraint is re-created wholesale from a full value list, the second pair explicitly carries update_invoice forward, otherwise it would silently revoke the first pair's value at apply time.
[2026-07-26] Declined a new check:guards ratchet for the "new Error(body.error)" toast-error class: whether a given site is broken depends on the backing route's error shape (canonical envelope object vs hand-rolled Swedish string), which is a cross-file dynamic property (fetch URL -> route module -> which code path answered) that a static AST guard cannot resolve, especially through the extension catch-all dispatcher; a syntactic ratchet would flag the fine string-route sites the audit explicitly excludes and its count would not track user-visible breakage. Broadened isSwedishUserMessage instead (kan inte/hittades/redan/låst) so real Swedish route sentences survive the correct getErrorMessage(body, { statusCode }) treatment verbatim.
[2026-07-26] Fixed cross-user attachment access (Odin Aero support case) at the call sites with service-role clients after company-scoped authorization, instead of rewriting the documents bucket storage policy to be company-scoped like sie-files got in 20260416120000: the documents path layout (documents/{userId}/...) carries no company_id, so a company-scoped policy needs a per-object join against document_attachments on every storage op, and the authorize-then-service-client pattern was already the established model (inline proxy route, v1 download route, MCP tools). Sweep found and fixed the same defect in the metadata/sign route, the integrity probe, verifyIntegrity, invoice-inbox retry-extraction, and cloud-backup archive generation. Known leftover, deliberately unfixed: deleteDocument and the upload-failure cleanups call storage remove() with a user-bound client, which silently no-ops (no DELETE policy, WORM), orphaning storage objects; harmless for compliance, needs a separate decision on whether files should ever be hard-deleted.
[2026-07-26] reverseEntry now blocks only source_type='storno', no longer 'correction': BFL 5 kap 5 § requires traceability, not immunity for rättelseverifikat, and blocking corrections left users with no sanctioned exit when a rättelse duplicated an affärshändelse booked elsewhere (support case 2026-07-26); it also broke uncategorize-after-rättelse since transactions are relinked to the correction entry. Storno-of-storno stays blocked (chain ambiguity).
[2026-07-26] Supplier-invoice DELETE allows 'approved' (not only 'registered'/'overdue'): the overdue cron flips BOTH registered and approved invoices to 'overdue', so excluding 'approved' would make deletability depend on whether the cron ran yet; the orphan-safety checks (no registration verifikat, no payments, no accrual schedule) are the real guard, and an attested but unbooked, unpaid invoice deletes nothing from the books.
[2026-07-26] Hydrating approval cards on resume re-links pending_operations via agent_metadata->>conversation_id rather than persisting the staged_operation stream events: the metadata stamp already exists for the BFL trail, so no migration and no second source of truth for a proposal. Cards render from the operation row, which is also what commit/reject act on, so a hydrated card and a live one cannot disagree.
[2026-07-26] Regenerate now rejects the discarded turn's staged operations through the normal reject endpoint instead of leaving them pending: the alternative (silently deleting) would break the append-only audit trail, and leaving them (previous behaviour) produced two live proposals for one booking, each with its own 30-day expiry.
[2026-07-27] replace_sie_import and undo_sie_import honor p_user_id only when auth.role() = 'service_role'; every other caller is pinned to auth.uid(): the prior COALESCE(p_user_id, auth.uid()) with EXECUTE granted to authenticated let any member pass an owner's UUID over PostgREST and impersonate them into the owner/admin gate that disarms the immutability triggers (pre-publish review finding; same guard shape as list_invoice_delivery_summaries_for_service).
[2026-07-27] bulk_book_transactions refuses homogeneous non-SEK batches (BULK_BOOK_FOREIGN_CURRENCY) instead of writing foreign magnitudes into the always-SEK debit/credit columns: a 100+200 EUR batch previously produced a verifikat whose 300 was read as kronor by balansrakning, moms and SIE export; foreign transactions book individually through the FX-aware flow. The pg test that pinned the old outcome as correct was rewritten to assert the refusal.
[2026-07-27] Same-currency foreign settlements (EUR invoice paid by EUR bank tx) now clear 1510 at the invoice booking rate and book the realized diff to 3960/7960, mirroring the cross-currency path; rate-less foreign invoices refuse with MATCH_INVOICE_BOOKING_RATE_MISSING. The old arSek = bankSek shortcut stranded the kursdiff on 1510 and broke reskontra-to-GL tie-out; a branch test had pinned that behavior and was rewritten.
[2026-07-27] Balansdagen revaluation of receivables includes status partially_paid and revalues outstanding (total - paid_amount) rather than face value, mirroring the payables side per ARL 4 kap 13 §; the year-end readiness check follows the same status list.
[2026-07-27] deleteDocument and the upload-failure cleanups now remove storage objects via the cookieless service client after company-scoped authorization, revisiting the 2026-07-26 "deliberately unfixed" entry: the phase-A company-scoped SELECT policy changed the premise, since an orphaned object is now readable by every company member while its DB row and audit context are gone, so silent retention became a leak rather than a harmless orphan. WORM stays: no DELETE RLS policy exists, and documents linked to journal entries still cannot be deleted.
[2026-07-27] The credit-note cap trigger treats a cross-company credited_invoice_id as a rejection rather than not-found-so-uncapped, and no longer prints the original invoice's total or currency in exception text: with a SECURITY DEFINER lookup and no company match those messages were a cross-tenant amount oracle.
[2026-07-27] KPI preferences stay company-scoped (one row per company, last writer wins): the read path filters on (company_id, extension_id, key) with no user filter, so the broken upsert arbiter was aligned to the real unique constraint instead of introducing per-user semantics nothing reads.
[2026-07-27] Booking-template updates scope-check company templates against the active company and team templates against the active company's team_id, instead of a blind company_id filter which would have broken team-shared template editing (team templates carry company_id NULL).
[2026-07-27] Deferred from the pre-publish review, each needs its own decision: arcim OAuth state is not yet bound to the initiating browser session (TTL cut from 60 to 10 minutes as mitigation); customer invoices can still be created rate-less while supplier invoices refuse (the engine now refuses at booking instead); the v1 journal-entries dry-run commit still burns voucher numbers (pre-existing audit P0, route untouched by this branch).
[2026-07-27] Restored 20260726140000 to its preview-recorded content (60b14193) and restated the NULL-safe tenant guard as 20260727130000: the PR #1215 preview branch recorded that version before review hardening edited it, an applied version never re-runs, and a bare rename would have orphaned the preview's version row; CREATE OR REPLACE makes both replay orders converge on identical prosrc.
+29 -41
View File
@@ -17,6 +17,10 @@ import { isBankIdEnabled } from '@/lib/auth/bankid'
import { getBranding } from '@/lib/branding/service'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import {
consumeInviteCookie,
INVITE_PROBLEM_MESSAGE_KEYS,
} from '@/lib/auth/consume-invite-cookie'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
const branding = getBranding()
@@ -59,8 +63,27 @@ function LoginPageContent() {
const bankIdEnabled = isBankIdEnabled()
const tAuth = useTranslations('auth')
const tCommon = useTranslations('common')
const tInvite = useTranslations('invite')
const errorLocale = useLocale() as ErrorLocale
// Accept a pending invite, if any, and report a non-definitive failure.
// Returns true when the caller should land the user in the app directly.
// The invite cookie survives anything that is not a settled outcome, so
// /onboarding and /select-company can retry acceptance server-side.
const acceptPendingInvite = async (): Promise<boolean> => {
const invite = await consumeInviteCookie()
if (invite.accepted) return true
if (invite.problem) {
const keys = INVITE_PROBLEM_MESSAGE_KEYS[invite.problem]
toast({
title: tInvite(keys.title),
description: tInvite(keys.body),
variant: 'destructive',
})
}
return false
}
// Reset cooldown timer
useEffect(() => {
if (!resetCooldownUntil) return
@@ -114,26 +137,9 @@ function LoginPageContent() {
}
// Check for pending invite token
const bankIdCookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
const bankIdInviteToken = bankIdCookieMatch?.[1]
if (bankIdInviteToken) {
try {
const res = await fetch('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: bankIdInviteToken }),
})
if (res.ok) {
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
window.location.href = '/'
return
}
} catch (err) {
console.error('[login] invite acceptance failed:', err)
}
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
if (await acceptPendingInvite()) {
window.location.href = '/'
return
}
if (nextPath !== '/') {
@@ -196,27 +202,9 @@ function LoginPageContent() {
}
// Check for pending invite token
const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
const inviteToken = cookieMatch?.[1]
if (inviteToken) {
try {
const res = await fetch('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inviteToken }),
})
if (res.ok) {
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
window.location.href = '/'
return
}
} catch (err) {
console.error('[login] invite acceptance failed:', err)
}
// Clear cookie even on failure to avoid retrying stale tokens
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
if (await acceptPendingInvite()) {
window.location.href = '/'
return
}
if (nextPath !== '/') {
+26 -20
View File
@@ -11,6 +11,10 @@ import { useToast } from '@/components/ui/use-toast'
import { Loader2, ShieldCheck, LogOut } from 'lucide-react'
import { SupportLink } from '@/components/ui/support-link'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import {
consumeInviteCookie,
INVITE_PROBLEM_MESSAGE_KEYS,
} from '@/lib/auth/consume-invite-cookie'
export default function MfaVerifyPage() {
return (
@@ -23,6 +27,7 @@ export default function MfaVerifyPage() {
function MfaVerifyContent() {
const t = useTranslations('mfa')
const tCommon = useTranslations('common')
const tInvite = useTranslations('invite')
const [code, setCode] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [factorId, setFactorId] = useState<string | null>(null)
@@ -68,6 +73,24 @@ function MfaVerifyContent() {
return () => clearInterval(interval)
}, [lockoutUntil])
// Accept a pending invite, if any, and report a non-definitive failure.
// Returns true when the caller should land the user in the app directly.
// The invite cookie survives anything that is not a settled outcome, so
// /onboarding and /select-company can retry acceptance server-side.
const acceptPendingInvite = async (): Promise<boolean> => {
const invite = await consumeInviteCookie()
if (invite.accepted) return true
if (invite.problem) {
const keys = INVITE_PROBLEM_MESSAGE_KEYS[invite.problem]
toast({
title: tInvite(keys.title),
description: tInvite(keys.body),
variant: 'destructive',
})
}
return false
}
const handleVerify = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!factorId || code.length !== 6) return
@@ -116,26 +139,9 @@ function MfaVerifyContent() {
return
}
const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
const inviteToken = cookieMatch?.[1]
if (inviteToken) {
try {
const res = await fetch('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inviteToken }),
})
if (res.ok) {
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
window.location.href = '/'
return
}
} catch (err) {
console.error('[mfa/verify] invite acceptance failed:', err)
}
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
if (await acceptPendingInvite()) {
window.location.href = '/'
return
}
if (returnTo.startsWith('/api/')) {
@@ -0,0 +1,181 @@
import { describe, it, expect } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
/**
* Two findings on the signup page, both about an address travelling (or not)
* with the user.
*
* 1. The BankID signup form left its email field blank and editable while the
* password form on the very same page pre-filled and locked it from the
* invitation. An invitee who chose BankID therefore typed their private
* address, POST /api/team/accept answered 403 on its email equality check,
* and they landed on /select-company with no membership. The token now
* survives that 403 (lib/auth/consume-invite-cookie.ts retains it on
* `wrong_email`, and /select-company re-runs acceptance server-side), so the
* dead end is recoverable; the signup still should not walk into it.
* 2. The duplicate-email screen linked to `/login?email=...`, which
* app/(auth)/login/page.tsx never reads. The address rode along in the URL,
* the browser history, the Referer header and every proxy access log, and
* arrived nowhere.
*
* The page is a client component and this repo deliberately has no component
* test harness (CLAUDE.md: scope is lib/ + app/api/), so these assert on the
* page source, the same pattern used by
* app/(auth)/reset-password/__tests__/invite-handoff.test.ts and
* app/invite/[token]/__tests__/invite-cookie.test.ts.
*/
const SRC = fs.readFileSync(path.resolve(__dirname, '../page.tsx'), 'utf8')
/** The page source with comment lines dropped, so prose about a pattern is never mistaken for the pattern. */
const CODE = SRC.split('\n')
.filter((line) => !/^\s*(\*|\/\/|\/\*)/.test(line))
.join('\n')
/** The props of one `<Input>`, located by its id. */
function inputProps(id: string): string {
const anchor = SRC.indexOf(`id="${id}"`)
expect(anchor, `no <Input id="${id}"> on the page`).toBeGreaterThan(-1)
return SRC.slice(SRC.lastIndexOf('<Input', anchor), SRC.indexOf('/>', anchor) + 2)
}
/** A whole labelled field group (Label + Input + hint), located by the input's id. */
function fieldGroup(id: string): string {
const anchor = SRC.indexOf(`id="${id}"`)
expect(anchor, `no <Input id="${id}"> on the page`).toBeGreaterThan(-1)
return SRC.slice(
SRC.lastIndexOf('<div className="space-y-2">', anchor),
SRC.indexOf('</div>', anchor) + '</div>'.length,
)
}
/** The body of the effect that resolves `?invite=` into the invited address. */
function inviteEffect(): string {
const start = SRC.indexOf("searchParams.get('invite')")
expect(start).toBeGreaterThan(-1)
return SRC.slice(start, SRC.indexOf('}, [searchParams])', start))
}
/**
* Just the duplicate-email screen. Scoped, because the page footer carries its
* own bare `/login` link and would satisfy an unscoped assertion.
*/
function duplicateScreen(): string {
const start = CODE.indexOf('if (duplicateEmail) {')
expect(start).toBeGreaterThan(-1)
const end = CODE.indexOf('if (isRegistered) {', start)
expect(end).toBeGreaterThan(start)
return CODE.slice(start, end)
}
describe('an invited signup', () => {
it('pre-fills the BankID email field from the invitation, not just the password one', () => {
const effect = inviteEffect()
expect(effect).toContain('setEmail(data.data.email)')
expect(effect).toContain('setBankIdEmail(data.data.email)')
})
it('locks the BankID email field exactly the way the password field is locked', () => {
// Same invitation, same page: one behaviour. The divergence was the bug.
for (const id of ['email', 'bankid_email']) {
const props = inputProps(id)
expect(props, id).toContain('disabled={isLoading || !!inviteEmail}')
expect(props, id).toContain('readOnly={!!inviteEmail}')
}
})
it('tells the invitee where the locked address came from', () => {
expect(fieldGroup('bankid_email')).toContain("t('invite_email_hint')")
expect(fieldGroup('email')).toContain("t('invite_email_hint')")
})
it('still submits the invited address although a disabled input is not in the FormData', () => {
// `disabled` (unlike `readOnly`) excludes a field from FormData, so the
// locked value only reaches the request through the state fallback. Both
// handlers rely on it; dropping either fallback would POST an empty email.
expect(CODE).toContain("(formData.get('bankid_email') as string) || bankIdEmail")
expect(CODE).toContain("(formData.get('email') as string) || email")
})
})
describe('a signup that did not come from an invitation', () => {
// The overwhelming majority. Nothing may lock or pre-fill for these users.
it('starts with no invited address', () => {
expect(CODE).toContain('const [inviteEmail, setInviteEmail] = useState<string | null>(null)')
expect(CODE).toContain("const [bankIdEmail, setBankIdEmail] = useState('')")
})
it('never resolves an invitation when the link carries no token', () => {
expect(inviteEffect()).toContain('if (!inviteToken) return')
})
it('leaves both email fields editable, gating every lock on the invitation', () => {
for (const id of ['email', 'bankid_email']) {
const props = inputProps(id)
// No unconditional lock: every disabled/readOnly here names inviteEmail.
const locks = props.match(/(disabled|readOnly)=\{[^}]*\}/g) ?? []
expect(locks.length, id).toBeGreaterThan(0)
for (const lock of locks) {
expect(lock, `${id}: ${lock} is not conditional on the invitation`).toContain('inviteEmail')
}
}
})
it('keeps the BankID field its own hint when there is no invitation', () => {
expect(fieldGroup('bankid_email')).toContain("t('bankid_email_hint')")
})
})
describe('the duplicate-email screen', () => {
it('sends the user to plain /login', () => {
expect(duplicateScreen()).toContain('<Link href="/login">')
})
it('does not put the address in the URL of a page that never reads it', () => {
expect(duplicateScreen()).not.toMatch(/\/login\?/)
expect(CODE).not.toContain('encodeURIComponent(duplicateEmail)')
})
it('still shows the address, so nothing is lost by dropping the parameter', () => {
expect(duplicateScreen()).toContain('{duplicateEmail}')
})
})
describe('the post-auth destination parameter', () => {
it('does not read `next`', () => {
// Nothing links to /register with one: bounceToAuth (lib/supabase/
// middleware.ts) targets /login and the two MFA pages, and
// app/invite/[token]/page.tsx sends `?invite=`. An unread parameter cannot
// redirect anyone.
expect(CODE).not.toContain("searchParams.get('next')")
})
it('would have to sanitize a destination through safeReturnTo if one is ever added', () => {
// The ratchet, not a restatement of today's behaviour: the moment someone
// reads a destination off this URL, the shared validator has to be the
// thing that vets it. Hand-rolled checks here emitted
// `Location: http://evil.com/`. Rejection of absolute and
// protocol-relative values is covered by
// lib/auth/__tests__/safe-return-to.test.ts.
const readsDestination = /searchParams\.get\('(next|returnTo|redirect|redirectTo)'\)/.test(CODE)
if (readsDestination) {
expect(CODE).toContain("from '@/lib/auth/safe-return-to'")
expect(CODE).toMatch(/safeReturnTo\(\s*searchParams\.get\('(next|returnTo|redirect|redirectTo)'\)/)
}
})
})
describe('the wording the locked field renders', () => {
const messages = (locale: 'sv' | 'en') =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../../messages/${locale}.json`), 'utf8'),
) as { register: Record<string, string> }
for (const locale of ['sv', 'en'] as const) {
it(`has both email hints in ${locale}.json`, () => {
const register = messages(locale).register
expect(register.invite_email_hint).toBeTruthy()
expect(register.bankid_email_hint).toBeTruthy()
})
}
})
+55 -24
View File
@@ -17,6 +17,10 @@ import { isBankIdEnabled } from '@/lib/auth/bankid'
import type { BankIdResult } from '@/components/auth/BankIdAuth'
import { getBranding } from '@/lib/branding/service'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
import {
consumeInviteCookie,
INVITE_PROBLEM_MESSAGE_KEYS,
} from '@/lib/auth/consume-invite-cookie'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
const branding = getBranding()
@@ -35,6 +39,17 @@ export default function RegisterPage() {
}
function RegisterPageContent() {
// `invite` is the only query parameter this page reads. It deliberately does
// NOT read `next`: nothing links here with one (bounceToAuth in
// lib/supabase/middleware.ts targets /login and the two MFA pages only, and
// app/invite/[token]/page.tsx sends `?invite=`), the already-signed-in case
// is handled in the middleware behind safeReturnTo, and neither signup path
// has a destination to spend it on: the password path leaves through the
// confirmation mail and /auth/callback, and the BankID path must land a
// brand-new account on '/' or /select-company rather than a deep link it has
// no membership for. If a destination is ever wanted here it MUST go through
// safeReturnTo (lib/auth/safe-return-to.ts); a hand-rolled check on this
// value is an open redirect.
const searchParams = useSearchParams()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -51,10 +66,35 @@ function RegisterPageContent() {
const supabase = createClient()
const bankIdEnabled = isBankIdEnabled()
const t = useTranslations('register')
const tInvite = useTranslations('invite')
const errorLocale = useLocale() as ErrorLocale
// Accept a pending invite, if any, and report a non-definitive failure.
// Returns true when the caller should land the user in the app directly.
// The invite cookie survives anything that is not a settled outcome, so
// /onboarding and /select-company can retry acceptance server-side.
const acceptPendingInvite = async (): Promise<boolean> => {
const invite = await consumeInviteCookie()
if (invite.accepted) return true
if (invite.problem) {
const keys = INVITE_PROBLEM_MESSAGE_KEYS[invite.problem]
toast({
title: tInvite(keys.title),
description: tInvite(keys.body),
variant: 'destructive',
})
}
return false
}
// When arriving from an invite link, fetch the invite info to pre-fill
// and lock the email field so the user registers with the correct address.
// BOTH signup forms are pre-filled: the BankID form used to be left blank
// and editable, so an invitee who signed up with BankID typed their private
// address, POST /api/team/accept answered 403 on the email equality check,
// and they landed on /select-company with no membership. The token now
// survives that 403 (lib/auth/consume-invite-cookie.ts) so it is recoverable
// rather than terminal, but the signup should not walk into it at all.
useEffect(() => {
const inviteToken = searchParams.get('invite')
if (!inviteToken) return
@@ -65,6 +105,7 @@ function RegisterPageContent() {
if (data?.data?.email) {
setInviteEmail(data.data.email)
setEmail(data.data.email)
setBankIdEmail(data.data.email)
}
})
.catch(() => {})
@@ -156,27 +197,9 @@ function RegisterPageContent() {
// invitee who registers with BankID lands on /select-company with no
// membership and gets funneled into creating a company instead of
// joining the one they were invited to.
const inviteCookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
const inviteToken = inviteCookieMatch?.[1]
if (inviteToken) {
try {
const res = await fetch('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inviteToken }),
})
if (res.ok) {
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
window.location.href = '/'
return
}
} catch (err) {
console.error('[register] invite acceptance failed:', err instanceof Error ? err.message : String(err))
}
// Clear cookie even on failure to avoid retrying stale tokens
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
if (await acceptPendingInvite()) {
window.location.href = '/'
return
}
router.push('/select-company')
@@ -326,8 +349,15 @@ function RegisterPageContent() {
</div>
<div className="space-y-2">
{/*
Plain /login, no `email` parameter: app/(auth)/login/page.tsx
reads only `error`, `flow` and `next`, so the address was
travelling in the URL (browser history, Referer, every proxy
access log) and arriving nowhere. The address is already on
screen above, so nothing is lost by dropping it.
*/}
<Button className="w-full" asChild>
<Link href={`/login?email=${encodeURIComponent(duplicateEmail)}`}>
<Link href="/login">
{t('sign_in')}
</Link>
</Button>
@@ -452,11 +482,12 @@ function RegisterPageContent() {
value={bankIdEmail}
onChange={(e) => setBankIdEmail(e.target.value)}
required
disabled={isLoading}
disabled={isLoading || !!inviteEmail}
readOnly={!!inviteEmail}
className="h-11"
/>
<p className="text-xs text-muted-foreground">
{t('bankid_email_hint')}
{inviteEmail ? t('invite_email_hint') : t('bankid_email_hint')}
</p>
</div>
<Button type="submit" className="w-full h-11" disabled={isLoading}>
@@ -0,0 +1,365 @@
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import {
INVITE_COOKIE_NAME,
INVITE_PROBLEM_MESSAGE_KEYS,
} from '@/lib/auth/consume-invite-cookie'
import {
handoffPendingInvite,
INVITE_ACCEPTED_DESTINATION,
INVITE_RETRY_DESTINATION,
type InviteHandoffDeps,
} from '../invite-handoff'
/**
* The finding: `/reset-password` was the only session-establishing path that
* never consumed the pre-auth invite cookie, and the server-side safety nets
* (`acceptPendingInviteByToken()` on `/onboarding` and `/select-company`) do
* not cover it, because `/onboarding` is only ever reached by a user with zero
* companies. A consultant who already had a company of their own could not
* recover the invitation at all.
*
* The flow lives in `../invite-handoff.ts` so it is reachable from the node
* test env: the page itself is a client component and this repo deliberately
* has no component harness (CLAUDE.md: scope is lib/ + app/api/). The wiring
* inside the page is asserted against its source, the same pattern used by
* app/invite/[token]/__tests__/invite-cookie.test.ts.
*/
const TOKEN = 'gnubok_inv_Zm9vYmFyLXRva2Vu'
/**
* Minimal document.cookie stand-in (node env has no DOM). Models the one
* behaviour the shared helper depends on: assigning `name=; max-age=0` removes
* the entry.
*/
function installCookieJar(initial: Record<string, string> = {}) {
const jar = new Map(Object.entries(initial))
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
get cookie() {
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ')
},
set cookie(raw: string) {
const [pair, ...attrs] = raw.split(';').map((s) => s.trim())
const eq = pair.indexOf('=')
const name = pair.slice(0, eq)
const value = pair.slice(eq + 1)
if (attrs.some((a) => /^max-age=0$/i.test(a)) || value === '') {
jar.delete(name)
} else {
jar.set(name, value)
}
},
},
})
return jar
}
let fetchMock: ReturnType<typeof vi.fn>
let reportProblem: Mock<InviteHandoffDeps['reportProblem']>
let getUser: Mock<InviteHandoffDeps['getUser']>
let getAssuranceLevel: Mock<InviteHandoffDeps['getAssuranceLevel']>
/** An ordinary hosted user: no BankID link, no TOTP step-up owed. */
function deps(overrides: Partial<InviteHandoffDeps> = {}): InviteHandoffDeps {
return {
getUser,
getAssuranceLevel,
reportProblem,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
vi.spyOn(console, 'error').mockImplementation(() => {})
reportProblem = vi.fn<InviteHandoffDeps['reportProblem']>()
getUser = vi
.fn<InviteHandoffDeps['getUser']>()
.mockResolvedValue({ app_metadata: {} })
getAssuranceLevel = vi
.fn<InviteHandoffDeps['getAssuranceLevel']>()
.mockResolvedValue({ currentLevel: 'aal1', nextLevel: 'aal1' })
})
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
Reflect.deleteProperty(globalThis, 'document')
vi.restoreAllMocks()
})
function respondWith(status: number) {
fetchMock.mockResolvedValue({ status, ok: status >= 200 && status < 300 })
}
describe('invite handoff after a password reset', () => {
// The core of the finding: the invitee resets their password and is a member
// when they land in the app.
it('joins the invited company and lands the user in the app', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(200)
const destination = await handoffPendingInvite(deps())
expect(fetchMock).toHaveBeenCalledWith('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: TOKEN }),
})
expect(destination).toBe(INVITE_ACCEPTED_DESTINATION)
expect(reportProblem).not.toHaveBeenCalled()
expect(jar.has(INVITE_COOKIE_NAME)).toBe(false)
})
/**
* The user shape that motivated the fix. Acceptance is inline and reads no
* membership state, so a user who already has a company of their own goes
* through the identical single POST: exactly one request, no company lookup
* that could branch them away, and never a bounce through `/onboarding`
* (which they would never be sent to).
*/
it('joins a user who already has a company of their own too', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(200)
const destination = await handoffPendingInvite(deps())
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(destination).toBe(INVITE_ACCEPTED_DESTINATION)
expect(destination).not.toBe('/onboarding')
expect(jar.has(INVITE_COOKIE_NAME)).toBe(false)
})
it('treats already-a-member (409) as joined', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(409)
const destination = await handoffPendingInvite(deps())
expect(destination).toBe(INVITE_ACCEPTED_DESTINATION)
expect(reportProblem).not.toHaveBeenCalled()
expect(jar.has(INVITE_COOKIE_NAME)).toBe(false)
})
it('retains the token on a transient failure, reports it, and asks for a server-side retry', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(500)
const destination = await handoffPendingInvite(deps())
expect(jar.get(INVITE_COOKIE_NAME)).toBe(TOKEN)
expect(reportProblem).toHaveBeenCalledWith('retryable')
// /select-company re-runs acceptPendingInviteByToken() server-side and is
// reachable for a user who already has a company; /onboarding is not.
expect(destination).toBe(INVITE_RETRY_DESTINATION)
expect(destination).not.toBe('/onboarding')
})
it('retains the token when the request never reaches the server', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
const destination = await handoffPendingInvite(deps())
expect(jar.get(INVITE_COOKIE_NAME)).toBe(TOKEN)
expect(reportProblem).toHaveBeenCalledWith('retryable')
expect(destination).toBe(INVITE_RETRY_DESTINATION)
})
it('retains the token on an email mismatch (403) without a futile retry', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(403)
const destination = await handoffPendingInvite(deps())
expect(jar.get(INVITE_COOKIE_NAME)).toBe(TOKEN)
expect(reportProblem).toHaveBeenCalledWith('wrong_email')
// The server-side email equality check would fail identically for this
// account, so there is nothing for /select-company to retry.
expect(destination).toBeNull()
})
it('clears a spent token (410) and reports it', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(410)
const destination = await handoffPendingInvite(deps())
expect(jar.has(INVITE_COOKIE_NAME)).toBe(false)
expect(reportProblem).toHaveBeenCalledWith('spent')
expect(destination).toBeNull()
})
it('does nothing at all when there is no invite cookie', async () => {
installCookieJar({ 'sb-access-token': 'x' })
const destination = await handoffPendingInvite(deps())
expect(fetchMock).not.toHaveBeenCalled()
expect(getUser).not.toHaveBeenCalled()
expect(reportProblem).not.toHaveBeenCalled()
expect(destination).toBeNull()
})
})
/**
* A recovery session is AAL1, and POST /api/team/accept is NOT in
* `apiPathSkipsMfaGate`, so the middleware answers a pending step-up with a
* bare 403. The shared classifier reads 403 as `wrong_email`, so attempting the
* acceptance there would tell a legitimate invitee their invitation belongs to
* someone else. Defer instead, on exactly the middleware's own condition.
*/
describe('a session that still owes an MFA step-up', () => {
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
getAssuranceLevel.mockResolvedValue({
currentLevel: 'aal1',
nextLevel: 'aal2',
})
})
it('defers without touching the token or claiming the wrong address', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(403)
const destination = await handoffPendingInvite(deps())
expect(fetchMock).not.toHaveBeenCalled()
expect(reportProblem).not.toHaveBeenCalled()
// The same predicate bounces the user to /mfa/verify, which consumes the
// surviving cookie once the second factor is in.
expect(jar.get(INVITE_COOKIE_NAME)).toBe(TOKEN)
expect(destination).toBeNull()
})
it('does not defer a BankID-linked user, whom nothing would bounce', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(200)
const destination = await handoffPendingInvite(
deps({ getUser: async () => ({ app_metadata: { bankid_linked: true } }) }),
)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(destination).toBe(INVITE_ACCEPTED_DESTINATION)
expect(jar.has(INVITE_COOKIE_NAME)).toBe(false)
})
it('does not defer on self-hosted, where MFA is never enforced', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(200)
const destination = await handoffPendingInvite(deps())
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(destination).toBe(INVITE_ACCEPTED_DESTINATION)
})
it('still attempts when the assurance level cannot be read', async () => {
installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(200)
const destination = await handoffPendingInvite(
deps({ getAssuranceLevel: async () => null }),
)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(destination).toBe(INVITE_ACCEPTED_DESTINATION)
})
// A throw here must never surface as "could not save password": the password
// was saved before this ran.
it('still attempts, without throwing, when the session read fails outright', async () => {
installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(200)
const destination = await handoffPendingInvite(
deps({
getUser: async () => {
throw new TypeError('Failed to fetch')
},
}),
)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(destination).toBe(INVITE_ACCEPTED_DESTINATION)
})
})
/**
* The page wiring. Against HEAD every assertion here fails: the page never
* mentioned the invite at all.
*/
describe('reset-password page wiring', () => {
const SRC = fs.readFileSync(path.resolve(__dirname, '../page.tsx'), 'utf8')
it('calls the shared handoff rather than carrying its own copy', () => {
expect(SRC).toContain("import { handoffPendingInvite } from './invite-handoff'")
expect(SRC).toContain('await handoffPendingInvite({')
// The acceptance POST and the cookie writes belong to the shared helper.
expect(SRC).not.toContain('/api/team/accept')
expect(SRC).not.toContain('document.cookie')
})
it('runs the handoff only after the password has actually been saved', () => {
const passwordPost = SRC.indexOf("'/api/account/password'")
const failureGuard = SRC.indexOf('if (!res.ok)')
const handoff = SRC.indexOf('await handoffPendingInvite({')
expect(passwordPost).toBeGreaterThan(-1)
expect(failureGuard).toBeGreaterThan(passwordPost)
expect(handoff).toBeGreaterThan(failureGuard)
})
it('hard-navigates to whatever the handoff returns', () => {
expect(SRC).toContain('window.location.href = inviteDestination')
})
it('never routes invite recovery through /onboarding', () => {
// /onboarding heals a missed invite server-side but is unreachable for a
// user who already has a company: that is the whole finding. Comment lines
// are dropped so prose about it is not mistaken for a navigation.
const code = SRC.split('\n').filter(
(line) => !/^\s*(\*|\/\/|\/\*)/.test(line),
)
expect(code.join('\n')).not.toContain('/onboarding')
})
it('renders the shared invite wording instead of inventing its own', () => {
expect(SRC).toContain(
"import { INVITE_PROBLEM_MESSAGE_KEYS } from '@/lib/auth/consume-invite-cookie'",
)
expect(SRC).toContain("useTranslations('invite')")
})
})
/** The toast the page now renders must exist in both locales. */
describe('invite problem wording', () => {
const messages = (locale: 'sv' | 'en') =>
JSON.parse(
fs.readFileSync(
path.resolve(__dirname, `../../../../messages/${locale}.json`),
'utf8',
),
) as { invite: Record<string, string> }
for (const locale of ['sv', 'en'] as const) {
it(`has every problem title and body in ${locale}.json`, () => {
const invite = messages(locale).invite
for (const { title, body } of Object.values(INVITE_PROBLEM_MESSAGE_KEYS)) {
expect(invite[title]).toBeTruthy()
expect(invite[body]).toBeTruthy()
}
})
}
})
+135
View File
@@ -0,0 +1,135 @@
import {
consumeInviteCookie,
readInviteCookie,
type InviteAcceptProblem,
} from '@/lib/auth/consume-invite-cookie'
import { shouldEnforceMfa } from '@/lib/auth/mfa'
/**
* Invite handoff for the password-recovery flow.
*
* `/reset-password` was the only session-establishing path that never picked
* the pre-auth invite cookie back up. Login, register and `/mfa/verify` all
* call `consumeInviteCookie()`; the recovery flow did not, and the server-side
* safety nets do not cover it. `acceptPendingInviteByToken()`
* (lib/company/pending-invites.ts) runs only on `/onboarding` and
* `/select-company`, and `/onboarding` is reachable only for a user with zero
* companies. A consultant who already has a company of their own therefore had
* no route back to the invitation at all after resetting their password.
*
* This module holds the decision only. The POST, the status classification and
* the "clear the cookie on a definitive outcome, keep it otherwise" rule all
* stay in `lib/auth/consume-invite-cookie.ts`: this is the fifth caller of that
* helper, not a fifth copy of it.
*
* It lives beside `page.tsx` rather than inside it because a Next.js page file
* may not export anything but the page component and the route segment config,
* which would leave the flow untestable in a repo with no component harness.
*/
/** Only the field the MFA predicate reads off the authenticated user. */
export interface InviteHandoffUser {
app_metadata?: Record<string, unknown>
}
/** Current vs required assurance level for the live session. */
export interface InviteHandoffAssuranceLevel {
currentLevel: string | null
nextLevel: string | null
}
export interface InviteHandoffDeps {
/** The authenticated user, or null when it could not be read. */
getUser: () => Promise<InviteHandoffUser | null>
/** The session's assurance levels, or null when they could not be read. */
getAssuranceLevel: () => Promise<InviteHandoffAssuranceLevel | null>
/** Show the user a non-definitive outcome. */
reportProblem: (problem: InviteAcceptProblem) => void
}
/**
* Where a user whose invite survived an inconclusive attempt is sent so the
* server can retry it. `/select-company` re-runs `acceptPendingInviteByToken()`
* on every load and, unlike `/onboarding`, is reachable for a user who already
* has a company (lib/supabase/middleware.ts lets it through with a resolved
* active company). That makes it the only healing surface for the user shape
* this whole handoff exists for.
*/
export const INVITE_RETRY_DESTINATION = '/select-company'
/** Where a settled invite lands the user. */
export const INVITE_ACCEPTED_DESTINATION = '/'
/**
* Accept a pending invitation once the password reset itself is finished.
*
* MUST be called only after `POST /api/account/password` has returned 2xx.
* That route runs `requireAuth()`, so its success proves a server-validated
* session exists, and waiting for it means no membership is ever created off a
* half-completed recovery. Authorization is not this function's business:
* `/api/team/accept` re-authorizes every attempt with `requireAuth()` plus an
* email equality check against the invitation.
*
* Returns the path to hard-navigate to, or null to keep the caller's normal
* post-reset navigation. Null covers every case where nothing changed
* server-side: no invitation in flight, a step-up still owed, a spent token, or
* an invitation belonging to another address.
*
* Never throws, so a hiccup in the invite handoff can never be mistaken for a
* failed password reset by the caller's error path.
*/
export async function handoffPendingInvite(
deps: InviteHandoffDeps,
): Promise<string | null> {
// No invitation in flight: no requests, no toast, untouched flow.
if (!readInviteCookie()) return null
if (await mfaStepUpOwed(deps)) return null
const invite = await consumeInviteCookie()
if (invite.problem) deps.reportProblem(invite.problem)
if (invite.accepted) return INVITE_ACCEPTED_DESTINATION
// Retain-and-retry, for transient failures only. `wrong_email` is
// deterministic for this account (the server-side email equality check would
// fail identically), and `spent` already destroyed the token, so neither
// gains anything from the server-side retry: both just show the toast and
// keep the normal flow.
if (invite.disposition === 'retryable') return INVITE_RETRY_DESTINATION
return null
}
/**
* True when the middleware would reject the acceptance for want of a step-up.
*
* `POST /api/team/accept` sits BEHIND the middleware MFA gate: it is not in
* `apiPathSkipsMfaGate` (lib/auth/api-mfa-gate.ts), and a recovery session is
* AAL1. Attempting the acceptance while a step-up is owed returns a bare 403,
* which the shared classifier cannot tell apart from an email mismatch, so a
* legitimate invitee would be told their invitation belongs to someone else.
*
* The predicate mirrors the middleware's own condition exactly
* (`shouldEnforceMfa` plus aal1-with-aal2-required), which makes "we skipped"
* and "the middleware will bounce this user to /mfa/verify" the same statement:
* the cookie survives untouched and `/mfa/verify` consumes it once the second
* factor is in. A user whose MFA is not enforced (self-hosted, BankID-linked)
* is deliberately never deferred, because nothing would bounce them and the
* token would sit there unused.
*
* A failed read is not evidence of a pending step-up, so it falls through to
* the attempt: the server is the authority, and a 403 keeps the token anyway.
*/
async function mfaStepUpOwed(deps: InviteHandoffDeps): Promise<boolean> {
try {
const user = await deps.getUser()
if (!user || !shouldEnforceMfa(user)) return false
const aal = await deps.getAssuranceLevel()
return aal?.nextLevel === 'aal2' && aal.currentLevel === 'aal1'
} catch (err) {
console.error('[reset-password] could not read the session MFA state', err)
return false
}
}
+31
View File
@@ -9,6 +9,8 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, KeyRound } from 'lucide-react'
import { INVITE_PROBLEM_MESSAGE_KEYS } from '@/lib/auth/consume-invite-cookie'
import { handoffPendingInvite } from './invite-handoff'
/**
* Three entry modes:
@@ -31,6 +33,7 @@ type Mode = 'loading' | 'set-password' | 'confirm-link' | 'enter-code'
function ResetPasswordInner() {
const t = useTranslations('reset_password')
const tInvite = useTranslations('invite')
const searchParams = useSearchParams()
const tokenHash = searchParams.get('token_hash')
const [mode, setMode] = useState<Mode>('loading')
@@ -156,6 +159,34 @@ function ResetPasswordInner() {
description: t('saved_description'),
})
// Only now, with the reset finished and the session proven by the 2xx
// above (that route runs requireAuth()), pick up a pending invitation.
// This is the recovery flow's copy of what login, register and
// /mfa/verify already do, and it is the only chance an invitee who
// already has a company of their own gets: the server-side retry on
// /onboarding never sees them. See ./invite-handoff.ts.
const inviteDestination = await handoffPendingInvite({
getUser: async () => (await supabase.auth.getUser()).data.user,
getAssuranceLevel: async () =>
(await supabase.auth.mfa.getAuthenticatorAssuranceLevel()).data,
reportProblem: (problem) => {
const keys = INVITE_PROBLEM_MESSAGE_KEYS[problem]
toast({
title: tInvite(keys.title),
description: tInvite(keys.body),
variant: 'destructive',
})
},
})
if (inviteDestination) {
// Hard navigation: either the membership and the active company just
// changed server-side, or the destination re-runs acceptance on the
// server. Both leave the client router's cached render stale.
window.location.href = inviteDestination
return
}
router.push('/')
router.refresh()
} catch {
+1
View File
@@ -410,6 +410,7 @@ export default function ArticleDetailPage({
<ArticleForm
onSubmit={handleUpdate}
isLoading={isUpdating}
onCancel={() => setIsEditOpen(false)}
initialData={{
article_number: article.article_number || undefined,
name: article.name,
+1
View File
@@ -279,6 +279,7 @@ function ArticlesPageInner() {
<ArticleForm
onSubmit={handleCreateArticle}
isLoading={isCreating}
onCancel={() => setIsDialogOpen(false)}
/>
</DialogContent>
</Dialog>
+227 -108
View File
@@ -23,6 +23,23 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
const supabase = createClient()
/**
* The sentence the route meant to say, from a response it refused.
*
* The parsed body goes to the mapper as-is. Wrapping it in `new Error()` first,
* which every handler here used to do, stringifies the canonical envelope
* `{ error: { code, message } }` into "[object Object]": the mapper then has
* nothing to match on and falls through to the generic "Något gick fel",
* discarding the route's own Swedish reason and its Zod field list. Passing the
* status as well means a body-less or non-JSON refusal still resolves to the
* right sentence, so a 403 read-only refusal reads as one instead of as a
* generic failure.
*/
async function describeFailure(response: Response): Promise<string> {
const body: unknown = await response.json().catch(() => null)
return getUserErrorMessage(body, { statusCode: response.status })
}
export default function DeadlinesPage() {
const { company } = useCompany()
const companyId = company?.id
@@ -37,8 +54,11 @@ export default function DeadlinesPage() {
const [editingDeadline, setEditingDeadline] = useState<Deadline | null>(null)
const { toast } = useToast()
const fetchData = useCallback(async () => {
if (!companyId) return
// Returns whether the list on screen was actually refreshed. Handlers that
// confirm an outcome to the user need that: claiming a row moved while the
// reload failed would describe a list the user is not looking at.
const fetchData = useCallback(async (): Promise<boolean> => {
if (!companyId) return false
setIsLoading(true)
try {
@@ -73,7 +93,17 @@ export default function DeadlinesPage() {
.from('invoices')
.select('total_sek, total, currency')
.eq('company_id', companyId)
.in('status', ['sent', 'unpaid'])
// The open statuses, and only those the CHECK actually allows
// (draft, sent, paid, partially_paid, overdue, cancelled,
// credited): draft is not owed yet, paid/cancelled/credited are
// settled. There is no 'unpaid' status; this filter used to name
// one, so every invoice the reminder run had already flipped to
// 'overdue' fell out of the total silently. partially_paid is
// deliberately absent: the sum below is the invoice total, not
// remaining_amount, so counting one would report money already
// received as still owed, and the action link lands on the
// invoices tab that lists exactly sent + overdue.
.in('status', ['sent', 'overdue'])
.lt('due_date', today)
.order('id', { ascending: true })
.range(from, to),
@@ -94,12 +124,14 @@ export default function DeadlinesPage() {
setDeadlines(deadlineRows)
setCustomers(customerRows)
setOverdueInvoices({ count: overdueRows.length, total: overdueTotal, unconverted: unconvertedCount })
return true
} catch {
toast({
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
return false
} finally {
setIsLoading(false)
}
@@ -112,13 +144,33 @@ export default function DeadlinesPage() {
const handleGenerateSystemDeadlines = async () => {
setIsGenerating(true)
try {
const response = await fetch('/api/tax-deadlines/generate', { method: 'POST' })
const result = await response.json()
const response = await fetch('/api/tax-deadlines/generate', {
method: 'POST',
}).catch(() => null)
// The request never reached the route (offline, connection reset), so
// point at the connection rather than the data.
if (!response) {
toast({
title: t('generate_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
return
}
if (!response.ok) {
throw new Error(result.error || t('generate_failed_description'))
toast({
title: t('generate_failed_title'),
description: await describeFailure(response),
variant: 'destructive',
})
return
}
// Only a malformed success body reaches the catch below now.
const result = await response.json()
if ((result.created ?? 0) === 0) {
// Nothing was generated: the tax settings are genuinely incomplete.
// Point the user to fill them in (the banner's settings link stays visible).
@@ -145,149 +197,216 @@ export default function DeadlinesPage() {
}
}
const handleDeadlineCreate = async (data: DeadlineFormValues) => {
try {
const response = await fetch('/api/deadlines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
// Returns whether the create landed. False keeps the form open with the
// user's input (handleFormSubmit only closes it on success); the toast above
// each return is the user's copy of why. Not a throw: DeadlineForm's submit
// wrapper has no catch, so a rejection here would escape as an unhandled
// promise rejection rather than control flow.
const handleDeadlineCreate = async (data: DeadlineFormValues): Promise<boolean> => {
const response = await fetch('/api/deadlines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).catch(() => null)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create deadline')
}
toast({
title: t('created_title'),
description: t('created_description'),
})
fetchData()
} catch (error) {
if (!response) {
// The request never reached the route (offline, connection reset), so
// point at the connection rather than the data.
toast({
title: t('create_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
description: t('load_failed_description'),
variant: 'destructive',
})
throw error
return false
}
if (!response.ok) {
toast({
title: t('create_failed_title'),
description: await describeFailure(response),
variant: 'destructive',
})
return false
}
toast({
title: t('created_title'),
description: t('created_description'),
})
fetchData()
return true
}
// "Ångra" on the marked-done toast. It lives here rather than inline in the
// toast so it gets the same treatment as every other mutation on this page:
// gated on res.ok, refetched, and answered with exactly one sentence. A
// Skatteverket deadline that is still marked done must never be reported as
// back on the list, and a failed undo must not look like a successful one.
const handleUndoComplete = useCallback(
async (deadline: Deadline) => {
const response = await fetch(`/api/deadlines/${deadline.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// The target state, not a toggle: clicking undo twice, or undoing a row
// something else already un-ticked, must not re-complete the deadline.
body: JSON.stringify({ is_completed: false }),
}).catch(() => null)
// null: the request never completed (offline, connection reset). Nothing
// was undone, so point at the connection rather than the data.
if (!response) {
toast({
title: t('undo_failed'),
description: t('load_failed_description'),
variant: 'destructive',
})
return
}
if (!response.ok) {
toast({
title: t('undo_failed'),
// The route knows why it refused (gone, read-only member); prefer its
// own message over generic retry copy.
description: await describeFailure(response),
variant: 'destructive',
})
return
}
// Confirm only once the row is provably back. When the reload failed,
// fetchData has already said so and TOAST_LIMIT is 1, so a success
// sentence here would evict that warning and render alone.
if (!(await fetchData())) return
toast({ title: t('marked_not_done', { title: deadline.title }) })
},
[fetchData, toast, t],
)
const handleDeadlineToggle = async (deadline: Deadline) => {
const wasCompleted = deadline.is_completed
const newCompleted = !wasCompleted
try {
const response = await fetch(`/api/deadlines/${deadline.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_completed: newCompleted }),
})
const response = await fetch(`/api/deadlines/${deadline.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_completed: newCompleted }),
}).catch(() => null)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to toggle deadline')
}
fetchData()
if (newCompleted) {
toast({
title: t('marked_done', { title: deadline.title }),
action: (
<ToastAction altText={t('undo')} onClick={async () => {
try {
await fetch(`/api/deadlines/${deadline.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_completed: false }),
})
} catch {
toast({
title: t('undo_failed'),
variant: 'destructive',
})
}
}}>
{t('undo')}
</ToastAction>
),
})
} else {
toast({ title: t('marked_not_done', { title: deadline.title }) })
}
} catch (error) {
if (!response) {
toast({
title: t('toggle_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
description: t('load_failed_description'),
variant: 'destructive',
})
return
}
if (!response.ok) {
toast({
title: t('toggle_failed_title'),
description: await describeFailure(response),
variant: 'destructive',
})
return
}
fetchData()
if (newCompleted) {
toast({
title: t('marked_done', { title: deadline.title }),
action: (
<ToastAction
altText={t('undo')}
onClick={() => {
void handleUndoComplete(deadline)
}}
>
{t('undo')}
</ToastAction>
),
})
} else {
toast({ title: t('marked_not_done', { title: deadline.title }) })
}
}
// Sends ONLY the form-managed fields: the PUT route whitelists to the same
// set, and posting a whole Deadline row from here would fabricate system
// fields (source, status, reminder_offsets) that only the whitelist drops.
const handleDeadlineEdit = async (id: string, data: DeadlineFormValues) => {
try {
const response = await fetch(`/api/deadlines/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
// Returns whether the edit landed, same contract as create: false keeps the
// form open with the user's input instead of silently discarding it.
const handleDeadlineEdit = async (id: string, data: DeadlineFormValues): Promise<boolean> => {
const response = await fetch(`/api/deadlines/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).catch(() => null)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to edit deadline')
}
toast({
title: t('updated_title'),
description: t('updated_description'),
})
fetchData()
} catch (error) {
if (!response) {
toast({
title: t('update_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
description: t('load_failed_description'),
variant: 'destructive',
})
// Rethrow (like create) so a failed edit keeps the form open with the
// user's input instead of silently discarding it.
throw error
return false
}
if (!response.ok) {
toast({
title: t('update_failed_title'),
description: await describeFailure(response),
variant: 'destructive',
})
return false
}
toast({
title: t('updated_title'),
description: t('updated_description'),
})
fetchData()
return true
}
const handleDeadlineDelete = async (deadline: Deadline) => {
try {
const response = await fetch(`/api/deadlines/${deadline.id}`, {
method: 'DELETE',
})
const response = await fetch(`/api/deadlines/${deadline.id}`, {
method: 'DELETE',
}).catch(() => null)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete deadline')
}
toast({ title: t('deleted_title') })
fetchData()
} catch (error) {
if (!response) {
toast({
title: t('delete_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
description: t('load_failed_description'),
variant: 'destructive',
})
return
}
if (!response.ok) {
toast({
title: t('delete_failed_title'),
description: await describeFailure(response),
variant: 'destructive',
})
return
}
toast({ title: t('deleted_title') })
fetchData()
}
const handleFormSubmit = async (data: DeadlineFormValues) => {
if (editingDeadline) {
await handleDeadlineEdit(editingDeadline.id, data)
} else {
await handleDeadlineCreate(data)
}
const ok = editingDeadline
? await handleDeadlineEdit(editingDeadline.id, data)
: await handleDeadlineCreate(data)
// On failure the handler has already toasted; leave the form open so the
// user's input survives a retry.
if (!ok) return
setShowForm(false)
setEditingDeadline(null)
}
+8 -3
View File
@@ -271,7 +271,10 @@ function BankFileImportWizard() {
const data = await res.json()
if (!res.ok) {
setBankError(data.error || 'Importen misslyckades')
// Map the parsed body plus the status: on the thrown-error path
// `data.error` is the canonical envelope OBJECT (withRouteContext),
// which must never be stored into a rendered string state.
setBankError(getErrorMessage(data, { statusCode: res.status }))
return
}
@@ -872,7 +875,9 @@ function OpeningBalanceFlow() {
const data = await res.json()
if (!res.ok) {
setObError(data.error || 'Kunde inte läsa filen')
// Map the parsed body plus the status, never render `data.error` raw:
// the thrown-error path answers with the canonical envelope OBJECT.
setObError(getErrorMessage(data, { statusCode: res.status }))
return
}
@@ -927,7 +932,7 @@ function OpeningBalanceFlow() {
const data = await res.json()
if (!res.ok) {
setObError(data.error || 'Kunde inte läsa filen med de valda kolumnerna')
setObError(getErrorMessage(data, { statusCode: res.status }))
return
}
+17 -6
View File
@@ -45,10 +45,6 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
const [createdCreditNote, setCreatedCreditNote] = useState<InvoiceWithRelations | null>(null)
const [showSendPrompt, setShowSendPrompt] = useState(false)
useEffect(() => {
fetchInvoice()
}, [id])
async function fetchInvoice() {
setIsLoading(true)
@@ -103,6 +99,10 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
setIsLoading(false)
}
useEffect(() => {
fetchInvoice()
}, [id])
async function handleSubmit() {
if (!invoice) return
@@ -119,8 +119,19 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || t('create_failed_fallback'))
// Map the parsed body plus the status, never `new Error(data.error)`:
// the route answers thrown errors with the canonical envelope
// `{ error: { code, message } }`, and the Error constructor would
// stringify that object to "[object Object]", discarding the route's
// own Swedish reason.
const body = await response.json().catch(() => null)
toast({
title: t('create_failed_title'),
description: getUserErrorMessage(body, { statusCode: response.status }),
variant: 'destructive',
})
setIsSubmitting(false)
return
}
const { data: creditNote } = await response.json() as { data: InvoiceWithRelations }
+206 -27
View File
@@ -18,6 +18,13 @@ import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import { canCopyInvoice } from '@/lib/invoices/copy-invoice'
import {
invoiceDocumentCaveat,
invoiceRerenderUrl,
resolveInvoicePdfSource,
type InvoicePdfRerenderReason,
type InvoicePdfSource,
} from '@/lib/invoices/invoice-pdf-source'
import { contentDispositionFilename } from '@/lib/api/content-disposition'
import {
Loader2,
@@ -70,6 +77,18 @@ const statusVariantMap: Record<InvoiceStatus, 'default' | 'secondary' | 'success
credited: 'secondary',
}
// Why the downloaded file is not the invoice the customer received. One key
// per reason: "no archived copy exists" and "the archive could not be reached"
// are different facts and must not be told as the same story.
const RERENDER_CAVEAT_KEYS: Record<
Exclude<InvoicePdfRerenderReason, 'not_sent_yet'>,
string
> = {
sent_outside_accounted: 'pdf_rerender_reason_sent_outside',
no_archived_copy: 'pdf_rerender_reason_no_archive',
archive_unreachable: 'pdf_rerender_reason_archive_unreachable',
}
// A line is periodiserad when both period dates are set: the revenue was
// parked on the 29xx interim account and dissolves monthly via accrual_schedules.
const itemHasAccrual = (item: InvoiceItem): boolean =>
@@ -99,6 +118,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
const [reminders, setReminders] = useState<InvoiceReminder[]>([])
const [deliveries, setDeliveries] = useState<InvoiceDeliveryView[]>([])
// An empty deliveries list means "nothing was ever sent through Accounted".
// A failed read also produces an empty list, and the two must never be
// conflated: the archived PDF the customer received is the räkenskapsunderlag
// (BFL 7 kap), and a freshly re-rendered one is a different document.
const [deliveriesUnreadable, setDeliveriesUnreadable] = useState(false)
// Set when the archived copy could not be produced, so the user is asked
// instead of being handed a substitute that looks like the original.
const [pdfArchiveIssue, setPdfArchiveIssue] = useState<'history' | 'document' | null>(null)
// Payment history backing the new Betalningsstatus card. Fetched alongside
// the invoice itself so the card stays in sync with paid_amount /
// remaining_amount on the invoice row.
@@ -142,6 +169,34 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
fetchInvoice()
}, [id])
/**
* Read the delivery history, keeping "read failed" distinct from "nothing
* has been sent". Both used to arrive as `[]`, which is what let a network
* blip silently downgrade the invoice download from the archived PDF the
* customer received to a freshly re-rendered one.
*/
async function loadDeliveries(): Promise<{
ok: boolean
deliveries: InvoiceDeliveryView[]
}> {
try {
const response = await fetch(`/api/invoices/${encodeURIComponent(id)}/deliveries`)
if (!response.ok) return { ok: false, deliveries: [] }
const payload = (await response.json()) as { data?: InvoiceDeliveryView[] }
if (!Array.isArray(payload.data)) return { ok: false, deliveries: [] }
return { ok: true, deliveries: payload.data }
} catch {
return { ok: false, deliveries: [] }
}
}
async function retryLoadDeliveries() {
const result = await loadDeliveries()
setDeliveries(result.deliveries)
setDeliveriesUnreadable(!result.ok)
return result
}
async function fetchInvoice() {
setIsLoading(true)
@@ -155,13 +210,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
.maybeSingle()
: Promise.resolve(null)
const deliveriesPromise = fetch(`/api/invoices/${encodeURIComponent(id)}/deliveries`)
.then(async (response) => {
if (!response.ok) return []
const payload = (await response.json()) as { data?: InvoiceDeliveryView[] }
return Array.isArray(payload.data) ? payload.data : []
})
.catch(() => [] as InvoiceDeliveryView[])
const deliveriesPromise = loadDeliveries()
// Invoice, reminders, payments, and deliveries all key on the route id: one
// parallel batch. Only the follow-ups below need the invoice row.
@@ -211,7 +260,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
}
setInvoice(data as InvoiceWithRelations)
setDeliveries(deliveryData)
setDeliveries(deliveryData.deliveries)
setDeliveriesUnreadable(!deliveryData.ok)
if (reminderData) {
setReminders(reminderData as InvoiceReminder[])
@@ -419,25 +469,36 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
setIsConverting(false)
}
async function downloadPDF() {
/**
* Fetch and save one specific document, then say truthfully which one it was.
*
* The archived delivery is the invoice the customer actually received and is
* the räkenskapsunderlag kept for 7 years (BFL 7 kap). A re-render comes off
* today's invoice row, customer row, company settings and logo, so it is a
* different document whenever any of those moved. It may be served when it
* is the only thing that exists, but never under the plain "nedladdad" toast
* that reads as "here is what you sent".
*/
async function runInvoiceDownload(source: InvoicePdfSource) {
if (!invoice) return
if (source.kind === 'unavailable') {
setPdfArchiveIssue('history')
return
}
setIsDownloading(true)
try {
const mostRecentDelivery = deliveries.find(
(delivery) => delivery.status === 'sent' || delivery.status === 'marked_sent',
)
const archivedDelivery = mostRecentDelivery?.status === 'sent'
? mostRecentDelivery
: undefined
const response = await fetch(
archivedDelivery?.document_attachment_id
? `/api/documents/${archivedDelivery.document_attachment_id}/inline`
: `/api/invoices/${invoice.id}/pdf`,
)
const response = await fetch(source.url)
if (!response.ok) {
// A missing archive is not a generation failure and must not offer a
// silent substitute: hand the choice back to the user.
if (source.kind === 'archived') {
setPdfArchiveIssue('document')
return
}
throw new Error(t('pdf_generate_failed'))
}
@@ -452,21 +513,73 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
toast({
title: t('pdf_downloaded_title'),
description: invoice.invoice_number
? t('pdf_downloaded_with_number', { number: invoice.invoice_number })
: t('pdf_downloaded_draft'),
})
const caveat = invoiceDocumentCaveat(source)
if (caveat) {
toast({
title: t('pdf_rerender_downloaded_title'),
description: t(RERENDER_CAVEAT_KEYS[caveat]),
})
} else {
toast({
title: t('pdf_downloaded_title'),
description: invoice.invoice_number
? t('pdf_downloaded_with_number', { number: invoice.invoice_number })
: t('pdf_downloaded_draft'),
})
}
} catch (error) {
toast({
title: t('pdf_download_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
} finally {
setIsDownloading(false)
}
}
async function downloadPDF() {
if (!invoice) return
setPdfArchiveIssue(null)
await runInvoiceDownload(
resolveInvoicePdfSource({
invoiceId: invoice.id,
invoiceStatus: invoice.status,
deliveriesLoaded: !deliveriesUnreadable,
deliveries,
}),
)
}
// "Försök igen" from the archive dialog. Re-reads the delivery history first
// so a transient list failure resolves back to the archived copy instead of
// getting stuck on the stale empty state.
async function retryArchivedDownload() {
if (!invoice) return
setIsDownloading(true)
const result = await retryLoadDeliveries()
setIsDownloading(false)
setPdfArchiveIssue(null)
await runInvoiceDownload(
resolveInvoicePdfSource({
invoiceId: invoice.id,
invoiceStatus: invoice.status,
deliveriesLoaded: result.ok,
deliveries: result.deliveries,
}),
)
}
// The user explicitly accepted a re-render after being told it is not the
// document that was sent. The toast still says so.
async function downloadRerenderAnyway() {
if (!invoice) return
setPdfArchiveIssue(null)
await runInvoiceDownload({
kind: 'rerender',
url: invoiceRerenderUrl(invoice.id),
reason: 'archive_unreachable',
})
}
// Open the finalize dialog and peek the next F-number so the user can see
@@ -1002,7 +1115,32 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Card>
)}
{isRealInvoice && !isSelfBilled && (
{/* The legacy empty state asserts "sent before delivery history
existed". A failed read produces the same empty list, so that
claim would be a guess: say what actually happened instead. */}
{isRealInvoice && !isSelfBilled && deliveriesUnreadable && (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
{t('delivery_history_title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground">
{t('delivery_history_unreadable_title')}
</p>
<p className="mt-1">{t('delivery_history_unreadable_description')}</p>
</div>
<Button variant="outline" size="sm" onClick={() => void retryLoadDeliveries()}>
{t('delivery_history_unreadable_retry')}
</Button>
</CardContent>
</Card>
)}
{isRealInvoice && !isSelfBilled && !deliveriesUnreadable && (
<InvoiceDeliveryHistory
deliveries={deliveries}
showLegacyEmptyState={[
@@ -1533,6 +1671,47 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</DialogContent>
</Dialog>
{/* The archived PDF the customer received could not be produced. Nothing
has been downloaded at this point: a re-render is a different
document, so the user chooses it deliberately or not at all. */}
<Dialog
open={pdfArchiveIssue !== null}
onOpenChange={(open) => {
if (!open) setPdfArchiveIssue(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('pdf_archive_issue_title')}</DialogTitle>
<DialogDescription>
{pdfArchiveIssue === 'document'
? t('pdf_archive_issue_document_desc')
: t('pdf_archive_issue_history_desc')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setPdfArchiveIssue(null)}
disabled={isDownloading}
>
{t('pdf_archive_issue_cancel')}
</Button>
<Button
variant="secondary"
onClick={downloadRerenderAnyway}
disabled={isDownloading}
>
{t('pdf_archive_issue_rerender')}
</Button>
<Button onClick={retryArchivedDownload} disabled={isDownloading}>
{isDownloading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('pdf_archive_issue_retry')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PaymentBookingDialog
open={showPaymentDialog}
onOpenChange={setShowPaymentDialog}
+154 -49
View File
@@ -1,14 +1,21 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback, useId } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Settings2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '@/components/ui/page-header'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { useToast } from '@/components/ui/use-toast'
import { FyPicker } from '@/components/common/FyPicker'
import { KPIPanes, KPIBreakdown } from '@/components/kpi/KPIStory'
import { KPISettingsDialog } from '@/components/kpi/KPISettingsDialog'
import { getDefaultPreferences } from '@/lib/reports/kpi-definitions'
import { saveKPIPreferences } from '@/components/kpi/save-preferences'
import { loadKPIPreferences } from '@/components/kpi/load-preferences'
import { failureDescription, type ActionFailure } from '@/lib/browser/action-failure'
import type { ErrorLocale } from '@/lib/errors/get-error-message'
import type { KPIReport, KPIPreferences } from '@/types'
/**
@@ -19,26 +26,50 @@ import type { KPIReport, KPIPreferences } from '@/types'
*/
export default function KpiPage() {
const t = useTranslations('kpi')
const locale = useLocale() as ErrorLocale
const { toast } = useToast()
const [selectedPeriod, setSelectedPeriod] = useState<string>('')
const [report, setReport] = useState<KPIReport | null>(null)
const [preferences, setPreferences] = useState<KPIPreferences>(getDefaultPreferences())
// null = the stored layout is not known: still loading, or the read failed
// (prefsError). It never holds a fabricated value.
const [preferences, setPreferences] = useState<KPIPreferences | null>(null)
const [prefsError, setPrefsError] = useState<ActionFailure | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [isLoadingReport, setIsLoadingReport] = useState(false)
const [isSavingPrefs, setIsSavingPrefs] = useState(false)
const [error, setError] = useState<string | null>(null)
const prefsStatusId = useId()
// A page that could not read the stored layout must not render one, and above
// all must not let the user save over it. The previous version fell back to
// getDefaultPreferences() on any failed read, silently: the grid presented
// the defaults as though they were the user's, the settings dialog seeded
// its draft from them, and the next save PUT a complete defaults-based
// object over the stored row (the route merges key by key, so a payload
// carrying every key replaces the row outright). A transient read failure
// became permanent loss of the user's layout.
//
// So the unknown stays unknown: preferences stays null, the preference-driven
// panes do not render, "Anpassa" is disabled, and one AttnLine says the
// layout could not be read. An expired session (401/403) is the only case
// where the reason changes what the user must do, so there the status-map
// sentence replaces the retry; everything else is transient and the retry is
// the whole answer.
useEffect(() => {
let cancelled = false
;(async () => {
try {
const res = await fetch('/api/kpi/preferences')
const { data } = await res.json()
if (!cancelled && data) setPreferences(data)
} catch {
// Silently fall back to defaults
setPrefsError(null)
const result = await loadKPIPreferences({ locale })
if (cancelled) return
if (result.ok) {
setPreferences(result.preferences)
} else {
setPreferences(null)
setPrefsError(result)
}
})()
return () => { cancelled = true }
}, [])
}, [reloadKey, locale])
const fetchReport = useCallback(async (periodId: string) => {
setIsLoadingReport(true)
@@ -64,60 +95,134 @@ export default function KpiPage() {
return () => { cancelled = true }
}, [selectedPeriod, fetchReport])
async function handleSavePreferences(prefs: KPIPreferences) {
/**
* Resolves true only when the row was written. The dialog keeps itself open on
* false, so the draft the user assembled survives a failed save.
*
* The previous version swallowed both the `!res.ok` throw and every thrown
* fetch behind "Silently fail: user can retry": the dialog closed, this grid
* went on rendering the layout the user had just picked, and the next page
* load read the untouched row back and reverted it. Exactly one toast per
* outcome, since TOAST_LIMIT is 1.
*/
async function handleSavePreferences(prefs: KPIPreferences): Promise<boolean> {
let result
setIsSavingPrefs(true)
try {
const res = await fetch('/api/kpi/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(prefs),
})
if (!res.ok) throw new Error()
const { data } = await res.json()
setPreferences(data)
if (selectedPeriod) await fetchReport(selectedPeriod)
} catch {
// Silently fail: user can retry
result = await saveKPIPreferences({ preferences: prefs, locale })
} finally {
// The busy state covers the save and nothing else. The report refetch
// below is unbounded and has its own skeleton, and holding `saving` open
// across it would keep the dialog locked on a request that is not the save.
setIsSavingPrefs(false)
}
if (!result.ok) {
toast({
title: t('save_failed_title'),
description: failureDescription(result, {
// A timeout on a write is genuinely ambiguous: the row may have been
// written. Say that instead of claiming the save failed.
timeout: t('save_timeout'),
network: t('save_network'),
}),
variant: 'destructive',
})
return false
}
setPreferences(result.preferences)
// Not awaited: the layout is stored, so the dialog closes now. A failing
// refetch reports itself through the `error` surface below.
if (selectedPeriod) void fetchReport(selectedPeriod)
return true
}
const isLoadingPrefs = preferences === null && prefsError === null
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
help={
<HelpPopover>
<p>{t('help_text')}</p>
</HelpPopover>
}
action={
<div className="flex items-center gap-2">
<KPISettingsDialog
preferences={preferences}
onSave={handleSavePreferences}
saving={isSavingPrefs}
/>
<FyPicker
value={selectedPeriod || null}
onChange={(id) => setSelectedPeriod(id || '')}
includeAllOption={false}
hideFuturePeriods
/>
</div>
}
/>
{/* Header and read-status share one flow child so the always-mounted
live region adds no vertical rhythm while it is empty. */}
<div>
<PageHeader
title={t('title')}
help={
<HelpPopover>
<p>{t('help_text')}</p>
</HelpPopover>
}
action={
<div className="flex items-center gap-2">
{preferences ? (
<KPISettingsDialog
preferences={preferences}
onSave={handleSavePreferences}
saving={isSavingPrefs}
/>
) : (
// The dialog seeds its draft from `preferences` and saves the
// complete draft, so while the stored layout is unknown the
// control that opens it must not exist: a save from here would
// overwrite the row with a layout the user never chose.
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled
aria-describedby={prefsError ? prefsStatusId : undefined}
>
<Settings2 className="h-3.5 w-3.5" />
{t('customize')}
</Button>
)}
<FyPicker
value={selectedPeriod || null}
onChange={(id) => setSelectedPeriod(id || '')}
includeAllOption={false}
hideFuturePeriods
/>
</div>
}
/>
{/* Live region always mounted so a failed read is announced when it
appears, not merely inserted. Inline, never a toast: TOAST_LIMIT is
1 and a toast here could evict a save failure's toast. */}
<div id={prefsStatusId} role="status" aria-live="polite">
{prefsError && (
<AttnLine
className="mt-3"
action={
prefsError.reason === 'server' &&
(prefsError.status === 401 || prefsError.status === 403)
? undefined
: { label: t('load_retry'), onClick: () => setReloadKey((k) => k + 1) }
}
>
{failureDescription(prefsError, {
timeout: t('load_timeout'),
network: t('load_network'),
})}
</AttnLine>
)}
</div>
</div>
{error && (
<p className="py-8 text-center text-sm text-muted-foreground">{error}</p>
)}
{isLoadingReport && <LoadingSkeleton />}
{(isLoadingReport || (!error && report !== null && isLoadingPrefs)) && (
<LoadingSkeleton />
)}
{!isLoadingReport && !error && report && (
{!isLoadingReport && !error && report && !isLoadingPrefs && (
<div className="stagger-enter space-y-10">
<KPIPanes report={report} preferences={preferences} />
{/* The panes are the preference-driven surface: with the layout
unknown they stay off rather than render defaults as if they
were the user's. The cost story below reads only the report. */}
{preferences && <KPIPanes report={report} preferences={preferences} />}
<KPIBreakdown report={report} />
</div>
)}
@@ -2,22 +2,36 @@
import Link from 'next/link'
import { useEffect, useState, useCallback } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { PageHeader } from '@/components/ui/page-header'
import { FyPicker } from '@/components/common/FyPicker'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/info-tooltip'
import { ArrowLeft, Download, FileSpreadsheet, AlertTriangle, CheckCircle2 } from 'lucide-react'
import {
ArrowLeft,
Download,
FileSpreadsheet,
AlertTriangle,
CheckCircle2,
Loader2,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { downloadFile } from '@/lib/browser/download-file'
import { failureDescription } from '@/lib/browser/action-failure'
import type { KassaflodesanalysReport } from '@/lib/reports/kassaflodesanalys'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import {
getErrorMessage as getUserErrorMessage,
type ErrorLocale,
} from '@/lib/errors/get-error-message'
function formatAmount(n: number): string {
return n.toLocaleString('sv-SE', {
@@ -55,10 +69,14 @@ function SubtotalRow({ label, amount }: SubtotalRowProps) {
}
export function KassaflodesanalysClient() {
const t = useTranslations('reports')
const locale = useLocale() as ErrorLocale
const { toast } = useToast()
const [selectedPeriod, setSelectedPeriod] = useState<string | null>(null)
const [report, setReport] = useState<KassaflodesanalysReport | null>(null)
const [isLoadingPeriods, setIsLoadingPeriods] = useState(true)
const [isLoadingReport, setIsLoadingReport] = useState(false)
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadReport = useCallback(async (periodId: string) => {
@@ -88,10 +106,60 @@ export function KassaflodesanalysClient() {
}
}, [selectedPeriod, loadReport])
const handleDownloadPdf = useCallback(() => {
if (!selectedPeriod) return
window.location.href = `/api/reports/kassaflodesanalys/pdf?period_id=${selectedPeriod}`
}, [selectedPeriod])
/**
* Fetch the statutory kassaflödesanalys PDF and save it only if the server
* actually produced one.
*
* This used to be `window.location.href = <pdf route>`, which has no seam for
* either half of the problem. On success the browser cancels the navigation
* (the route answers Content-Disposition: attachment) and the click gives no
* feedback at all, so nothing marks the render as in flight: a second click
* starts a second full report query plus renderToBuffer. On failure the route
* answers a JSON error envelope with no Content-Disposition, so the browser
* navigates the whole app away and the user is left staring at raw JSON with
* their report page gone. There is no `res` to check and no promise to catch
* on a location assignment; the only fix is to make the download a bounded
* fetch.
*
* The filename mirrors the route's Content-Disposition
* (kassaflodesanalys-<period_start>.pdf): both sides derive it from the same
* fiscal period, and the button is disabled until that report is loaded.
*
* No success toast: the saved file is the feedback. On failure nothing was
* written to disk and exactly one toast says why. Never two, TOAST_LIMIT is 1
* (components/ui/use-toast.tsx), so a second toast in the same tick evicts the
* first and only the last is rendered.
*/
const handleDownloadPdf = useCallback(async () => {
// The button is disabled while a render is in flight; this guard closes the
// double-click / Enter-repeat race before React has re-rendered it.
if (!selectedPeriod || !report || isDownloadingPdf) return
setIsDownloadingPdf(true)
try {
// The shared 15s deadline applies: this is one fiscal year of aggregation
// plus a single-page render with stock fonts, so the realistic worst case
// is a cold start and one round trip. If a healthy render ever needs
// longer, the answer is maxDuration on the route plus a matching
// timeoutMs here, never an unbounded fetch that spins forever.
const result = await downloadFile({
url: `/api/reports/kassaflodesanalys/pdf?period_id=${selectedPeriod}`,
filename: `kassaflodesanalys-${report.period_start}.pdf`,
locale,
})
if (!result.ok) {
toast({
title: t('pdf_download_failed_title'),
description: failureDescription(result, {
timeout: t('pdf_download_timeout'),
network: t('pdf_download_network'),
}),
variant: 'destructive',
})
}
} finally {
setIsDownloadingPdf(false)
}
}, [selectedPeriod, report, isDownloadingPdf, locale, toast, t])
return (
<div className="space-y-8">
@@ -116,9 +184,13 @@ export function KassaflodesanalysClient() {
<Button
variant="outline"
onClick={handleDownloadPdf}
disabled={!report || isLoadingReport}
disabled={!report || isLoadingReport || isDownloadingPdf}
>
<Download className="mr-2 h-4 w-4" />
{isDownloadingPdf ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Ladda ner PDF
</Button>
<TooltipProvider delayDuration={300}>
@@ -26,7 +26,7 @@ import {
lookupBankByClearing,
checkEmployeeAccountChecksum,
} from '@/lib/salary/payment/bank-account'
import type { Employee } from '@/types'
import type { EmployeeMasked } from '@/types'
import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel'
import { OpeningBalancesPanel } from '@/components/salary/OpeningBalancesPanel'
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
@@ -49,7 +49,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
const { toast } = useToast()
const { canWrite } = useCanWrite()
const { dialogProps, confirm: confirmAction } = useDestructiveConfirm()
const [employee, setEmployee] = useState<Employee | null>(null)
const [employee, setEmployee] = useState<EmployeeMasked | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [deactivating, setDeactivating] = useState(false)
@@ -241,7 +241,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
{employee.first_name} {employee.last_name}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{employee.personnummer} · {t(EMPLOYMENT_LABEL_KEYS[employee.employment_type])}
{employee.personnummer_masked} · {t(EMPLOYMENT_LABEL_KEYS[employee.employment_type])}
</p>
</div>
</div>
@@ -416,9 +416,10 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
</Card>
)}
{/* Tax */}
{/* Tax. The masked personnummer is enough input here: EmployeeTaxCard
only reads the leading birthdate digits to suggest a tax column. */}
<EmployeeTaxCard
personnummer={employee.personnummer || ''}
personnummer={employee.personnummer_masked || ''}
disabled={!canWrite}
onChange={setTax}
initial={{
+3 -3
View File
@@ -13,7 +13,7 @@ import { Plus, UserCircle } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { cn, formatCurrency } from '@/lib/utils'
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import type { Employee } from '@/types'
import type { EmployeeMasked } from '@/types'
const NewEmployeeDialog = dynamic(
() => import('@/components/salary/NewEmployeeDialog'),
@@ -33,7 +33,7 @@ const EMPLOYMENT_LABEL_KEYS: Record<string, string> = {
*/
export default function EmployeesPage() {
const t = useTranslations('employees')
const [employees, setEmployees] = useState<Employee[]>([])
const [employees, setEmployees] = useState<EmployeeMasked[]>([])
const [loading, setLoading] = useState(true)
const { canWrite } = useCanWrite()
const router = useRouter()
@@ -120,7 +120,7 @@ export default function EmployeesPage() {
</Link>
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap tabular-nums text-muted-foreground md:table-cell')}>
{emp.personnummer}
{emp.personnummer_masked}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-muted-foreground')}>
{EMPLOYMENT_LABEL_KEYS[emp.employment_type]
+2 -2
View File
@@ -14,7 +14,7 @@ import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import type { Employee, SalaryRun } from '@/types'
import type { EmployeeMasked, SalaryRun } from '@/types'
const STATUS_LABEL_KEYS: Record<string, string> = {
draft: 'status_draft',
@@ -46,7 +46,7 @@ const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'war
*/
export default function SalaryPage() {
const [runs, setRuns] = useState<SalaryRun[]>([])
const [employees, setEmployees] = useState<Employee[]>([])
const [employees, setEmployees] = useState<EmployeeMasked[]>([])
const [loading, setLoading] = useState(true)
const [starting, setStarting] = useState(false)
const { canWrite } = useCanWrite()
@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button'
import { SalaryCalendar } from '@/components/salary/SalaryCalendar'
import { SalaryOverridePanel } from '@/components/salary/SalaryOverridePanel'
import { formatCurrency } from '@/lib/utils'
import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, Employee } from '@/types'
import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, EmployeeMasked } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** Translation keys in the `salary_run_employee` namespace. */
@@ -55,7 +55,7 @@ const LINE_ITEM_TYPE_KEYS: Record<SalaryLineItemType, string> = {
interface DetailResponse {
run: SalaryRun
runEmployee: SalaryRunEmployee & { employee: Employee; line_items: SalaryLineItem[] }
runEmployee: SalaryRunEmployee & { employee: EmployeeMasked; line_items: SalaryLineItem[] }
}
export default function SalaryRunEmployeeDetailPage({
@@ -81,10 +81,21 @@ export default function SalaryRunEmployeeDetailPage({
fetch(`/api/salary/runs/${runId}`),
fetch(`/api/salary/runs/${runId}/employees/${employeeId}`),
])
const runJson = await runRes.json()
const sreJson = await sreRes.json()
if (!runRes.ok) throw new Error(runJson.error || t('error_load_run'))
if (!sreRes.ok) throw new Error(sreJson.error || t('error_load_employee'))
const runJson = await runRes.json().catch(() => null)
const sreJson = await sreRes.json().catch(() => null)
// Map the parsed body plus the status, never `new Error(json.error)`:
// the routes answer thrown errors with the canonical envelope
// `{ error: { code, message } }`, and the Error constructor stringifies
// that object to "[object Object]", which falls through to the generic
// "Något gick fel" and discards the route's own Swedish reason.
if (!runRes.ok) {
setError(getUserErrorMessage(runJson, { statusCode: runRes.status }))
return
}
if (!sreRes.ok) {
setError(getUserErrorMessage(sreJson, { statusCode: sreRes.status }))
return
}
setData({ run: runJson.data, runEmployee: sreJson.data })
} catch (e) {
setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
@@ -103,9 +114,12 @@ export default function SalaryRunEmployeeDetailPage({
setError(null)
try {
const res = await fetch(`/api/salary/runs/${runId}/calculate`, { method: 'POST' })
const json = await res.json().catch(() => ({}))
const json = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(json.error || t('error_calculate'))
// Same reason as in load(): the calculate route builds its refusals
// with errorResponse(), so `json.error` is the envelope object.
setError(getUserErrorMessage(json, { statusCode: res.status }))
return
}
await load()
} catch (e) {
@@ -176,7 +190,7 @@ export default function SalaryRunEmployeeDetailPage({
{employee.first_name} {employee.last_name}
</h1>
<p className="text-sm text-muted-foreground tabular-nums">
{employee.personnummer} · {t('payslip_period', { period: periodLabel })}
{employee.personnummer_masked} · {t('payslip_period', { period: periodLabel })}
</p>
</div>
{run.status === 'draft' && (
+341 -175
View File
@@ -22,7 +22,15 @@ import {
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useAgiSubmission } from '@/lib/hooks/use-agi-submission'
import { deriveAgiFilingState } from '@/lib/salary/agi-submission-state'
import {
deriveAgiFilingState,
resolveRunAgiKvittensnummer,
} from '@/lib/salary/agi-submission-state'
import {
buildPayslipZipReport,
payslipEmployeeLabel,
type PayslipZipAttempt,
} from '@/lib/salary/payslip-zip-report'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { AGIPanel } from '@/components/salary/AGIPanel'
import { PaymentFilePanel } from '@/components/salary/PaymentFilePanel'
@@ -34,7 +42,7 @@ import { RunEmployeesTable } from '@/components/salary/run/RunEmployeesTable'
import { RunCalculationDetails } from '@/components/salary/run/RunCalculationDetails'
import { RunJournalPreview, type PreviewData } from '@/components/salary/run/RunJournalPreview'
import { periodLabelOf, type RunDetail } from '@/components/salary/run/types'
import type { Employee, SalaryRunEmployee } from '@/types'
import type { EmployeeMasked, SalaryRunEmployee } from '@/types'
export default function SalaryRunPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
@@ -45,7 +53,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
const { dialogProps, confirm: confirmAction } = useDestructiveConfirm()
const [run, setRun] = useState<RunDetail | null>(null)
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([])
const [availableEmployees, setAvailableEmployees] = useState<EmployeeMasked[]>([])
const [preview, setPreview] = useState<PreviewData | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
@@ -157,30 +165,41 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
}
}, [id, isCalculatedForPreview, run?.total_gross, run?.total_tax, run?.total_avgifter])
// Every handler below releases actionLoading in a finally: the flag gates the
// header button, the progress rail and the employee table, so a rejected
// fetch that skipped the release froze the whole page until a reload.
async function handleAction(action: string, method: string = 'POST') {
setActionLoading(action)
const res = await fetch(`/api/salary/runs/${id}/${action}`, { method })
if (res.ok) {
// Optimistic: the status-transition endpoints return the updated run row.
// Merge it in immediately so the screen flips without waiting for the
// heavy detail refetch, then reconcile in the background. This is what
// makes "Till granskning" / "Godkänn" feel instant.
const payload = await res.json().catch(() => null)
if (payload?.data) {
setRun(prev => (prev ? { ...prev, ...payload.data } : prev))
try {
const res = await fetch(`/api/salary/runs/${id}/${action}`, { method })
if (res.ok) {
// Optimistic: the status-transition endpoints return the updated run row.
// Merge it in immediately so the screen flips without waiting for the
// heavy detail refetch, then reconcile in the background. This is what
// makes "Till granskning" / "Godkänn" feel instant.
const payload = await res.json().catch(() => null)
if (payload?.data) {
setRun(prev => (prev ? { ...prev, ...payload.data } : prev))
}
toast({ title: t('toast_status_updated') })
loadRun() // background reconcile - not awaited
return
}
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_status_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
} catch (err) {
toast({
title: t('toast_status_failed'),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
setActionLoading(null)
toast({ title: t('toast_status_updated') })
loadRun() // background reconcile - not awaited
return
}
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_status_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
setActionLoading(null)
}
// Approval is an authorization step. Missing bank details are an *overridable*
@@ -189,38 +208,46 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
// the user chooses "Godkänn ändå". The payment-file step still hard-blocks.
async function doApprove(force: boolean) {
setActionLoading('approve')
const res = await fetch(`/api/salary/runs/${id}/approve${force ? '?force=true' : ''}`, {
method: 'POST',
})
if (res.ok) {
setApproveOverride(null)
const payload = await res.json().catch(() => null)
if (payload?.data) {
setRun(prev => (prev ? { ...prev, ...payload.data } : prev))
try {
const res = await fetch(`/api/salary/runs/${id}/approve${force ? '?force=true' : ''}`, {
method: 'POST',
})
if (res.ok) {
setApproveOverride(null)
const payload = await res.json().catch(() => null)
if (payload?.data) {
setRun(prev => (prev ? { ...prev, ...payload.data } : prev))
}
toast({ title: t('toast_status_updated') })
loadRun() // background reconcile - not awaited
return
}
const result = await res.json().catch(() => ({}))
// Overridable → open the confirm dialog instead of toasting the error.
if (
!force &&
result?.code === 'SALARY_APPROVE_BANK_DETAILS_MISSING' &&
Array.isArray(result.details)
) {
setApproveOverride(result.details as string[])
return
}
setApproveOverride(null)
toast({
title: t('toast_status_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
} catch (err) {
setApproveOverride(null)
toast({
title: t('toast_status_failed'),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
setActionLoading(null)
toast({ title: t('toast_status_updated') })
loadRun() // background reconcile - not awaited
return
}
const result = await res.json().catch(() => ({}))
// Overridable → open the confirm dialog instead of toasting the error.
if (
!force &&
result?.code === 'SALARY_APPROVE_BANK_DETAILS_MISSING' &&
Array.isArray(result.details)
) {
setApproveOverride(result.details as string[])
setActionLoading(null)
return
}
setApproveOverride(null)
toast({
title: t('toast_status_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
setActionLoading(null)
}
// Recall an approval (approved → review). Approval is only an internal
@@ -255,18 +282,29 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
})
if (!ok) return
setActionLoading('delete')
const res = await fetch(`/api/salary/runs/${id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: t('toast_draft_deleted') })
router.push('/salary')
return
// No finally here: the success path navigates away and deliberately leaves
// the buttons disabled for the duration of the route change. Every path
// that stays on the page releases the flag.
try {
const res = await fetch(`/api/salary/runs/${id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: t('toast_draft_deleted') })
router.push('/salary')
return
}
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_delete_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
} catch (err) {
toast({
title: t('toast_delete_failed'),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
}
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_delete_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
setActionLoading(null)
}
@@ -274,41 +312,60 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
// RunHeader; this fires only after the user has confirmed there.
async function handleCorrect() {
setActionLoading('correct')
const res = await fetch(`/api/salary/runs/${id}/correct`, { method: 'POST' })
if (res.ok) {
const { data } = await res.json()
toast({ title: t('toast_correction_created'), description: t('toast_correction_description') })
router.push(`/salary/runs/${data.id}`)
return
// As in handleDelete: the success path navigates to the new run and keeps
// the buttons disabled meanwhile; every path that stays here releases.
try {
const res = await fetch(`/api/salary/runs/${id}/correct`, { method: 'POST' })
if (res.ok) {
const { data } = await res.json()
toast({ title: t('toast_correction_created'), description: t('toast_correction_description') })
router.push(`/salary/runs/${data.id}`)
return
}
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_correction_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
} catch (err) {
toast({
title: t('toast_correction_failed'),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
}
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_correction_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
setActionLoading(null)
}
async function handleAddEmployee(employeeId: string) {
setActionLoading('add-employee')
const res = await fetch(`/api/salary/runs/${id}/employees`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ employee_id: employeeId }),
})
if (res.ok) {
await loadRun()
toast({ title: t('toast_employee_added') })
} else {
const result = await res.json().catch(() => ({}))
try {
const res = await fetch(`/api/salary/runs/${id}/employees`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ employee_id: employeeId }),
})
if (res.ok) {
await loadRun()
toast({ title: t('toast_employee_added') })
} else {
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_add_employee_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
} catch (err) {
toast({
title: t('toast_add_employee_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
setActionLoading(null)
}
setActionLoading(null)
}
// Remove an employee from a draft run. The DELETE endpoint is draft-only and
@@ -322,21 +379,30 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
})
if (!ok) return
setActionLoading(`remove-${employeeId}`)
const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, {
method: 'DELETE',
})
if (res.ok) {
await loadRun()
toast({ title: t('toast_employee_removed') })
} else {
const result = await res.json()
try {
const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, {
method: 'DELETE',
})
if (res.ok) {
await loadRun()
toast({ title: t('toast_employee_removed') })
} else {
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_remove_employee_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
} catch (err) {
toast({
title: t('toast_remove_employee_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
setActionLoading(null)
}
setActionLoading(null)
}
// Edit this month's monthly salary for one employee (draft only). The engine
@@ -346,89 +412,127 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
const monthly = Number(raw.replace(/\s/g, '').replace(',', '.'))
if (!Number.isFinite(monthly) || monthly < 0 || monthly === previous) return
setActionLoading(`salary-${employeeId}`)
const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ monthly_salary: monthly }),
})
if (res.ok) {
await loadRun()
toast({ title: t('toast_salary_updated'), description: t('toast_salary_updated_hint') })
} else {
const result = await res.json()
try {
const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ monthly_salary: monthly }),
})
if (res.ok) {
await loadRun()
toast({ title: t('toast_salary_updated'), description: t('toast_salary_updated_hint') })
} else {
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_salary_update_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
} catch (err) {
toast({
title: t('toast_salary_update_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
setActionLoading(null)
}
setActionLoading(null)
}
async function handleCalculate() {
setActionLoading('calculate')
const res = await fetch(`/api/salary/runs/${id}/calculate`, { method: 'POST' })
if (res.ok) {
const payload = await res.json()
await loadRun()
const warnings = (payload.warnings as string[] | undefined) ?? []
if (warnings.length === 0) {
toast({ title: t('toast_calculation_done') })
} else {
for (const warning of warnings) {
toast({ title: t('toast_calculation_warning'), description: warning })
try {
const res = await fetch(`/api/salary/runs/${id}/calculate`, { method: 'POST' })
if (res.ok) {
const payload = await res.json().catch(() => ({}))
await loadRun()
const warnings = (payload.warnings as string[] | undefined) ?? []
if (warnings.length === 0) {
toast({ title: t('toast_calculation_done') })
} else {
for (const warning of warnings) {
toast({ title: t('toast_calculation_warning'), description: warning })
}
}
} else {
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_calculation_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
} else {
const result = await res.json()
} catch (err) {
toast({
title: t('toast_calculation_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
setActionLoading(null)
}
setActionLoading(null)
}
async function handlePreview() {
setActionLoading('preview')
const res = await fetch(`/api/salary/runs/${id}/preview`)
if (res.ok) {
const { data } = await res.json()
setPreview(data)
try {
const res = await fetch(`/api/salary/runs/${id}/preview`)
if (res.ok) {
const { data } = await res.json()
setPreview(data)
}
} catch {
// The preview is a read-only convenience; the auto-load effect retries on
// the next calculation. Nothing to report, but the flag must be released.
} finally {
setActionLoading(null)
}
setActionLoading(null)
}
async function handleSendPayslips() {
setActionLoading('payslips-send')
const res = await fetch(`/api/salary/runs/${id}/payslips/send`, { method: 'POST' })
if (res.ok) {
const { data } = await res.json()
await loadRun()
toast({
title: t('toast_payslips_sent'),
description: t('toast_payslips_sent_detail', {
sent: data.sent,
skipped: data.skipped,
}),
})
if (data.errors?.length) {
for (const err of data.errors as string[]) {
toast({ title: t('toast_payslip_error'), description: err, variant: 'destructive' })
try {
const res = await fetch(`/api/salary/runs/${id}/payslips/send`, { method: 'POST' })
if (res.ok) {
const { data } = await res.json()
await loadRun()
toast({
title: t('toast_payslips_sent'),
description: t('toast_payslips_sent_detail', {
sent: data.sent,
skipped: data.skipped,
}),
})
if (data.errors?.length) {
for (const err of data.errors as string[]) {
toast({ title: t('toast_payslip_error'), description: err, variant: 'destructive' })
}
}
} else {
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_payslips_send_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
} else {
const result = await res.json()
} catch (err) {
toast({
title: t('toast_payslips_send_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
setActionLoading(null)
}
setActionLoading(null)
}
// Bulk payslip archive. One PDF request per employee, so a single failure
// must not take the batch down - but it must not vanish either: the employer
// hands out what the ZIP contains and has no other signal that someone is
// missing from it. Every outcome is recorded per employee and reported by
// name; a short archive is never presented as a complete one.
async function handleBulkPayslipDownload() {
if (!run) return
setActionLoading('bulk_payslip')
@@ -436,24 +540,45 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
const { default: JSZip } = await import('jszip')
const zip = new JSZip()
const periodLabel = periodLabelOf(run)
let added = 0
const attempts: PayslipZipAttempt[] = []
for (const sre of employees) {
const employee = (sre as SalaryRunEmployee & {
employee?: { first_name: string; last_name: string }
}).employee
const res = await fetch(`/api/salary/runs/${id}/payslips/${sre.employee_id}/pdf`)
if (!res.ok) continue
const blob = await res.blob()
const name = employee
? `${employee.last_name}_${employee.first_name}`.replace(/[^A-Za-z0-9_-]/g, '_')
: sre.employee_id.slice(0, 8)
zip.file(`Lonespec_${periodLabel}_${name}.pdf`, blob)
added++
let ok = false
try {
const res = await fetch(`/api/salary/runs/${id}/payslips/${sre.employee_id}/pdf`)
if (res.ok) {
const blob = await res.blob()
const fileName = employee
? `${employee.last_name}_${employee.first_name}`.replace(/[^A-Za-z0-9_-]/g, '_')
: sre.employee_id.slice(0, 8)
zip.file(`Lonespec_${periodLabel}_${fileName}.pdf`, blob)
ok = true
}
} catch {
// A network-level failure for this one employee counts exactly like a
// non-ok response: missing from the archive, named in the report.
}
attempts.push({ name: payslipEmployeeLabel(sre.employee_id, employee), ok })
}
if (added === 0) {
const report = buildPayslipZipReport(attempts)
// Nothing to archive: no file is produced, so say so and stop.
if (report.outcome === 'empty') {
toast({ title: t('toast_payslips_download_empty'), variant: 'destructive' })
return
}
if (report.outcome === 'none') {
toast({
title: t('toast_payslips_download_empty'),
description: t('toast_payslips_download_none_detail', { total: report.total }),
variant: 'destructive',
})
return
}
const archive = await zip.generateAsync({ type: 'blob' })
const url = URL.createObjectURL(archive)
const a = document.createElement('a')
@@ -463,7 +588,32 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast({ title: t('toast_payslips_downloaded'), description: t('toast_payslips_downloaded_detail', { count: added }) })
// The toaster holds one toast at a time: a warning emitted next to a
// success toast is evicted and never rendered. So a partial archive gets
// a single destructive toast naming who is missing, not success + warning.
if (report.outcome === 'partial') {
const shown = report.missingShown.join(', ')
const names =
report.missingOverflow > 0
? t('toast_payslips_download_more', { names: shown, count: report.missingOverflow })
: shown
toast({
title: t('toast_payslips_download_partial'),
description: t('toast_payslips_download_partial_detail', {
added: report.added,
total: report.total,
names,
}),
variant: 'destructive',
})
return
}
toast({
title: t('toast_payslips_downloaded'),
description: t('toast_payslips_downloaded_detail', { count: report.added }),
})
} catch (err) {
toast({
title: t('toast_zip_failed'),
@@ -475,33 +625,44 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
}
}
// actionLoading gates this button, the progress rail and the employee table,
// so it has to be released on every path. A rejected fetch (or a blob/read
// failure) used to leave it set and freeze the page until a full reload.
async function handleDownloadAgi() {
if (!run) return
setActionLoading('agi-download')
const res = await fetch(`/api/salary/runs/${id}/agi/xml`)
if (!res.ok) {
const result = await res.json().catch(() => ({ error: t('toast_agi_failed') }))
try {
const res = await fetch(`/api/salary/runs/${id}/agi/xml`)
if (!res.ok) {
const result = await res.json().catch(() => ({ error: t('toast_agi_failed') }))
toast({
title: t('toast_agi_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const compactPeriod = `${run.period_year}${String(run.period_month).padStart(2, '0')}`
const a = document.createElement('a')
a.href = url
a.download = `AGI_${compactPeriod}.xml`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
await loadRun()
toast({ title: t('toast_agi_downloaded') })
} catch (err) {
toast({
title: t('toast_agi_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
setActionLoading(null)
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const compactPeriod = `${run.period_year}${String(run.period_month).padStart(2, '0')}`
const a = document.createElement('a')
a.href = url
a.download = `AGI_${compactPeriod}.xml`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
await loadRun()
toast({ title: t('toast_agi_downloaded') })
setActionLoading(null)
}
if (loading) {
@@ -536,6 +697,11 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
// Real AGI filing state: run-row timestamps + the extension's submission
// record. Falls back gracefully when the extension is unavailable.
const agiState = deriveAgiFilingState(run, agiSubmission)
// The submission record is cached per PERIOD, but a corrected month holds two
// runs and each files its own complete replacement declaration, so each gets
// its own kvittens from Skatteverket. Resolving the record against this run
// keeps the rail from labelling one run's step with the sibling's receipt.
const agiKvittensnummer = resolveRunAgiKvittensnummer(run, agiSubmission)
// Advancing a draft to review. For a nollkörning confirm first: an empty
// declaration is filed to Skatteverket, which should be deliberate.
@@ -586,7 +752,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
isCalculated={isCalculated}
noPayout={noPayout}
agiState={agiState}
agiKvittensnummer={agiSubmission?.kvittensnummer ?? null}
agiKvittensnummer={agiKvittensnummer}
canWrite={canWrite}
actionLoading={actionLoading}
primaryAction={primaryAction}
+28 -4
View File
@@ -188,7 +188,15 @@ export default function SkattekontoPage() {
}
return
}
throw new Error(json.error || 'Synk misslyckades')
// Map the parsed body plus the status, never `new Error(json.error)`:
// the Error constructor stringifies a non-string body field, and the
// mapper would discard the route's own Swedish reason.
toast({
title: 'Synk misslyckades',
description: getUserErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return
}
setReconnectMessage(null)
toast({
@@ -216,7 +224,12 @@ export default function SkattekontoPage() {
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Bokföring misslyckades')
toast({
title: 'Kunde inte bokföra',
description: getUserErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({
title: 'Utkast skapat',
@@ -245,7 +258,13 @@ export default function SkattekontoPage() {
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Kunde inte söka kandidater')
toast({
title: 'Kunde inte hämta kandidater',
description: getUserErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
setMatchOpenFor(null)
return
}
setMatchCandidates(json.data.candidates as MatchCandidate[])
} catch (err) {
@@ -274,7 +293,12 @@ export default function SkattekontoPage() {
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Matchning misslyckades')
toast({
title: 'Kunde inte koppla transaktionen',
description: getUserErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({ title: 'Transaktion kopplad till verifikat' })
setMatchOpenFor(null)
+9 -1
View File
@@ -1824,7 +1824,15 @@ export default function TransactionsPage() {
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Bokföring misslyckades')
// Map the parsed body plus the status, never `new Error(json.error)`:
// the Error constructor stringifies a non-string body field, and the
// mapper would discard the route's own Swedish reason.
toast({
title: 'Kunde inte bokföra',
description: getErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({
title: 'Utkast skapat',
+27 -11
View File
@@ -5,6 +5,7 @@ import { getActiveCompanyId } from '@/lib/company/context'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
import { employeeFieldValue, loadActiveEmployeeCount } from '@/lib/agent/composer/employee-facts'
import AgentOnboarding from '@/components/onboarding/agent/AgentOnboarding'
export const dynamic = 'force-dynamic'
@@ -41,12 +42,13 @@ export default async function AgentOnboardingPage() {
{ data: profile },
{ data: existingProfile },
{ data: atomRows },
activeEmployees,
hdrs,
] = await Promise.all([
supabase
.from('company_settings')
.select(
'is_sandbox, city, address_line1, postal_code, f_skatt, vat_registered, moms_period, fiscal_year_start_month, employee_count, has_employees',
'is_sandbox, city, address_line1, postal_code, f_skatt, vat_registered, moms_period, fiscal_year_start_month, employer_registered, pays_salaries',
)
.eq('company_id', companyId)
.maybeSingle(),
@@ -61,6 +63,9 @@ export default async function AgentOnboardingPage() {
.select('id, title')
.eq('is_active', true)
.is('parent_atom_id', null), // skill titles only; reference children never appear as profile chips
// The only source that actually knows the headcount. Cheap: a head-only
// count served by idx_employees_active, and it rides the same batch.
loadActiveEmployeeCount(supabase, companyId).catch(() => null),
headers(),
])
@@ -101,7 +106,7 @@ export default async function AgentOnboardingPage() {
const firstName = profile?.full_name?.split(' ')[0] ?? null
// Pre-render-friendly snapshot of company info: used to seed Phase B fields
// before the stream completes so the layout doesn't jump.
const initialFields = buildInitialFields(company, settings)
const initialFields = buildInitialFields(company, settings, activeEmployees)
const atomTitles: Record<string, string> = {}
for (const row of (atomRows ?? []) as { id: string; title: string }[]) {
@@ -143,8 +148,12 @@ interface CompanySettingsForPhaseB {
vat_registered: boolean | null
moms_period: string | null
fiscal_year_start_month: number | null
employee_count: number | null
has_employees: boolean | null
// The employer facts that exist on company_settings. This select used to name
// `employee_count` and `has_employees`, neither of which is a column on this
// table, so PostgREST rejected the select whole (42703) and every field above
// arrived null as well.
employer_registered: boolean | null
pays_salaries: boolean | null
}
function buildInitialFields(
@@ -155,6 +164,7 @@ function buildInitialFields(
tic_snapshot: Record<string, unknown> | null
},
settings: CompanySettingsForPhaseB | null,
activeEmployees: number | null,
): InitialFields {
const tic = (company.tic_snapshot ?? null) as Record<string, unknown> | null
const entityLabel =
@@ -173,7 +183,7 @@ function buildInitialFields(
let fSkatt: string | null = null
let vatPeriod: string | null = null
let fiscalPeriod: string | null = null
let employees: string | null = null
let ticEmployeeRange: string | null = null
if (tic) {
const sni = (tic.sniCodes as { code: string; name: string }[] | undefined) ?? []
@@ -184,7 +194,7 @@ function buildInitialFields(
if (addr?.city) city = addr.city
const reg = tic.registration as { fTax?: boolean } | undefined
if (reg) fSkatt = reg.fTax ? 'Aktivt' : 'Saknas'
if (tic.employeeRange) employees = tic.employeeRange as string
if (tic.employeeRange) ticEmployeeRange = tic.employeeRange as string
// v2 caches `fiscalYear` (current fiscal-year configuration) on the
// snapshot. Prefer it over the user-entered value below so onboarding
// can show the registered fiscal period from Bolagsverket without
@@ -208,13 +218,19 @@ function buildInitialFields(
if (!fiscalPeriod && settings.fiscal_year_start_month != null) {
fiscalPeriod = fiscalYearLabel(settings.fiscal_year_start_month)
}
if (!employees && settings.employee_count != null) {
employees = String(settings.employee_count)
} else if (!employees && settings.has_employees != null) {
employees = settings.has_employees ? 'Ja' : 'Nej'
}
}
// "Anställda": the live headcount if there is one, else Bolagsverket's
// interval, else the attested employer facts, else null so the row shows its
// placeholder instead of a number nobody entered. See employee-facts.ts for
// why pays_salaries === false is not treated as "Nej".
const employees = employeeFieldValue({
activeEmployees,
ticEmployeeRange,
employerRegistered: settings?.employer_registered ?? null,
paysSalaries: settings?.pays_salaries ?? null,
})
return {
entity_type_label: entityLabel,
sni_codes: sniCodes,
+30 -2
View File
@@ -24,12 +24,20 @@ interface InvoiceData {
previousResponse: 'marked_paid' | 'disputed' | null
// Dröjsmålsränta + lagstadgad påminnelseavgift (Räntelagen §6, Lag 1981:739).
// Default to 0 for older reminders sent before the surcharge feature shipped.
//
// interestAmount is a share of the invoice total, so it is in `currency`.
// The påminnelseavgift is a fixed krona amount fixed by statute and booked
// 1510/3990 in SEK, so it is quoted in `reminderFeeCurrency` (always 'SEK')
// and is only inside `totalDue` when the invoice itself is in SEK. Otherwise
// it arrives as `feeDueSeparately` and is shown as its own amount.
interestAmount: number
interestRate: number
interestFromDate: string | null
interestDays: number | null
reminderFee: number
reminderFeeCurrency?: string
totalDue: number
feeDueSeparately?: number
}
export default function InvoiceActionPage({ params }: { params: Promise<{ token: string }> }) {
@@ -151,6 +159,12 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
const now = new Date()
const daysOverdue = Math.floor((now.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
// The statutory påminnelseavgift is a krona amount (Lag 1981:739). On a
// foreign-currency invoice it is never converted or relabelled: it is shown
// as its own SEK amount next to the invoice-currency total.
const feeCurrency = invoice.reminderFeeCurrency || 'SEK'
const feeDueSeparately = invoice.feeDueSeparately ?? 0
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white py-12 px-4">
<div className="max-w-lg mx-auto">
@@ -212,7 +226,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
{invoice.reminderFee > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Påminnelseavgift</span>
<span>{formatCurrency(invoice.reminderFee, invoice.currency)}</span>
<span>{formatCurrency(invoice.reminderFee, feeCurrency)}</span>
</div>
)}
<div className="border-t border-destructive/20 pt-2 mt-2" />
@@ -221,10 +235,24 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
<p className="text-2xl font-bold text-destructive tabular-nums">
{formatCurrency(invoice.totalDue || invoice.total, invoice.currency)}
{feeDueSeparately > 0 && (
<>
{' + '}
{formatCurrency(feeDueSeparately, feeCurrency)}
</>
)}
</p>
{(invoice.interestAmount > 0 || invoice.reminderFee > 0) && (
<p className="text-xs text-muted-foreground">
Att betala (inkl. dröjsmålsränta och påminnelseavgift)
{feeDueSeparately > 0
? 'Att betala (inkl. dröjsmålsränta) plus påminnelseavgift i SEK'
: 'Att betala (inkl. dröjsmålsränta och påminnelseavgift)'}
</p>
)}
{feeDueSeparately > 0 && (
<p className="text-xs text-muted-foreground">
Påminnelseavgiften är lagstadgad (Lag 1981:739) och anges i svenska kronor.
Den räknas inte om till fakturans valuta utan betalas som ett separat belopp i SEK.
</p>
)}
</div>
+34 -9
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { sparsePatchBody } from '@/lib/api/sparse-patch'
import { UpdateAccountSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -35,14 +36,31 @@ export const DELETE = withRouteContext(
}
// Check if the account is referenced in THIS company's journal entries.
// journal_entry_lines has no company_id column, so scope via the parent
// entry — a user can be a member of several companies, and another
// company's usage of the same BAS number must not block deletion here.
const { count } = await supabase
.from('journal_entry_lines')
.select('id, journal_entries!inner(company_id)', { count: 'exact', head: true })
.eq('journal_entries.company_id', companyId)
.eq('account_number', number)
// journal_entry_lines has no company_id column, so the scope has to come
// from the parent entry: a user can be a member of several companies, and
// another company's usage of the same BAS number must not block deletion
// here.
//
// This used to be a `journal_entries!inner(company_id)` head+count query.
// PostgREST compiles that embed into a correlated LATERAL join, so the
// count walked the ENTIRE journal_entry_lines table across all tenants to
// answer a single-account question (see lib/bookkeeping/entry-lines.ts).
// fetchEntryLines cannot replace it (it returns rows, not a count), so the
// count moves into SQL: get_account_usage_counts is the same
// company-scoped aggregate the kontoplan usage column and the prune dialog
// already run (migration 20260704110000, covering index 20260706120000),
// and it counts lines on ALL entry statuses, exactly like the old query.
const { data: usage, error: usageError } = await supabase.rpc('get_account_usage_counts', {
p_company_id: companyId,
})
if (usageError) {
return NextResponse.json({ error: getUserErrorMessage(usageError) }, { status: 500 })
}
const count = ((usage ?? []) as { account_number: string; usage_count: number }[]).find(
(row) => row.account_number === number,
)?.usage_count
if (count && count > 0) {
return NextResponse.json(
@@ -72,7 +90,14 @@ export const PUT = withRouteContext(
const { number } = await params
const { supabase, companyId, log } = ctx
const validation = await validateBody(request, UpdateAccountSchema, {
// The body is spread straight into .update(), so only the fields the
// caller actually named may reach it. UpdateAccountSchema carries no
// .default() today, so sparsePatchBody is a no-op here: it is the
// structural guarantee that adding one later cannot make a PUT that
// renames an account also rewrite its VAT code or SRU mapping. An
// explicit null (clearing sru_code, default_vat_code, default_vat_rate)
// still survives.
const validation = await validateBody(request, sparsePatchBody(UpdateAccountSchema), {
log,
operation: 'bookkeeping.accounts.update',
})
@@ -218,10 +218,10 @@ describe('POST /api/bookkeeping/accounts', () => {
})
describe('DELETE /api/bookkeeping/accounts/[number]', () => {
it('scopes the usage check to the company via the journal_entries join', async () => {
it('scopes the usage check to the company via the usage-counts RPC', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: { id: 'acc-1', is_system_account: false } }, // account fetch
{ count: 0 }, // usage count
{ data: [{ account_number: '4010', usage_count: 7 }] }, // usage counts (not 5010)
{ data: null }, // delete
])
auth(supabase)
@@ -231,16 +231,18 @@ describe('DELETE /api/bookkeeping/accounts/[number]', () => {
)
expect(status).toBe(200)
// The company scope is an RPC argument now, not a filter on an embed:
// another company's use of the same BAS number can never be counted here.
const rpcCalls = calls.filter((c) => c.method === 'rpc').map((c) => c.args)
expect(rpcCalls).toContainEqual(['get_account_usage_counts', { p_company_id: 'company-1' }])
const selectArgs = calls.filter((c) => c.method === 'select').map((c) => c.args[0])
expect(selectArgs).toContain('id, journal_entries!inner(company_id)')
const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args)
expect(eqCalls).toContainEqual(['journal_entries.company_id', 'company-1'])
expect(selectArgs).not.toContain('id, journal_entries!inner(company_id)')
})
it('refuses deleting an account used in this company with 400', async () => {
const { supabase } = createCapturingSupabase([
{ data: { id: 'acc-1', is_system_account: false } },
{ count: 3 },
{ data: [{ account_number: '5010', usage_count: 3 }] },
])
auth(supabase)
@@ -321,6 +323,55 @@ describe('PUT /api/bookkeeping/accounts/[number]', () => {
}
expect(updateArg?.default_vat_rate).toBe(0)
})
// The body is spread straight into .update(), so the write set must be
// exactly what the caller named. UpdateAccountSchema carries no .default()
// today; these two lock the property in so adding one cannot turn a rename
// into a silent rewrite of the VAT code and SRU mapping.
it('writes only the field the caller named', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: { account_number: '5010', account_name: 'Nytt namn' } },
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
method: 'PUT',
body: { account_name: 'Nytt namn' },
})
expect((await parseJsonResponse(await PUT(req, numberParams))).status).toBe(200)
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as Record<string, unknown>
expect(Object.keys(updateArg)).toEqual(['account_name'])
})
it('keeps an explicit null so sru_code can be cleared', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: { account_number: '5010', sru_code: null } },
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
method: 'PUT',
body: { sru_code: null },
})
expect((await parseJsonResponse(await PUT(req, numberParams))).status).toBe(200)
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as Record<string, unknown>
expect(updateArg).toEqual({ sru_code: null })
})
it('drops unknown keys instead of forwarding them to the update', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: { account_number: '5010' } },
])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
method: 'PUT',
body: { account_name: 'Nytt namn', is_system_account: true, company_id: 'other' },
})
expect((await parseJsonResponse(await PUT(req, numberParams))).status).toBe(200)
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as Record<string, unknown>
expect(Object.keys(updateArg)).toEqual(['account_name'])
})
})
describe('POST /api/bookkeeping/accounts/activate', () => {
@@ -153,6 +153,8 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toBeDefined()
// Nothing precedes the first period, so there is nothing to advise about.
expect(body.warnings).toBeUndefined()
})
it('rejects overlapping periods', async () => {
@@ -168,6 +170,22 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
expect(body.error).toMatch(/Overlaps/)
})
// Dropping the "prior year must be locked" gate must not open an overlap
// hole: a period colliding with a still-open prior year is still a 409.
it('rejects an overlapping period even when the prior year is still open', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
openPeriods: [{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
overlapping: [{ id: 'p1', name: 'FY 2025' }],
})
// Starts inside FY 2025.
const req = createMockRequest({ name: 'FY 2025 dup', period_start: '2025-07-01', period_end: '2026-06-30' })
const res = await POST(req)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error).toMatch(/Overlaps/)
})
it('rejects forward period with wrong start date', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: true }],
@@ -179,22 +197,81 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
expect(body.error).toMatch(/must start on 2026-01-01/)
})
it('rejects forward period when an unlocked open period exists and returns it as a blocking period', async () => {
// Regression (BFL 5 kap 2 §): this used to be a 409. One unbooked December
// bank transaction makes FY 2025 unlockable (lockPeriod refuses, correctly),
// and the old guard then refused to create FY 2026, so ALL bookkeeping in the
// new year stopped, while BFL 5 kap 2 § requires the new year's
// affärshändelser to be booked "så snart det kan ske" (senast månaden efter)
// and BFL 6 kap gives the bokslut 6 months. Both bind at once: the new
// räkenskapsår must be creatable with the prior one still fully open.
it('creates the new räkenskapsår while the prior year is still fully open', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
openPeriods: [{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
openPeriods: [{ id: 'p1', name: 'Räkenskapsår 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
overlapping: [],
})
const req = createMockRequest({
name: 'Räkenskapsår 2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
})
const req = createMockRequest({ name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' })
const res = await POST(req)
expect(res.status).toBe(409)
expect(res.status).toBe(200)
const body = await res.json()
// Canonical envelope with a machine code + the blocking periods so the
// dialog can offer to lock them inline.
expect(body.error.code).toBe('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS')
expect(body.error.message).toMatch(/låsa föregående räkenskapsår/)
expect(body.error.details.blockingPeriods).toEqual([
{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
])
expect(body.data).toBeDefined()
// The open prior year survives only as a non-blocking advisory.
expect(body.warnings).toHaveLength(1)
expect(body.warnings[0].code).toBe('PRIOR_FISCAL_YEAR_STILL_OPEN')
expect(body.warnings[0].message).toContain('Räkenskapsår 2025')
})
// The advisory must be actionable and must not dead-end: it states that the
// new year is bookable now and that the IB arrives with the bokslut, rather
// than demanding a lock the user may be unable to perform.
it('advisory tells the user booking may continue and that IB follows the bokslut', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
openPeriods: [{ id: 'p1', name: 'Räkenskapsår 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
overlapping: [],
})
const req = createMockRequest({
name: 'Räkenskapsår 2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
})
const res = await POST(req)
const body = await res.json()
expect(body.warnings[0].message).toMatch(/bokföra i det direkt/)
expect(body.warnings[0].message).toMatch(/Ingående balanser bokförs automatiskt/)
// No wording that orders the user to lock the prior year first.
expect(body.warnings[0].message).not.toMatch(/måste låsa/)
})
// Every still-open prior year is named, not just the immediate predecessor:
// the user needs to know the full set whose UB is still provisional.
it('names every still-open prior räkenskapsår in the single advisory', async () => {
buildMockSupabase({
allPeriods: [
{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false },
{ id: 'p2', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false },
],
openPeriods: [
{ id: 'p1', name: 'Räkenskapsår 2024', period_start: '2024-01-01', period_end: '2024-12-31' },
{ id: 'p2', name: 'Räkenskapsår 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
],
overlapping: [],
})
const req = createMockRequest({
name: 'Räkenskapsår 2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
})
const res = await POST(req)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.warnings).toHaveLength(1)
expect(body.warnings[0].message).toContain('Räkenskapsår 2024')
expect(body.warnings[0].message).toContain('Räkenskapsår 2025')
})
// Regression: BFL 6 kap allows löpande bokföring of the new year in parallel
@@ -232,10 +309,14 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toBeDefined()
// Effectively locked: nothing to advise about.
expect(body.warnings).toBeUndefined()
})
// Partial coverage: lock-through covers only part of the period: must still block.
it('rejects forward period creation when company-wide lock only partially covers prior period', async () => {
// Partial coverage: lock-through covers only part of the period, so the prior
// year still counts as open. Creation proceeds either way; only the advisory
// distinguishes the two states.
it('advises but does not block when company-wide lock only partially covers prior period', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }],
openPeriods: [{ id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' }],
@@ -244,12 +325,11 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
})
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
const res = await POST(req)
expect(res.status).toBe(409)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.error.code).toBe('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS')
expect(body.error.details.blockingPeriods).toEqual([
{ id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' },
])
expect(body.warnings).toHaveLength(1)
expect(body.warnings[0].code).toBe('PRIOR_FISCAL_YEAR_STILL_OPEN')
expect(body.warnings[0].message).toContain('FY 2024')
})
it('allows backward period creation', async () => {
@@ -283,6 +363,10 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
const res = await POST(req)
expect(res.status).toBe(200)
// The advisory is about the NEXT year's ingående balanser, so it belongs to
// appends only. Backfilling an earlier year says nothing about IB.
const body = await res.json()
expect(body.warnings).toBeUndefined()
})
// Regression (2026-06-16): a company with FY 2024 + FY 2026 but no
@@ -356,6 +440,9 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
const res = await POST(req)
expect(res.status).toBe(200)
// Gap fill is a backfill too: no IB advisory.
const body = await res.json()
expect(body.warnings).toBeUndefined()
})
it('rejects invalid period duration (> 18 months)', async () => {
+71 -27
View File
@@ -3,11 +3,19 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import { validateBody } from '@/lib/api/validate'
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Response shapes are legacy `{ error: string }` (plus one envelope code for
// the blocked-by-open-periods dialog) — kept for the räkenskapsår UI.
// Response shapes are legacy `{ error: string }`, kept for the räkenskapsår UI.
// Success may carry a non-blocking `warnings` array (same shape as the invoice
// booking routes: `{ code, message }`).
/** A prior räkenskapsår that is still fully open when the next one is created. */
interface OpenPriorPeriod {
id: string
name: string
period_start: string
period_end: string
}
export const GET = withRouteContext('period.list', async (_request, ctx) => {
const { supabase, companyId } = ctx
@@ -58,6 +66,11 @@ export const POST = withRouteContext(
const predecessor = [...sortedPeriods].reverse().find((p) => p.period_end < body.period_start) ?? null
const successor = sortedPeriods.find((p) => p.period_start > body.period_end) ?? null
// Prior räkenskapsår that are still fully open at the moment the next one is
// appended. Advisory only (see the isAppend block below), attached to the
// success response so the UI can nudge without gating.
let openPriorPeriods: OpenPriorPeriod[] = []
if (sortedPeriods.length > 0) {
const earliest = sortedPeriods[0]
const latest = sortedPeriods[sortedPeriods.length - 1]
@@ -118,17 +131,38 @@ export const POST = withRouteContext(
}
}
// The "prior year must be locked" guard applies only when appending a new
// latest räkenskapsår, not when backfilling a gap between existing years.
// A gap fill is a backfill (like prepend) and must not be blocked by an
// open neighbouring year.
// An open prior räkenskapsår is INFORMATION, never a gate.
//
// This used to hard-refuse (409) a new latest räkenskapsår while any
// prior period was still fully open, on the theory that the prior year
// "must at least be locked so nothing is back-posted into a year you've
// moved on from". That theory has no support in BFL and inverts two rules
// that bind simultaneously:
// - BFL 5 kap 2 §: kontanta in-/utbetalningar bokförs senast påföljande
// arbetsdag, övriga affärshändelser "så snart det kan ske" (per BFNAR
// 2013:2 senast månaden efter). Booking January REQUIRES a
// räkenskapsår covering January, within weeks.
// - BFL 6 kap: årsbokslut/årsredovisning ska upprättas inom 6 månader
// efter räkenskapsårets utgång (AB filing 7 månader). The prior year
// is therefore legitimately unfinished, and must stay unlocked so
// bokslutsposter (periodiseringar, avskrivningar, skatt) can be
// posted into it, for months into the new year.
// Running the two years in parallel is not a tolerated edge case, it is
// the normal and legally required state during that window. The old guard
// made one unbooked December bank transaction (which blocks lockPeriod,
// correctly, per BFL 5 kap 2 §) freeze ALL bookkeeping in the new year.
//
// What genuinely protects the prior year is unchanged and lives
// elsewhere: period locked_at (enforce_period_lock) and
// company_settings.bookkeeping_locked_through (enforce_company_lock_date),
// both set deliberately by the user. Creating the next räkenskapsår does
// not write a single row into the prior one.
//
// What IS kept from the old check is its detection, downgraded to an
// advisory on the success response: an open prior year means its UB is
// not final, so the new year's ingående balanser are not posted yet.
// A period counts as "effectively locked" if its own locked_at is set, OR
// company_settings.bookkeeping_locked_through covers its end date (the
// enforce_company_lock_date trigger blocks any entry on/before that date).
// BFL 6 kap allows löpande bokföring of the new year in parallel with
// bokslut work on the prior year, so locked-but-not-closed prior periods
// must not block creating the next räkenskapsår.
// company_settings.bookkeeping_locked_through covers its end date.
if (isAppend) {
const { data: openPeriods } = await supabase
.from('fiscal_periods')
@@ -149,20 +183,12 @@ export const POST = withRouteContext(
(p) => !(lockThrough && p.period_end <= lockThrough)
)
if (trulyOpen.length > 0) {
// Hand the blocking periods (id + name + dates) to the client so the
// "Skapa räkenskapsår" dialog can offer to lock them inline and retry,
// instead of dead-ending the user on a message they can't act on.
const blockingPeriods = trulyOpen.map((p) => ({
id: p.id,
name: p.name,
period_start: p.period_start,
period_end: p.period_end,
}))
return errorResponseFromCode('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS', log, {
details: { blockingPeriods },
})
}
openPriorPeriods = trulyOpen.map((p) => ({
id: p.id,
name: p.name,
period_start: p.period_start,
period_end: p.period_end,
}))
}
}
}
@@ -231,7 +257,25 @@ export const POST = withRouteContext(
}
}
return NextResponse.json({ data })
// Non-blocking advisory: the new year exists and is bookable right now, but
// its ingående balanser are still pending because the prior year's bokslut
// has not run. Every action named here is reachable, so this never dead-ends:
// the user can book in the new year immediately and the IB lands by itself
// when the bokslut for the prior year is executed (executeYearEndClosing
// reuses an already-created next period and posts the IB verifikat into it).
const warnings: Array<{ code: string; message: string }> = []
if (openPriorPeriods.length > 0) {
const names = openPriorPeriods.map((p) => p.name).join(', ')
warnings.push({
code: 'PRIOR_FISCAL_YEAR_STILL_OPEN',
message:
`Räkenskapsåret är skapat och du kan bokföra i det direkt. ${names} är fortfarande öppet, ` +
'vilket är normalt medan bokslutet pågår: du får bokföra i båda åren samtidigt. ' +
'Ingående balanser bokförs automatiskt när bokslutet för föregående år körs.',
})
}
return NextResponse.json(warnings.length > 0 ? { data, warnings } : { data })
},
{ requireWrite: true },
)
@@ -70,7 +70,11 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Validation failed')
// Inverted from `toBe('Validation failed')`: the constant was the bug.
// `error` now names the offending field so a UI reading only `error` is
// actionable.
expect(body.error).toMatch(/^Valideringsfel: /)
expect(body.error).toContain('lines')
})
it('returns 400 when lines array is empty', async () => {
@@ -82,7 +86,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Validation failed')
expect(body.error).toMatch(/^Valideringsfel: /)
expect(body.error).toContain('At least two lines are required for double-entry')
})
it('returns reversal and corrected entries on success', async () => {
@@ -155,7 +160,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Validation failed')
expect(body.error).toMatch(/^Valideringsfel: /)
expect(body.error).toContain('description')
expect(mockCorrectEntry).not.toHaveBeenCalled()
})
@@ -67,7 +67,9 @@ describe('POST /api/bookkeeping/journal-entries/[id]/recordate', () => {
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Validation failed')
// Inverted from `toBe('Validation failed')`: the constant was the bug.
expect(body.error).toMatch(/^Valideringsfel: /)
expect(body.error).toContain('new_entry_date')
})
it('returns 400 when new_entry_date is not an ISO date', async () => {
@@ -79,7 +81,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/recordate', () => {
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Validation failed')
expect(body.error).toMatch(/^Valideringsfel: /)
expect(body.error).toContain('new_entry_date')
})
it('returns reversal and corrected entries on success', async () => {
+21 -2
View File
@@ -7,6 +7,17 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-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 }> }) => {
@@ -72,8 +83,14 @@ export const PATCH = withRouteContext(
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { requestId })
}
// The masked sentinel counts as "field not supplied": it carries no new
// value, so it must not be validated, stored or treated as a clear.
const personalNumberSubmitted =
body.personal_number !== undefined &&
!(typeof body.personal_number === 'string' && MASKED_PERSONAL_NUMBER.test(body.personal_number))
const effectiveType = body.customer_type ?? existing.customer_type
if (body.personal_number && effectiveType !== 'individual') {
if (personalNumberSubmitted && body.personal_number && effectiveType !== 'individual') {
return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED', opLog, { requestId })
}
@@ -91,7 +108,9 @@ export const PATCH = withRouteContext(
if (body.country !== undefined) updateData.country = body.country
if (body.org_number !== undefined) updateData.org_number = body.org_number
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
if (body.personal_number !== undefined) {
if (personalNumberSubmitted) {
// Stored as ciphertext; customers_personal_number_check accepts that
// shape only (20260726110000).
updateData.personal_number = encryptCustomerPersonalNumber(body.personal_number)
} else if (body.customer_type !== undefined && effectiveType !== 'individual') {
updateData.personal_number = null
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventBus } from '@/lib/events'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
const captured: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] }
let queryResult: { data: unknown; error: unknown } = { data: null, error: null }
@@ -51,6 +51,23 @@ import { PATCH } from '../[id]/route'
type CustomerWrite = { personal_number?: string | null }
// Synthetic personnummer, never a real one.
const PERSONAL_NUMBER = '19900101-1234'
const MASKED = '********-1234'
/**
* The shape customers_personal_number_check accepts as of 20260726110000:
* lowercase hex, 76 to 255 chars (24 iv + 32 auth tag + >= 20 ciphertext).
* Asserting against it is the point of these tests. The routes encrypt before
* writing, and until 20260726110000 the column still demanded the plaintext
* personnummer format below, so every write was rejected by Postgres while
* this mocked suite passed. Pinning both directions is what makes the unit
* test able to catch that mismatch; tests/pg/customers-personal-number-
* ciphertext.pg.test.ts proves the constraint itself.
*/
const CIPHERTEXT_SHAPE = /^[0-9a-f]{76,255}$/
const OLD_PLAINTEXT_CHECK = /^(\d{6}|\d{8})[-+]?\d{4}$/
describe('personal_number on customer routes', () => {
const routeParams = { params: Promise.resolve({ id: 'customer-1' }) }
@@ -99,12 +116,14 @@ describe('personal_number on customer routes', () => {
})
it('stores the personal number when creating a private customer', async () => {
// What the insert returns is what the DB would hold: ciphertext.
const stored = encryptPersonnummer(PERSONAL_NUMBER)
queryResult = {
data: {
id: 'customer-1',
name: 'Anna Andersson',
customer_type: 'individual',
personal_number: '19900101-1234',
personal_number: stored,
},
error: null,
}
@@ -115,7 +134,7 @@ describe('personal_number on customer routes', () => {
body: {
name: 'Anna Andersson',
customer_type: 'individual',
personal_number: '19900101-1234',
personal_number: PERSONAL_NUMBER,
},
}),
{ params: Promise.resolve({}) },
@@ -123,18 +142,25 @@ describe('personal_number on customer routes', () => {
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
expect(status).toBe(200)
const encrypted = (captured.insert[0] as CustomerWrite).personal_number as string
expect(encrypted).not.toBe('19900101-1234')
expect(decryptPersonnummer(encrypted)).toBe('19900101-1234')
expect(body.data.personal_number).toBe('********-1234')
const written = (captured.insert[0] as CustomerWrite).personal_number as string
// The value must be storable: Postgres accepts ciphertext shape only.
expect(written).toMatch(CIPHERTEXT_SHAPE)
expect(written).not.toMatch(OLD_PLAINTEXT_CHECK)
// ...and it must still be the customer's personnummer.
expect(written).not.toBe(PERSONAL_NUMBER)
expect(decryptPersonnummer(written)).toBe(PERSONAL_NUMBER)
// Read back masked: no read path returns the personnummer itself.
expect(body.data.personal_number).toBe(MASKED)
})
it('updates the personal number for an existing private customer', async () => {
const stored = encryptPersonnummer('900101-1234')
queryResult = {
data: {
id: 'customer-1',
customer_type: 'individual',
personal_number: '900101-1234',
personal_number: stored,
},
error: null,
}
@@ -147,10 +173,79 @@ describe('personal_number on customer routes', () => {
routeParams,
)
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
expect(status).toBe(200)
const written = (captured.update[0] as CustomerWrite).personal_number as string
expect(written).toMatch(CIPHERTEXT_SHAPE)
expect(written).not.toMatch(OLD_PLAINTEXT_CHECK)
expect(written).not.toBe('900101-1234')
expect(decryptPersonnummer(written)).toBe('900101-1234')
expect(body.data.personal_number).toBe(MASKED)
})
it('keeps the stored personal number when the masked value is sent back', async () => {
queryResult = {
data: {
id: 'customer-1',
customer_type: 'individual',
name: 'Anna A',
personal_number: encryptPersonnummer(PERSONAL_NUMBER),
},
error: null,
}
// A client that PATCHes back the customer it just read submits the mask.
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { name: 'Anna A', personal_number: MASKED },
}),
routeParams,
)
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
expect(status).toBe(200)
// Neither stored literally nor cleared: the column is left untouched.
expect(captured.update[0]).not.toHaveProperty('personal_number')
expect(body.data.personal_number).toBe(MASKED)
})
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' },
error: null,
}
// Switching type away from individual while echoing the mask clears the
// column, and must not trip the "not allowed for businesses" guard.
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { customer_type: 'swedish_business', personal_number: MASKED },
}),
routeParams,
)
expect(response.status).toBe(200)
const encrypted = (captured.update[0] as CustomerWrite).personal_number as string
expect(encrypted).not.toBe('900101-1234')
expect(decryptPersonnummer(encrypted)).toBe('900101-1234')
expect((captured.update[0] as CustomerWrite).personal_number).toBeNull()
})
it('rejects the masked value on create, where there is nothing to preserve', async () => {
const response = await POST(
createMockRequest('/api/customers', {
method: 'POST',
body: {
name: 'Anna Andersson',
customer_type: 'individual',
personal_number: MASKED,
},
}),
{ params: Promise.resolve({}) },
)
expect(response.status).toBe(400)
expect(captured.insert).toHaveLength(0)
})
it('clears the personal number when null is sent', async () => {
@@ -0,0 +1,256 @@
/**
* Tests for POST /api/deadlines/[id]/complete.
*
* The load-bearing case is the explicit-state one: the page's "Ångra" button
* posts `{ is_completed: false }`, and a route that toggles instead of setting
* turns a second undo click (or an undo of a row already un-ticked elsewhere)
* into a re-completed Skatteverket deadline.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
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),
}))
import { POST } from '../route'
const idParams = { params: Promise.resolve({ id: 'deadline-1' }) }
interface Captured {
update?: Record<string, unknown>
}
/**
* Sequential query results plus the payload handed to `.update()`, which is the
* only place the persisted state is observable.
*/
function createCapturingSupabase(
results: { data?: unknown; error?: unknown }[],
captured: Captured,
) {
let idx = 0
const makeBuilder = () => {
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', 'single', 'maybeSingle']) {
b[m] = () => b
}
b.update = (payload: Record<string, unknown>) => {
captured.update = payload
return b
}
b.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
return b
}
return { from: () => makeBuilder() }
}
function auth(supabase: unknown) {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
}
beforeEach(() => {
vi.clearAllMocks()
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('POST /api/deadlines/[id]/complete', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(createMockRequest('/x', { method: 'POST' }), idParams)
expect(res.status).toBe(401)
})
it('returns 403 for a viewer', async () => {
auth(createCapturingSupabase([], {}))
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const res = await POST(
createMockRequest('/x', { method: 'POST', body: { is_completed: false } }),
idParams,
)
expect(res.status).toBe(403)
})
it('maps zero-rows to 404 with a Swedish message', async () => {
auth(createCapturingSupabase([{ error: { code: 'PGRST116', message: 'no rows' } }], {}))
const { status, body } = await parseJsonResponse<{ error: string }>(
await POST(
createMockRequest('/x', { method: 'POST', body: { is_completed: false } }),
idParams,
),
)
expect(status).toBe(404)
expect(body.error).toBe('Deadline hittades inte')
})
it('marks a pending deadline done and stamps completed_at', async () => {
const captured: Captured = {}
auth(
createCapturingSupabase(
[{ data: { is_completed: false } }, { data: { id: 'deadline-1', is_completed: true } }],
captured,
),
)
const { status } = await parseJsonResponse(
await POST(
createMockRequest('/x', { method: 'POST', body: { is_completed: true } }),
idParams,
),
)
expect(status).toBe(200)
expect(captured.update?.is_completed).toBe(true)
expect(captured.update?.completed_at).toBeTruthy()
})
it('undoes a completed deadline and clears completed_at', async () => {
const captured: Captured = {}
auth(
createCapturingSupabase(
[{ data: { is_completed: true } }, { data: { id: 'deadline-1', is_completed: false } }],
captured,
),
)
const { status } = await parseJsonResponse(
await POST(
createMockRequest('/x', { method: 'POST', body: { is_completed: false } }),
idParams,
),
)
expect(status).toBe(200)
expect(captured.update?.is_completed).toBe(false)
expect(captured.update?.completed_at).toBeNull()
})
it('is idempotent: undoing an already-pending deadline leaves it pending', async () => {
// The "Ångra" click can land after the row was already un-ticked in another
// tab or by an MCP agent. A route that toggled here would re-complete the
// deadline, and the caller would still report it as put back on the list.
const captured: Captured = {}
auth(
createCapturingSupabase(
[{ data: { is_completed: false } }, { data: { id: 'deadline-1', is_completed: false } }],
captured,
),
)
const { status } = await parseJsonResponse(
await POST(
createMockRequest('/x', { method: 'POST', body: { is_completed: false } }),
idParams,
),
)
expect(status).toBe(200)
expect(captured.update?.is_completed).toBe(false)
expect(captured.update?.completed_at).toBeNull()
})
it('is idempotent: re-completing an already-done deadline keeps it done', async () => {
const captured: Captured = {}
auth(
createCapturingSupabase(
[{ data: { is_completed: true } }, { data: { id: 'deadline-1', is_completed: true } }],
captured,
),
)
await POST(
createMockRequest('/x', { method: 'POST', body: { is_completed: true } }),
idParams,
)
expect(captured.update?.is_completed).toBe(true)
expect(captured.update?.completed_at).toBeTruthy()
})
it('falls back to toggling when the body carries no state', async () => {
// Backwards compatibility: a body-less POST keeps the original toggle.
const captured: Captured = {}
auth(
createCapturingSupabase(
[{ data: { is_completed: false } }, { data: { id: 'deadline-1', is_completed: true } }],
captured,
),
)
await POST(createMockRequest('/x', { method: 'POST' }), idParams)
expect(captured.update?.is_completed).toBe(true)
})
it('rejects a non-boolean is_completed with 400 instead of guessing', async () => {
// { is_completed: "false" } is the load-bearing case: a string is truthy,
// and the old hand-rolled parser silently degraded it to a toggle, so an
// "undo" carrying the string could re-complete the deadline. A wrong type
// must fail loudly and persist nothing.
const captured: Captured = {}
auth(createCapturingSupabase([], captured))
const { status } = await parseJsonResponse(
await POST(
createMockRequest('/x', { method: 'POST', body: { is_completed: 'false' } }),
idParams,
),
)
expect(status).toBe(400)
expect(captured.update).toBeUndefined()
})
it('rejects other wrong types for is_completed with 400', async () => {
const captured: Captured = {}
auth(createCapturingSupabase([], captured))
for (const bad of [1, 'nope', null, [true]]) {
const { status } = await parseJsonResponse(
await POST(
createMockRequest('/x', { method: 'POST', body: { is_completed: bad } }),
idParams,
),
)
expect(status, `is_completed=${JSON.stringify(bad)} must be a 400`).toBe(400)
}
expect(captured.update).toBeUndefined()
})
it('rejects unknown body keys with 400 (strict schema)', async () => {
const captured: Captured = {}
auth(createCapturingSupabase([], captured))
const { status } = await parseJsonResponse(
await POST(
createMockRequest('/x', { method: 'POST', body: { is_completed: true, extra: 1 } }),
idParams,
),
)
expect(status).toBe(400)
expect(captured.update).toBeUndefined()
})
it('treats an explicit empty object as a toggle', async () => {
const captured: Captured = {}
auth(
createCapturingSupabase(
[{ data: { is_completed: false } }, { data: { id: 'deadline-1', is_completed: true } }],
captured,
),
)
const { status } = await parseJsonResponse(
await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams),
)
expect(status).toBe(200)
expect(captured.update?.is_completed).toBe(true)
})
})
+51 -5
View File
@@ -1,17 +1,62 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Strict: `is_completed` must be a genuine boolean or absent. The hand-rolled
// parser this replaces silently degraded { is_completed: "false" } (a string)
// to a toggle: the exact hazard the explicit-state contract was built to
// remove, since a truthy-string undo could re-complete the row it meant to
// un-tick. Wrong types are a 400 now, never a guess.
const CompleteDeadlineSchema = z
.object({
is_completed: z.boolean().optional(),
})
.strict()
/**
* POST /api/deadlines/[id]/complete
* Toggle completion status of a deadline
* Set the completion status of a deadline.
*
* The body is optional. When it carries a boolean `is_completed` the route sets
* exactly that state; a caller that sends no body (or `{}`) keeps the original
* toggle behaviour. A body that IS present but malformed (wrong type, unknown
* keys, broken JSON) is rejected with 400 instead of being reinterpreted.
*
* Honouring an explicit state is what makes the "Ångra" affordance an undo
* rather than a second toggle: the click has to be idempotent, because the row
* can already have been un-ticked in another tab or by an MCP agent between the
* toast appearing and the click landing, and a blind toggle would then
* re-complete a Skatteverket deadline the user was trying to put back on the
* list. It also makes the caller's own confirmation truthful: the client picks
* its toast from the state it asked for, so the server must not silently
* persist the opposite.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'deadline.toggle_complete',
async (_request, ctx, { params }) => {
async (request, ctx, { params }) => {
const { id } = await params
const { supabase, companyId } = ctx
// Absent/empty body = toggle (the pre-explicit-state ergonomics); anything
// else must validate. The raw text is read first because validateBody
// treats an empty body as invalid JSON, which would 400 the plain toggle.
const raw = (await request.text()).trim()
let requestedState: boolean | null = null
if (raw !== '') {
const validation = await validateBody(
new Request(request.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: raw,
}),
CompleteDeadlineSchema,
)
if (!validation.success) return validation.response
requestedState = validation.data.is_completed ?? null
}
// First, get current deadline state
const { data: existing, error: fetchError } = await supabase
.from('deadlines')
@@ -22,13 +67,14 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
// "Deadline" is the term the Swedish UI uses too (messages/sv.json).
return NextResponse.json({ error: 'Deadline hittades inte' }, { status: 404 })
}
return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
}
// Toggle completion
const newCompletedState = !existing.is_completed
// Explicit state when the caller supplied one, otherwise toggle.
const newCompletedState = requestedState ?? !existing.is_completed
const { data, error } = await supabase
.from('deadlines')
.update({
@@ -33,6 +33,19 @@ import { POST } from '../import-existing/route'
const request = () => createMockRequest('/api/dimensions/import-existing', { method: 'POST' })
const noParams = { params: Promise.resolve({}) }
/**
* Enqueue the two pages the scan reads through the two-step entry-lines fetch
* (lib/bookkeeping/entry-lines.ts): the company's parent entries first, then
* the lines keyed by journal_entry_id. The route never reads the parent, so a
* single synthetic entry per line set is enough.
*/
function enqueueLines(rows: Array<{ id: string; dimensions: Record<string, string> }>) {
enqueue({ data: rows.length > 0 ? [{ id: 'entry-1' }] : [] })
// No entries means the helper never queries the lines at all.
if (rows.length === 0) return
enqueue({ data: rows.map((r) => ({ ...r, journal_entry_id: 'entry-1' })) })
}
describe('POST /api/dimensions/import-existing', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -52,7 +65,7 @@ describe('POST /api/dimensions/import-existing', () => {
it('returns { created: 0 } when no line carries dimensions', async () => {
enqueue({ data: null }) // ensure RPC
enqueue({ data: [] }) // journal_entry_lines scan
enqueueLines([]) // journal_entry_lines scan
const response = await POST(request(), noParams)
const { status, body } = await parseJsonResponse<{ created: number }>(response)
@@ -64,13 +77,12 @@ describe('POST /api/dimensions/import-existing', () => {
it('creates inactive placeholder values for codes missing from the registry', async () => {
enqueue({ data: null }) // ensure RPC
// Lines: KS01 (dim 1) appears twice, P001 (dim 6) once, BUTIK already registered.
enqueue({
data: [
{ id: 'l1', dimensions: { '1': 'KS01' } },
{ id: 'l2', dimensions: { '1': 'KS01', '6': 'P001' } },
{ id: 'l3', dimensions: { '1': 'BUTIK' } },
],
})
enqueueLines([
{ id: 'l1', dimensions: { '1': 'KS01' } },
{ id: 'l2', dimensions: { '1': 'KS01', '6': 'P001' } },
{ id: 'l3', dimensions: { '1': 'BUTIK' } },
])
// Registry dims 1 & 6 exist (system dims).
enqueue({
data: [
@@ -94,14 +106,13 @@ describe('POST /api/dimensions/import-existing', () => {
enqueue({ data: null }) // ensure RPC
// 'KS"01"' and 'KS{01}' both sanitize to KS01 (one candidate); a 50-char
// code is capped at 40; '"{}"' sanitizes to empty and is dropped entirely.
enqueue({
data: [
{ id: 'l1', dimensions: { '1': 'KS"01"' } },
{ id: 'l2', dimensions: { '1': 'KS{01}' } },
{ id: 'l3', dimensions: { '1': 'X'.repeat(50) } },
{ id: 'l4', dimensions: { '1': '"{}"' } },
],
})
enqueueLines([
{ id: 'l1', dimensions: { '1': 'KS"01"' } },
{ id: 'l2', dimensions: { '1': 'KS{01}' } },
{ id: 'l3', dimensions: { '1': 'X'.repeat(50) } },
{ id: 'l4', dimensions: { '1': '"{}"' } },
])
enqueue({ data: [{ id: 'dim-1', sie_dim_no: 1 }] }) // registry dims
enqueue({ data: [] }) // no existing values
// Upsert returns the two surviving sanitized codes (KS01 + the capped one).
@@ -116,12 +127,11 @@ describe('POST /api/dimensions/import-existing', () => {
it('tolerates duplicates in the batch: created counts only the rows the upsert returned', async () => {
enqueue({ data: null }) // ensure RPC
enqueue({
data: [
{ id: 'l1', dimensions: { '1': 'KS01' } },
{ id: 'l2', dimensions: { '1': 'KS02' } },
],
})
enqueueLines([
{ id: 'l1', dimensions: { '1': 'KS01' } },
{ id: 'l2', dimensions: { '1': 'KS02' } },
])
enqueue({ data: [{ id: 'dim-1', sie_dim_no: 1 }] }) // registry dims
enqueue({ data: [] }) // existing-values snapshot missed a raced KS02
// ignoreDuplicates upsert skips the conflicting row instead of aborting
@@ -137,7 +147,7 @@ describe('POST /api/dimensions/import-existing', () => {
it('creates a registry dimension for an unregistered dim number found on lines', async () => {
enqueue({ data: null }) // ensure RPC
enqueue({ data: [{ id: 'l1', dimensions: { '7': 'AVD-A' } }] }) // lines
enqueueLines([{ id: 'l1', dimensions: { '7': 'AVD-A' } }]) // lines
// Registry only has the system dims: dim 7 is missing.
enqueue({
data: [
@@ -161,7 +171,7 @@ describe('POST /api/dimensions/import-existing', () => {
it('returns 500 DIMENSION_IMPORT_FAILED when the scan blows up', async () => {
enqueue({ data: null }) // ensure RPC
enqueue({ error: { message: 'relation missing' } }) // fetchAllRows throws
enqueue({ error: { message: 'relation missing' } }) // entry page throws
const response = await POST(request(), noParams)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
+14 -11
View File
@@ -16,7 +16,7 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -42,16 +42,19 @@ export const POST = withRouteContext(
try {
// 1. Collect every {dim_no: code} pair used on this company's lines.
// Inner-join scoping: journal_entry_lines has no company_id column.
const lines = await fetchAllRows<LineRow>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select('id, dimensions, journal_entries!inner(company_id)')
.eq('journal_entries.company_id', companyId)
.neq('dimensions', '{}')
.order('id', { ascending: true })
.range(from, to),
)
// journal_entry_lines has no company_id column, so the scope comes
// from the parent entries. Driving the query from that side
// (lib/bookkeeping/entry-lines.ts) instead of a
// `journal_entries!inner(company_id)` embed keeps PostgREST from
// compiling a correlated LATERAL join that walks every tenant's
// lines. The parent is not read here, so it is not reattached.
const lines = await fetchEntryLines<LineRow>({
supabase,
lineColumns: 'id, dimensions',
filterEntries: (q: EntryLinesQuery) => q.eq('company_id', companyId),
filterLines: (q: EntryLinesQuery) => q.neq('dimensions', '{}'),
attachEntriesAs: null,
})
const codesByDimNo = new Map<number, Set<string>>()
for (const line of lines) {
@@ -174,6 +174,30 @@ describe('GET /api/dimensions/tagging/lines', () => {
expect(second.lines.map((l) => l.id)).toEqual(['line-3'])
})
it('orders each voucher lines by sort_order regardless of fetch order', async () => {
// The two-step fetch (lib/bookkeeping/entry-lines.ts) pages the lines on
// their PK for stable paging, so the workbench's voucher order
// (sort_order, then id) has to be restored per verifikat.
enqueue({ data: [makeRawEntry()] })
enqueue({
data: [
makeRawLine({ id: 'line-c', sort_order: 2, account_number: '2641' }),
makeRawLine({ id: 'line-a', sort_order: 0, account_number: '4010' }),
makeRawLine({ id: 'line-b', sort_order: 1, account_number: '1930' }),
],
})
const response = await GET(request(), noParams)
const { status, body } = await parseJsonResponse<VouchersBody>(response)
expect(status).toBe(200)
expect(body.data.vouchers[0].lines.map((l) => l.id)).toEqual([
'line-a',
'line-b',
'line-c',
])
})
it('caps the result at limit vouchers and reports total_capped', async () => {
// limit=2 → route fetches 3 entries; a third means "there is more".
enqueue({
+28 -16
View File
@@ -29,6 +29,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { DimensionTaggingLinesQuerySchema } from '@/lib/api/schemas'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { fetchLinesByEntryIds } from '@/lib/bookkeeping/entry-lines'
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
ensureInitialized()
@@ -169,29 +170,40 @@ export const GET = withRouteContext(
}
// Step 2: the COMPLETE line set for each qualifying voucher, so voucher-
// level tagging always covers the whole verifikat. The entry IDs already
// come from the company-filtered step-1 query; the explicit parent scope
// here is defense in depth (repo convention).
const { data: lineData, error: lineError } = await supabase
.from('journal_entry_lines')
.select('id, account_number, debit_amount, credit_amount, dimensions, journal_entry_id, sort_order, journal_entries!inner(company_id)')
.eq('journal_entries.company_id', companyId)
.in('journal_entry_id', entries.map((e) => e.id))
.order('journal_entry_id', { ascending: true })
.order('sort_order', { ascending: true })
.order('id', { ascending: true })
if (lineError) {
log.error('tagging voucher line fetch failed', lineError)
return errorResponse(lineError, log, { requestId })
// level tagging always covers the whole verifikat.
//
// The entry IDs already come from the company-filtered step-1 query, so
// the old `journal_entries!inner(company_id)` embed added nothing but the
// correlated LATERAL join PostgREST compiles it into, which walks every
// tenant's journal_entry_lines (see lib/bookkeeping/entry-lines.ts).
// fetchLinesByEntryIds keeps the same `.in('journal_entry_id', ...)`
// scope, chunks it, and pages each chunk: the whole line set for a full
// page of vouchers no longer silently stops at PostgREST's 1000-row cap.
let lineData: RawLine[]
try {
lineData = await fetchLinesByEntryIds<RawLine>(
supabase,
entries.map((e) => e.id),
'id, account_number, debit_amount, credit_amount, dimensions, sort_order',
)
} catch (err) {
log.error('tagging voucher line fetch failed', err as Error)
return errorResponse(err, log, { requestId })
}
const linesByEntry = new Map<string, RawLine[]>()
for (const line of (lineData ?? []) as unknown as RawLine[]) {
for (const line of lineData) {
const bucket = linesByEntry.get(line.journal_entry_id) ?? []
bucket.push(line)
linesByEntry.set(line.journal_entry_id, bucket)
}
// The helper orders on the line PK for stable paging; the workbench shows
// lines in voucher order, which is (sort_order, id) inside each verifikat.
for (const bucket of linesByEntry.values()) {
bucket.sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.id.localeCompare(b.id),
)
}
const vouchers = entries.map((e) => ({
journal_entry_id: e.id,
+22 -3
View File
@@ -38,6 +38,16 @@ vi.mock('@/lib/supabase/server', () => ({
}),
}))
// deleteDocument removes storage objects via the cookieless service-role
// client: the documents bucket is WORM (no DELETE policy on storage.objects),
// so a caller-bound remove() is silently blocked by RLS.
const serviceRemoveMock = vi.fn()
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: () => ({
storage: { from: vi.fn(() => ({ remove: serviceRemoveMock })) },
}),
}))
import { GET, DELETE } from '../route'
import { requireWritePermission } from '@/lib/auth/require-write'
import { NextResponse } from 'next/server'
@@ -55,6 +65,7 @@ beforeEach(() => {
data: { signedUrl: 'https://example.com/signed' },
error: null,
})
serviceRemoveMock.mockResolvedValue({ data: [], error: null })
})
function makeReq(method: 'GET' | 'DELETE' = 'DELETE') {
@@ -228,9 +239,17 @@ describe('DELETE /api/documents/[id]', () => {
expect(status).toBe(200)
expect(body.data).toEqual({ id: 'doc-1', deleted: true })
expect(mockSupabase.storage.from).toHaveBeenCalledWith('documents')
const storageBucket = mockSupabase.storage.from.mock.results[0]?.value
expect(storageBucket.remove).toHaveBeenCalledWith(['documents/user-1/kvitto.pdf'])
// Both storage layouts are removed: the stored pointer plus the alternate
// candidate key. During the company-scoped path migration a document can
// exist under either prefix, and removing only the stored one would leave a
// readable orphan copy of a document the user asked to erase. The removal
// must go through the service-role client (WORM bucket: RLS silently
// blocks a caller-bound remove()), never the user-bound client.
expect(serviceRemoveMock).toHaveBeenCalledWith([
'documents/user-1/kvitto.pdf',
'documents/company-1/user-1/kvitto.pdf',
])
expect(mockSupabase.storage.from).not.toHaveBeenCalled()
expect(handler).toHaveBeenCalledOnce()
expect(handler).toHaveBeenCalledWith(
@@ -192,6 +192,32 @@ describe('GET /api/documents/verify/cron', () => {
expect(String(state.auditInserts[0].description)).not.toContain('DOCUMENT_OBJECT_MISSING')
})
it('verifies via the company-scoped fallback key when a concurrent backfill re-homed the object', async () => {
// The batch snapshot carries a stale legacy pointer: mid-batch, the
// Phase B backfill copied the object to the company-scoped key and
// removed the source. This must NOT produce a permanent false
// DOCUMENT_OBJECT_MISSING audit row for a healthy document.
const doc = makeDoc({
id: 'doc-repointed',
storage_path: 'documents/user-1/1_receipt.pdf',
})
const hash = registerObject('documents/company-1/user-1/1_receipt.pdf', 'healthy bytes')
state.documents = [{ ...doc, sha256_hash: hash }]
const response = await GET(cronRequest())
const json = await response.json()
expect(state.auditInserts).toHaveLength(0)
expect(state.updates.map((u) => u.id)).toEqual(['doc-repointed'])
expect(json).toEqual({
processed: 1,
verified: 1,
failures: 0,
missingObjects: 0,
errors: 0,
})
})
it('surfaces a missing storage object as an audit incident AND stamps the check', async () => {
const missing = makeDoc({ id: 'doc-missing', storage_path: 'user-1/company-1/gone.pdf' })
const healthy = makeDoc({ id: 'doc-healthy', storage_path: 'user-1/company-1/ok.pdf' })
+14 -3
View File
@@ -1,6 +1,7 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { withCronContext } from '@/lib/api/with-cron-context'
import { downloadDocumentObject } from '@/lib/core/documents/document-service'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
/**
@@ -55,9 +56,19 @@ export const GET = withCronContext('cron.documents_verify', async (_request, ctx
let missingObjects = 0
const summary = await ctx.forEach('document', documents, async (doc, itemCtx) => {
const { data: fileData, error: downloadError } = await supabase.storage
.from('documents')
.download(doc.storage_path)
// Dual-layout download: the batch is snapshotted up front, and a
// concurrent Phase B backfill (scripts/backfill-document-storage-paths.ts)
// can re-home an object from the legacy uploader-scoped key to the
// company-scoped key (and later remove the source) mid-batch, leaving
// doc.storage_path stale. Trusting the stale pointer here wrote a
// PERMANENT false DOCUMENT_OBJECT_MISSING INTEGRITY_FAILURE row into the
// immutable audit log for a healthy document. The helper tries the
// stored pointer first, then the alternate layout.
const { blob: fileData, error: downloadError } = await downloadDocumentObject(
supabase,
doc.storage_path,
doc.company_id
)
if (downloadError || !fileData) {
// The storage object is unreadable: surface it as an incident in the
+223 -17
View File
@@ -19,18 +19,28 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
// Mock API key auth
// Mock API key auth. Only the three IO-bound helpers are faked: hasScope and
// DEFAULT_SCOPES stay real so the scope tests exercise the actual scope table.
const mockValidateApiKey = vi.fn()
const mockExtractBearerToken = vi.fn()
const mockCreateServiceClientNoCookies = vi.fn()
vi.mock('@/lib/auth/api-keys', () => ({
validateApiKey: (...args: unknown[]) => mockValidateApiKey(...args),
extractBearerToken: (...args: unknown[]) => mockExtractBearerToken(...args),
createServiceClientNoCookies: () => mockCreateServiceClientNoCookies(),
}))
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
return {
...actual,
validateApiKey: (...args: unknown[]) => mockValidateApiKey(...args),
extractBearerToken: (...args: unknown[]) => mockExtractBearerToken(...args),
createServiceClientNoCookies: () => mockCreateServiceClientNoCookies(),
}
})
import { DEFAULT_SCOPES } from '@/lib/auth/api-keys'
import { GET } from '../route'
interface ErrorEnvelope {
error: { code: string; message: string; message_en?: string; details?: unknown }
}
describe('GET /api/events', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -51,6 +61,33 @@ describe('GET /api/events', () => {
},
]
/** A successful validateApiKey result, scoped and live by default. */
const apiKeyAuth = (overrides: Record<string, unknown> = {}) => ({
userId: 'user-1',
companyId: 'company-1',
apiKeyId: 'key-1',
apiKeyName: 'n8n poller',
scopes: ['events:read'],
mode: 'live',
...overrides,
})
/**
* Wire the service-role client the API-key branch uses. The route makes at
* most two queries in order: company_members, then event_log.
*/
const withKeyClient = (
results: { data?: unknown; error?: unknown }[],
) => {
const keyClient = createQueuedMockSupabase()
for (const r of results) keyClient.enqueue(r)
mockCreateServiceClientNoCookies.mockReturnValue(keyClient.supabase)
return keyClient
}
/** The membership row the re-check expects to find. */
const membershipFound = { data: { company_id: 'company-1' } }
beforeEach(() => {
vi.clearAllMocks()
reset()
@@ -58,6 +95,8 @@ describe('GET /api/events', () => {
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
})
// ── Session auth (unchanged behaviour) ───────────────────────
it('returns 401 when not authenticated', async () => {
mockExtractBearerToken.mockReturnValue(null)
requireAuthMock.mockResolvedValue({
@@ -75,6 +114,8 @@ describe('GET /api/events', () => {
})
it('returns events with session auth', async () => {
// Exactly ONE queued result: if the session path wrongly ran the
// company_members re-check it would consume this and return no events.
enqueue({ data: sampleEvents })
const request = createMockRequest('/api/events')
@@ -89,22 +130,21 @@ describe('GET /api/events', () => {
expect(body.data).toHaveLength(2)
expect(body.cursor).toBe(2)
expect(body.has_more).toBe(false)
expect(mockValidateApiKey).not.toHaveBeenCalled()
expect(response.headers.get('X-Gnubok-Mode')).toBeNull()
})
// ── API key auth ─────────────────────────────────────────────
it('returns events with API key auth', async () => {
mockExtractBearerToken.mockReturnValue('gnubok_sk_test123')
mockValidateApiKey.mockResolvedValue({ userId: 'user-1' })
const apiKeySupabase = createQueuedMockSupabase()
apiKeySupabase.enqueue({ data: sampleEvents })
mockCreateServiceClientNoCookies.mockReturnValue(apiKeySupabase.supabase)
mockValidateApiKey.mockResolvedValue(apiKeyAuth())
withKeyClient([membershipFound, { data: sampleEvents }])
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{
data: typeof sampleEvents
cursor: number
has_more: boolean
}>(response)
expect(status).toBe(200)
@@ -118,23 +158,175 @@ describe('GET /api/events', () => {
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse(response)
const { status, body } = await parseJsonResponse<ErrorEnvelope>(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Invalid API key' })
expect(body.error.code).toBe('UNAUTHORIZED')
})
it('returns 429 for rate-limited API key', async () => {
it('returns 429 with a RATE_LIMITED code for a rate-limited API key', async () => {
mockExtractBearerToken.mockReturnValue('gnubok_sk_limited')
mockValidateApiKey.mockResolvedValue({ error: 'Rate limit exceeded', status: 429 })
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status } = await parseJsonResponse(response)
const { status, body } = await parseJsonResponse<ErrorEnvelope>(response)
expect(status).toBe(429)
expect(body.error.code).toBe('RATE_LIMITED')
})
// ── Guard 1: scope ───────────────────────────────────────────
it('returns 403 INSUFFICIENT_SCOPE for a key without events:read', async () => {
mockExtractBearerToken.mockReturnValue('gnubok_sk_noscope')
mockValidateApiKey.mockResolvedValue(
apiKeyAuth({ scopes: ['reports:read', 'invoices:read'] }),
)
const keyClient = withKeyClient([membershipFound, { data: sampleEvents }])
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse<ErrorEnvelope>(response)
expect(status).toBe(403)
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
expect(body.error.details).toMatchObject({ required_scope: 'events:read' })
// Denied before any DB access.
expect(keyClient.supabase.from).not.toHaveBeenCalled()
})
it('returns 403 for a legacy null-scope key falling back to DEFAULT_SCOPES', async () => {
// validateApiKey substitutes DEFAULT_SCOPES when the api_keys row has
// scopes = NULL. Those six read scopes do NOT include events:read.
expect(DEFAULT_SCOPES).not.toContain('events:read')
mockExtractBearerToken.mockReturnValue('gnubok_sk_legacy')
mockValidateApiKey.mockResolvedValue(apiKeyAuth({ scopes: DEFAULT_SCOPES }))
withKeyClient([membershipFound, { data: sampleEvents }])
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse<ErrorEnvelope>(response)
expect(status).toBe(403)
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
})
// ── Guard 2: company membership re-check ─────────────────────
it('returns 404 when the key user is no longer a member of the bound company', async () => {
mockExtractBearerToken.mockReturnValue('gnubok_sk_offboarded')
mockValidateApiKey.mockResolvedValue(apiKeyAuth())
// Membership lookup finds nothing (offboarded, or company archived).
withKeyClient([{ data: null }, { data: sampleEvents }])
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse<ErrorEnvelope>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('NOT_FOUND')
})
it('returns 500 when the membership lookup itself fails', async () => {
mockExtractBearerToken.mockReturnValue('gnubok_sk_dberror')
mockValidateApiKey.mockResolvedValue(apiKeyAuth())
withKeyClient([{ error: { message: 'connection failure' } }, { data: sampleEvents }])
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse<ErrorEnvelope>(response)
expect(status).toBe(500)
expect(body.error.code).toBe('INTERNAL_ERROR')
})
// ── Guard 3: test-mode keys ──────────────────────────────────
it('serves a test-mode key and labels the response X-Gnubok-Mode: test', async () => {
mockExtractBearerToken.mockReturnValue('gnubok_sk_test_abc')
mockValidateApiKey.mockResolvedValue(apiKeyAuth({ mode: 'test' }))
withKeyClient([membershipFound, { data: sampleEvents }])
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{ data: typeof sampleEvents }>(response)
// A read has no write path to simulate, so it is served (matching every v1
// read) rather than blocked with TEST_KEY_WRITE_BLOCKED.
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
expect(response.headers.get('X-Gnubok-Mode')).toBe('test')
})
it('does not label live-key responses with X-Gnubok-Mode', async () => {
mockExtractBearerToken.mockReturnValue('gnubok_sk_live')
mockValidateApiKey.mockResolvedValue(apiKeyAuth({ mode: 'live' }))
withKeyClient([membershipFound, { data: sampleEvents }])
const request = createMockRequest('/api/events')
const response = await GET(request)
expect(response.headers.get('X-Gnubok-Mode')).toBeNull()
})
// ── Payload minimisation ─────────────────────────────────────
it('minimises event payloads before returning them', async () => {
mockExtractBearerToken.mockReturnValue('gnubok_sk_ok')
mockValidateApiKey.mockResolvedValue(apiKeyAuth())
withKeyClient([
membershipFound,
{
data: [
{
sequence: 7,
event_type: 'invoice.created',
entity_id: 'inv-7',
// userId is what minimisePayload strips: an internal
// auth.users.id that identifies the gnubok-side actor.
data: { userId: 'user-1', invoice: { id: 'inv-7', total: 1250 } },
created_at: '2026-03-25T10:00:00Z',
},
],
},
])
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{
data: { data: Record<string, unknown> }[]
}>(response)
expect(status).toBe(200)
expect(body.data[0].data).not.toHaveProperty('userId')
expect(body.data[0].data).toHaveProperty('invoice')
})
it('leaves non-object payloads untouched', async () => {
enqueue({
data: [
{
sequence: 3,
event_type: 'period.locked',
entity_id: null,
data: null,
created_at: '2026-03-25T10:00:00Z',
},
],
})
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{ data: { data: unknown }[] }>(response)
expect(status).toBe(200)
expect(body.data[0].data).toBeNull()
})
// ── Query params + cursor semantics ──────────────────────────
it('supports after cursor parameter', async () => {
enqueue({ data: [sampleEvents[1]] })
@@ -219,4 +411,18 @@ describe('GET /api/events', () => {
expect(status).toBe(400)
})
it('rejects invalid query params before running the event query for API keys', async () => {
mockExtractBearerToken.mockReturnValue('gnubok_sk_ok')
mockValidateApiKey.mockResolvedValue(apiKeyAuth())
withKeyClient([membershipFound, { data: sampleEvents }])
const request = createMockRequest('/api/events', {
searchParams: { after: '-1' },
})
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
})
+133 -7
View File
@@ -1,11 +1,59 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import {
extractBearerToken,
validateApiKey,
createServiceClientNoCookies,
hasScope,
type ApiKeyMode,
} from '@/lib/auth/api-keys'
import { validateQuery } from '@/lib/api/validate'
import { EventsQuerySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { minimisePayload } from '@/lib/webhooks/handler'
import { createLogger } from '@/lib/logger'
const log = createLogger('api/events')
/**
* Scope that gates this endpoint. Declared in lib/auth/api-keys.ts as
* "Polla händelseloggen (event_log) som webhook-fallback": the scope existed
* from day one but was never enforced here, so a key holding only
* DEFAULT_SCOPES (the six read scopes a legacy null-scope key falls back to,
* none of which is events:read) could still drain the whole event log.
*/
const REQUIRED_SCOPE = 'events:read' as const
/** Response header mirroring the v1 wrapper so integrators can see the key mode. */
const MODE_HEADER = 'X-Gnubok-Mode'
interface EventLogRow {
sequence: number
event_type: string
entity_id: string | null
data: unknown
created_at: string
}
/**
* Project a stored event payload through the same minimisation the webhook
* fan-out applies (lib/webhooks/handler.ts `minimisePayload`).
*
* The event_log row stores the emit-site payload minus userId/companyId
* (lib/events/handlers/event-log-handler.ts `stripMetaFields`), which is a
* different and narrower projection than the webhook one. Routing the polled
* rows through minimisePayload keeps the pull surface from ever handing out
* more than the push surface for the same event, and means a future tightening
* (e.g. stripping personnummer from payroll payloads) lands in one place for
* both. GDPR Art.5(1)(c) data minimisation.
*/
function minimiseEventData(value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
return minimisePayload(value as Record<string, unknown>)
}
/**
* GET /api/events
@@ -21,7 +69,12 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
* Supports both session auth (browser) and API key auth (automation platforms).
*/
export async function GET(request: Request) {
// Dual auth: API key or session
// Dual auth: API key or session. This is a deliberate withRouteContext
// opt-out: the wrapper is cookie-session only and cannot express the API-key
// branch. The session branch below still goes through requireAuth(), so MFA
// (AAL2) stays enforced; the key branch runs the same guards the other two
// validateApiKey call sites run (lib/api/v1/with-api-v1.ts,
// extensions/general/mcp-server): scope, then company membership.
let userId: string
let supabase: SupabaseClient
// When authenticated via an API key, the key is BOUND to a specific company.
@@ -29,15 +82,39 @@ export async function GET(request: Request) {
// active company: otherwise a key scoped to company A would leak company B's
// events whenever the user's active_company_id happened to point elsewhere.
let keyCompanyId: string | null = null
let keyMode: ApiKeyMode = 'live'
const token = extractBearerToken(request)
if (token?.startsWith('gnubok_sk_')) {
const authResult = await validateApiKey(token)
if ('error' in authResult) {
return NextResponse.json({ error: authResult.error }, { status: authResult.status })
// validateApiKey only ever returns 401 (bad/unknown/refresh token) or
// 429 (rate limit): map both onto the canonical envelope, same as v1.
return errorResponseFromCode(
authResult.status === 429 ? 'RATE_LIMITED' : 'UNAUTHORIZED',
log,
{ reason: authResult.error },
)
}
// Guard 1: scope. Mirrors with-api-v1.ts step 4.
if (!hasScope(authResult.scopes, REQUIRED_SCOPE)) {
return errorResponseFromCode('INSUFFICIENT_SCOPE', log, {
details: { required_scope: REQUIRED_SCOPE, granted_scopes: authResult.scopes },
})
}
userId = authResult.userId
keyCompanyId = authResult.companyId
// Guard 3: test keys. A test key is simulation-only for WRITES (the v1
// wrapper forces dry-run and blocks non-simulatable mutations with
// TEST_KEY_WRITE_BLOCKED). This endpoint has no write path at all, so the
// consistent behaviour for a read is the one v1 already ships: serve the
// key's own bound company and label the response, rather than 403 with a
// "cannot be simulated" message that would be untrue for a GET.
keyMode = authResult.mode
// Service-role client: RLS does NOT apply below this line, which is exactly
// why the membership re-check further down is mandatory.
supabase = createServiceClientNoCookies()
} else {
// Session auth: requireAuth enforces MFA (AAL2) on hosted, unlike a bare
@@ -54,7 +131,44 @@ export async function GET(request: Request) {
// scope. requireCompanyId throws when there is no company, but guard the
// key-bound path too so a malformed binding can't widen the query scope.
if (!companyId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
return errorResponseFromCode('FORBIDDEN', log)
}
// Guard 2: membership re-check, API-key path only.
//
// company_id on the api_keys row is a snapshot taken when the key was minted.
// Offboarding a user from a company does not revoke their keys, and the
// service-role client above bypasses RLS, so without this the key keeps
// draining the event log of a company its owner no longer belongs to. Both
// sibling call sites already re-check (with-api-v1.ts :349-370 and
// mcp-server/company-routing.ts :90-102); this closes the third.
//
// archived_at IS NULL follows company-routing: an archived company is a
// deactivated tenant and should stop feeding automation platforms.
//
// The session path is deliberately excluded: it runs on the request-scoped
// client where RLS (user_company_ids()) already enforces the same rule.
if (keyCompanyId !== null) {
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('company_id, companies!inner(archived_at)')
.eq('user_id', userId)
.eq('company_id', companyId)
.is('companies.archived_at', null)
.maybeSingle()
if (membershipError) {
log.error('failed to resolve company membership', membershipError)
return errorResponseFromCode('INTERNAL_ERROR', log, {
details: { reason: getUserErrorMessage(membershipError) },
})
}
if (!membership) {
// 404 rather than 403, matching v1 and MCP: do not confirm the company
// exists to a caller who is no longer allowed to see it.
return errorResponseFromCode('NOT_FOUND', log, { details: { companyId } })
}
}
// Validate query params
@@ -81,14 +195,26 @@ export async function GET(request: Request) {
const { data, error } = await query
if (error) {
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
log.error('event_log query failed', error)
return errorResponseFromCode('INTERNAL_ERROR', log, {
details: { reason: getUserErrorMessage(error) },
})
}
const events = data ?? []
const rows = (data ?? []) as EventLogRow[]
const events = rows.map((row) => ({ ...row, data: minimiseEventData(row.data) }))
return NextResponse.json({
const response = NextResponse.json({
data: events,
cursor: events.length > 0 ? events[events.length - 1].sequence : (after ?? 0),
has_more: events.length === limit,
})
// Signal test mode the same way the v1 wrapper does, so an integrator can see
// which key mode served the response without inspecting the body.
if (keyMode === 'test') {
response.headers.set(MODE_HEADER, 'test')
}
return response
}
@@ -19,6 +19,8 @@ vi.mock('@/lib/supabase/fetch-all', () => ({
}))
import { GET } from '../route'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
import { UNDECRYPTABLE_PERSONAL_NUMBER_MASK } from '@/lib/customers/protect-personal-number'
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -50,14 +52,14 @@ beforeEach(() => {
describe('GET /api/export/customers', () => {
it('returns 401 when unauthenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const res = await GET(createMockRequest('/api/export/customers'))
const res = await GET(createMockRequest('/api/export/customers'), { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns an xlsx customer register', async () => {
enqueue({ data: { company_name: 'Acme AB' } })
const res = await GET(createMockRequest('/api/export/customers'))
const res = await GET(createMockRequest('/api/export/customers'), { params: Promise.resolve({}) })
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toContain('spreadsheetml')
@@ -73,7 +75,7 @@ describe('GET /api/export/customers', () => {
it('returns a CSV with BOM when format=csv', async () => {
enqueue({ data: { company_name: 'Acme AB' } })
const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } }))
const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } }), { params: Promise.resolve({}) })
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toContain('text/csv')
@@ -81,4 +83,71 @@ describe('GET /api/export/customers', () => {
expect([buf[0], buf[1], buf[2]]).toEqual([0xef, 0xbb, 0xbf])
expect(buf.toString('utf-8')).toContain('Göteborg')
})
it('masks an encrypted personal_number: never ciphertext, never the full number', async () => {
// customers.personal_number holds AES-256-GCM ciphertext (migration
// 20260726110000). The export must show what the UI shows a member:
// '********-1234', not the hex blob and not the decrypted personnummer.
const stored = encryptPersonnummer('19900101-1234') // synthetic
mockFetchAllRows.mockResolvedValue([
{
...CUSTOMER,
id: 'c2',
name: 'Anna Andersson',
customer_type: 'individual',
org_number: null,
vat_number: null,
personal_number: stored,
},
])
enqueue({ data: { company_name: 'Acme AB' } })
const res = await GET(createMockRequest('/api/export/customers'), { params: Promise.resolve({}) })
expect(res.status).toBe(200)
const buf = Buffer.from(await res.arrayBuffer())
const wb = XLSX.read(new Uint8Array(buf), { type: 'array' })
const sheet = wb.Sheets[wb.SheetNames[0]]
const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1 })
const serialized = JSON.stringify(rows)
expect(rows[1] as string[]).toContain('********-1234')
// Neither the ciphertext nor the birthdate half of the number may appear.
expect(serialized).not.toContain(stored)
expect(serialized).not.toContain('19900101')
})
it('falls back to a placeholder mask when the stored value cannot be decrypted', async () => {
// Hex of the shape the DB CHECK accepts, but with an auth tag that can
// never verify. One bad row must neither leak nor abort the export.
const garbage = 'ab'.repeat(40)
mockFetchAllRows.mockResolvedValue([
{
...CUSTOMER,
id: 'c3',
name: 'Berit Bengtsson',
customer_type: 'individual',
org_number: null,
vat_number: null,
personal_number: garbage,
},
])
enqueue({ data: { company_name: 'Acme AB' } })
const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } }), { params: Promise.resolve({}) })
expect(res.status).toBe(200)
const text = Buffer.from(await res.arrayBuffer()).toString('utf-8')
expect(text).toContain(UNDECRYPTABLE_PERSONAL_NUMBER_MASK)
expect(text).not.toContain(garbage)
})
it('keeps the raw org number for business customers', async () => {
// Org numbers are public registry data: they export untouched, and the
// masking branch only engages when org_number is absent.
enqueue({ data: { company_name: 'Acme AB' } })
const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } }), { params: Promise.resolve({}) })
const text = Buffer.from(await res.arrayBuffer()).toString('utf-8')
expect(text).toContain('5560217780')
})
})
+9 -1
View File
@@ -4,6 +4,7 @@ import { errorResponse } from '@/lib/errors/get-structured-error'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { textColumn, integerColumn } from '@/lib/reports/xlsx-export'
import { buildRegisterExport, parseExportFormat, todayIso } from '@/lib/export/register-export'
import { maskStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
import type { Customer } from '@/types'
/**
@@ -58,7 +59,14 @@ export const GET = withRouteContext(
rows: customers,
mapRow: (c) => [
c.name,
c.org_number ?? c.personal_number,
// personal_number holds AES-256-GCM ciphertext (migration
// 20260726110000). Decrypt-and-mask exactly like the customer
// read surfaces: the export shows a member the same
// '********-1234' the UI shows, never the ciphertext and never
// the full personnummer (GDPR Art. 5(1)(f) data minimization on
// a file that leaves the system). Decrypt failures fall back to
// a placeholder mask instead of aborting the export.
c.org_number ?? maskStoredCustomerPersonalNumber(c.personal_number),
c.customer_type,
c.email,
c.phone,
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn().mockReturnValue({}),
}))
vi.mock('@/lib/extensions/loader', () => ({
loadExtensions: vi.fn(),
}))
vi.mock('@/lib/extensions/registry', () => ({
extensionRegistry: {
get: vi.fn(),
},
}))
vi.mock('@/extensions/general/push-notifications/notification-scheduler', () => ({
sendTaxDeadlineNotifications: vi.fn(),
sendInvoiceNotifications: vi.fn(),
sendMissingUnderlagNotifications: vi.fn(),
}))
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: vi.fn().mockReturnValue(null),
}))
import { GET } from '../route'
import { extensionRegistry } from '@/lib/extensions/registry'
import { loadExtensions } from '@/lib/extensions/loader'
import {
sendTaxDeadlineNotifications,
sendInvoiceNotifications,
sendMissingUnderlagNotifications,
} from '@/extensions/general/push-notifications/notification-scheduler'
import { verifyCronSecret } from '@/lib/auth/cron'
const mockRegistryGet = vi.mocked(extensionRegistry.get)
const mockVerifyCronSecret = vi.mocked(verifyCronSecret)
const mockTax = vi.mocked(sendTaxDeadlineNotifications)
const mockInvoice = vi.mocked(sendInvoiceNotifications)
const mockUnderlag = vi.mocked(sendMissingUnderlagNotifications)
function makeRequest() {
return new Request('http://localhost/api/extensions/push-notifications/cron', {
headers: { authorization: 'Bearer synthetic-cron-secret' },
})
}
beforeEach(() => {
vi.clearAllMocks()
mockVerifyCronSecret.mockReturnValue(null)
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://synthetic.supabase.co'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'synthetic-service-role-key'
})
describe('GET /api/extensions/push-notifications/cron', () => {
it('returns 401 when the cron secret is rejected', async () => {
mockVerifyCronSecret.mockReturnValue(
NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
)
const response = await GET(makeRequest())
expect(response.status).toBe(401)
expect(mockTax).not.toHaveBeenCalled()
})
it('returns 503 EXTENSION_DISABLED when the extension is not in the registry', async () => {
// Physical extension routes deploy in every build; the registry, generated
// from extensions.config.json, is what turns them on. Disabled must mean
// no sends AND a visible failure if the cron is ever scheduled anyway.
mockRegistryGet.mockReturnValue(undefined)
const response = await GET(makeRequest())
const body = await response.json()
expect(response.status).toBe(503)
expect(body.code).toBe('EXTENSION_DISABLED')
expect(mockTax).not.toHaveBeenCalled()
expect(mockInvoice).not.toHaveBeenCalled()
expect(mockUnderlag).not.toHaveBeenCalled()
})
it('runs all three schedulers and sums the totals when enabled', async () => {
mockRegistryGet.mockReturnValue({ id: 'push-notifications' } as never)
mockTax.mockResolvedValue({ sent: 2, skipped: 1 })
mockInvoice.mockResolvedValue({ sent: 3, skipped: 0 })
mockUnderlag.mockResolvedValue({ sent: 1, skipped: 4 })
const response = await GET(makeRequest())
const body = await response.json()
expect(loadExtensions).toHaveBeenCalled()
expect(mockRegistryGet).toHaveBeenCalledWith('push-notifications')
expect(response.status).toBe(200)
expect(body.success).toBe(true)
expect(body.totalSent).toBe(6)
expect(body.totalSkipped).toBe(5)
expect(body.details.taxDeadlines).toEqual({ sent: 2, skipped: 1 })
})
it('returns the error envelope when Supabase configuration is missing', async () => {
mockRegistryGet.mockReturnValue({ id: 'push-notifications' } as never)
delete process.env.NEXT_PUBLIC_SUPABASE_URL
const response = await GET(makeRequest())
const body = await response.json()
expect(response.status).toBe(500)
expect(body.error.code).toBe('INTERNAL_ERROR')
expect(mockTax).not.toHaveBeenCalled()
})
})
@@ -1,6 +1,7 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import {
sendTaxDeadlineNotifications,
sendInvoiceNotifications,
@@ -12,11 +13,29 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure
/**
* GET /api/extensions/push-notifications/cron: daily 09:00 UTC.
* Sends due tax, invoice and missing-underlag push notifications.
*
* NOT scheduled: this path is absent from vercel.json's crons (and therefore
* from the Docker crontabs generated from it). Adding it there is a product
* decision, not a code change.
*/
export const GET = withCronContext('cron.push_notifications', async (_request, ctx) => {
// Ensure extensions are loaded so event handlers are registered.
// Load the registry so it reflects extensions.config.json.
loadExtensions()
// Physical routes under app/api/extensions/<id>/ compile into EVERY build,
// including the core-with-zero-extensions one: the registry (generated from
// extensions.config.json) is what actually switches an extension on. Mirror
// the ext/[...path] dispatcher: a disabled extension must not expose a live
// send/query surface, and a scheduled-but-disabled cron must fail visibly
// (503) instead of quietly doing the work anyway.
if (!extensionRegistry.get('push-notifications')) {
ctx.log.warn('push-notifications extension is not enabled; cron refused')
return NextResponse.json(
{ error: 'Push notifications extension is not enabled', code: 'EXTENSION_DISABLED' },
{ status: 503 }
)
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
@@ -13,8 +13,9 @@ ensureInitialized()
* company. Backs the dashboard SkattekontoDriftTile. Returns null when no
* snapshot exists yet (fresh company, never synced).
*
* Access is recorded through the structured logger (Sentry / Vercel logs)
* because the response carries sensitive GL drift figures. Persisting every
* Access is recorded through the structured logger (Vercel logs; the
* observability sink only sees errors and no-ops until a provider is
* configured) because the response carries sensitive GL drift figures. Persisting every
* dashboard tile poll into event_log would be too noisy: the structured
* log line gives an auditable record without overrunning the 30-day event
* log retention (SOC 2 CC8.1, ISO 27001 A.8.15).
@@ -180,7 +180,8 @@ export const POST = withRouteContext(
// ASVS V16: durable audit sink for a failed correction. The core event bus
// has no opening_balance.* correction event type and lib/events/types.ts is
// outside the scope of this change, so the failure is recorded via the
// structured logger: it lands in the JSON log sink (Vercel/Sentry), tagged
// structured logger: it lands in the JSON log (Vercel logs, plus the
// observability sink, which no-ops until a provider is configured), tagged
// `audit: true` + both entry ids so an operator can reconcile the period by
// hand. (Follow-up: promote to a typed event persisted to event_log.)
const auditCorrectionFailure = (fields: Record<string, unknown>) => {
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
// The route delegates everything to replaceSIEImport (which itself runs the
// replace_sie_import RPC on the service client). The route's own contract is
// what these tests pin: auth, the 403 mapping of the RPC's authorization
// raise, the 400 fallback, and the success envelope.
const replaceSIEImportMock = vi.fn()
vi.mock('@/lib/import/sie-import', () => ({
replaceSIEImport: (...args: unknown[]) => replaceSIEImportMock(...args),
}))
import { POST } from '../route'
import { NextResponse } from 'next/server'
const mockUser = { id: 'user-1', email: 'test@test.se' }
function makeReq() {
return new Request('http://localhost/api/import/sie/import-1/replace', { method: 'POST' })
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
})
describe('POST /api/import/sie/[id]/replace', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(makeReq(), createMockRouteParams({ id: 'import-1' }))
expect(res.status).toBe(401)
expect(replaceSIEImportMock).not.toHaveBeenCalled()
})
it('maps the RPC authorization raise (42501) to 403 SIE_REPLACE_FORBIDDEN', async () => {
// replaceSIEImport flattens the PostgREST error into a message string, so
// the route matches the raise text pinned by migration 20260727120000.
replaceSIEImportMock.mockResolvedValue({
success: false,
deletedEntries: 0,
error: 'Kunde inte ersätta import: Only company owners and admins can replace SIE imports',
})
const res = await POST(makeReq(), createMockRouteParams({ id: 'import-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; message: string }
}>(res)
expect(status).toBe(403)
expect(body.error.code).toBe('SIE_REPLACE_FORBIDDEN')
// Swedish user-facing message, not raw Postgres prose.
expect(body.error.message).toBe(
'Endast ägare eller administratörer kan ersätta en SIE-import.',
)
})
it('returns 400 SIE_REPLACE_FAILED with the reason for other failures', async () => {
replaceSIEImportMock.mockResolvedValue({
success: false,
deletedEntries: 0,
error: 'Kan inte ersätta import i ett låst eller stängt räkenskapsår. Öppna perioden först.',
})
const res = await POST(makeReq(), createMockRouteParams({ id: 'import-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { reason?: string } }
}>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('SIE_REPLACE_FAILED')
expect(body.error.details?.reason).toContain('låst eller stängt')
})
it('passes the authorising user through and returns the deleted count', async () => {
replaceSIEImportMock.mockResolvedValue({ success: true, deletedEntries: 42 })
const res = await POST(makeReq(), createMockRouteParams({ id: 'import-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
deletedEntries: number
}>(res)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.deletedEntries).toBe(42)
expect(replaceSIEImportMock).toHaveBeenCalledWith(
mockSupabase,
'company-1',
'import-1',
'user-1',
)
})
})
+15 -2
View File
@@ -18,12 +18,25 @@ export const POST = withRouteContext(
'sie_import.replace',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const { supabase, companyId, user, log, requestId } = ctx
const opLog = log.child({ sieImportId: id })
const result = await replaceSIEImport(supabase, companyId!, id)
// The replace_sie_import RPC gates on owner/admin membership. It usually
// runs on the service client where auth.uid() is NULL, so the authorising
// user is passed explicitly; the RPC honors it only for service_role
// callers (migration 20260727120000).
const result = await replaceSIEImport(supabase, companyId!, id, user.id)
if (!result.success) {
// The RPC raises 42501 with this exact text when the actor is not an
// owner/admin. replaceSIEImport flattens the PostgREST error into a
// message string, so the route detects the authorization raise by the
// text, which is pinned by migration 20260727120000 and by
// lib/import/__tests__/sie-import.replace.pg.test.ts. Surface it as a
// structured 403 instead of a 400 with raw English prose in details.
if (result.error?.includes('Only company owners and admins can replace SIE imports')) {
return errorResponseFromCode('SIE_REPLACE_FORBIDDEN', opLog, { requestId })
}
return errorResponseFromCode('SIE_REPLACE_FAILED', opLog, {
requestId,
details: { reason: result.error },
@@ -4,6 +4,23 @@
* Exercises the routes through the real withRouteContext wrapper, mocking only
* its auth/company/write dependencies and injecting a queued Supabase mock via
* requireAuth. Covers: 401, 403 viewer, validation (400), and happy paths.
*
* The route has no 404 path: PUT upserts (a missing mapping is created, not
* rejected) and DELETE is idempotent (removing an unknown source account is a
* no-op, not a miss). The DELETE test below pins that contract.
*
* The PUT column-level tests use createRecordingSupabase() instead of the
* shared queued mock: the queued mock is a Proxy that discards call arguments,
* so it cannot see which columns the route actually writes, which is exactly
* what regressed here (source_name silently dropped from the upsert).
*
* The POST tests do the same one level deeper. Mocking saveMappings only
* proves which value the route passed as the second positional argument; it
* cannot show which COLUMN that value ends up in. That is what hid the bug
* this file used to pin: the route passed user.id where saveMappings expects
* companyId, and both are strings, so nothing complained. One POST test
* therefore runs the real saveMappings against a recording Supabase double and
* asserts on the row it upserts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
@@ -35,6 +52,78 @@ import { GET, POST, PUT, DELETE } from '../route'
const emptyParams = { params: Promise.resolve({}) }
type StoredMapping = { source_name: string | null } | null
type ReadChain = {
eq: (column: string, value: unknown) => ReadChain
maybeSingle: () => Promise<{ data: StoredMapping; error: null }>
}
type UpsertCall = {
table: string
payload: Record<string, unknown>
options: { onConflict?: string } | undefined
}
type BatchUpsertCall = {
table: string
rows: Record<string, unknown>[]
options: { onConflict?: string } | undefined
}
/**
* Supabase double for the POST path, which upserts an array of rows in
* batches. Records every row verbatim so a test can assert which column each
* value landed in.
*/
function createBatchRecordingSupabase() {
const upserts: BatchUpsertCall[] = []
const supabase = {
from: (table: string) => ({
upsert: async (rows: Record<string, unknown>[], options?: { onConflict?: string }) => {
upserts.push({ table, rows, options })
return { data: null, error: null }
},
}),
}
return { supabase, upserts }
}
/**
* Supabase double that records the upsert payload and options verbatim.
* `stored` is the mapping row the read-back finds (null = first save).
*/
function createRecordingSupabase(stored: StoredMapping) {
const upserts: UpsertCall[] = []
const readBackColumns: string[] = []
const readChain: ReadChain = {
eq: () => readChain,
maybeSingle: async () => ({ data: stored, error: null }),
}
const supabase = {
from: (table: string) => ({
select: (columns: string) => {
readBackColumns.push(columns)
return readChain
},
upsert: (payload: Record<string, unknown>, options?: { onConflict?: string }) => {
upserts.push({ table, payload, options })
return {
select: () => ({
single: async () => ({ data: payload, error: null }),
}),
}
},
}),
}
return { supabase, upserts, readBackColumns }
}
describe('/api/import/sie/mappings', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -82,13 +171,47 @@ describe('/api/import/sie/mappings', () => {
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
const { status, body } = await parseJsonResponse<{ error: string; type: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Invalid mappings data')
expect(body.type).toBe('validation_error')
expect(saveMappingsMock).not.toHaveBeenCalled()
})
it('POST saves the mappings', async () => {
it('POST rejects wrongly-typed mapping elements with 400 instead of a Postgres 500', async () => {
// Element-level payloads used to be unvalidated: a numeric sourceAccount
// or a string confidence sailed through to Postgres and surfaced as 500.
const badElements = [
[{ sourceAccount: 1920, targetAccount: '1930' }], // number, not string
[{ sourceAccount: '1920', targetAccount: '1930', confidence: 'high' }],
[{ sourceAccount: '1920', targetAccount: '1930', matchType: 'guess' }],
[{ sourceAccount: '', targetAccount: '1930' }], // empty source key
['not-an-object'],
]
for (const mappings of badElements) {
const response = await POST(
createMockRequest('/api/import/sie/mappings', { method: 'POST', body: { mappings } }),
emptyParams,
)
expect(response.status, `payload ${JSON.stringify(mappings)} must be a 400`).toBe(400)
}
expect(saveMappingsMock).not.toHaveBeenCalled()
})
it('POST accepts unmapped elements (no targetAccount): saveMappings filters them', async () => {
const mappings = [
{ sourceAccount: '1920', targetAccount: '1930' },
{ sourceAccount: '8888' }, // not yet mapped: valid on the wire
]
const response = await POST(
createMockRequest('/api/import/sie/mappings', { method: 'POST', body: { mappings } }),
emptyParams,
)
expect(response.status).toBe(200)
expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'company-1', mappings, 'user-1')
})
it('POST saves the mappings under the active company', async () => {
const mappings = [{ sourceAccount: '1920', targetAccount: '1930' }]
const request = createMockRequest('/api/import/sie/mappings', {
method: 'POST',
@@ -100,7 +223,70 @@ describe('/api/import/sie/mappings', () => {
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'user-1', mappings)
// saveMappings(supabase, companyId, mappings): the second argument is the
// tenant key and is written straight into sie_account_mappings.company_id.
// The route used to pass user.id here; both are strings, so the compiler
// could not tell them apart.
expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'company-1', mappings, 'user-1')
expect(saveMappingsMock.mock.calls[0][1]).not.toBe('user-1')
})
it('POST writes company_id, not the user id, into the mapping row', async () => {
// One level deeper than the mocked-argument assertion above: run the real
// saveMappings so the test sees which COLUMN the value lands in. company_id
// is NOT NULL and FK-bound to companies, so a user UUID there can never
// persist.
const { saveMappings: realSaveMappings } =
await vi.importActual<typeof import('@/lib/import/sie-import')>('@/lib/import/sie-import')
const { supabase: recording, upserts } = createBatchRecordingSupabase()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording })
saveMappingsMock.mockImplementation(realSaveMappings)
const request = createMockRequest('/api/import/sie/mappings', {
method: 'POST',
body: {
mappings: [
{
sourceAccount: '1920',
sourceName: 'Bank',
targetAccount: '1930',
confidence: 1.0,
matchType: 'manual',
},
],
},
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(200)
expect(upserts).toHaveLength(1)
expect(upserts[0].table).toBe('sie_account_mappings')
expect(upserts[0].rows).toHaveLength(1)
expect(upserts[0].rows[0]).toMatchObject({
company_id: 'company-1',
source_account: '1920',
target_account: '1930',
})
expect(upserts[0].rows[0].company_id).not.toBe('user-1')
expect(upserts[0].options?.onConflict).toBe('company_id,source_account')
})
it('POST ignores a company id smuggled into the body', async () => {
// Tenancy comes from withRouteContext (membership-validated), never from
// the caller. A body field naming another company must not redirect the
// write.
const mappings = [{ sourceAccount: '1920', targetAccount: '1930' }]
const request = createMockRequest('/api/import/sie/mappings', {
method: 'POST',
body: { mappings, companyId: 'company-2', company_id: 'company-2' },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(200)
expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'company-1', mappings, 'user-1')
expect(saveMappingsMock.mock.calls[0][1]).not.toBe('company-2')
})
it('GET lists the saved mappings', async () => {
@@ -125,7 +311,26 @@ describe('/api/import/sie/mappings', () => {
expect(status).toBe(400)
})
it('PUT rejects wrongly-typed fields with 400 instead of a Postgres 500', async () => {
const badBodies = [
{ sourceAccount: '1920', targetAccount: 1930 }, // number, not string
{ sourceAccount: 1920, targetAccount: '1930' },
{ sourceAccount: '1920', targetAccount: '1930', sourceName: 42 },
{ sourceAccount: '1920', targetAccount: '' }, // empty target
]
for (const body of badBodies) {
const response = await PUT(
createMockRequest('/api/import/sie/mappings', { method: 'PUT', body }),
emptyParams,
)
const { status, body: parsed } = await parseJsonResponse<{ type?: string }>(response)
expect(status, `body ${JSON.stringify(body)} must be a 400`).toBe(400)
expect(parsed.type).toBe('validation_error')
}
})
it('PUT upserts a single mapping', async () => {
enqueue({ data: null }) // read-back of the stored SIE label
enqueue({ data: { source_account: '1920', target_account: '1930' } })
const request = createMockRequest('/api/import/sie/mappings', {
@@ -140,6 +345,95 @@ describe('/api/import/sie/mappings', () => {
expect(body.data.target_account).toBe('1930')
})
it('PUT returns 500 when the stored-label read-back fails', async () => {
enqueue({ error: { code: '42P01', message: 'relation does not exist' } })
const request = createMockRequest('/api/import/sie/mappings', {
method: 'PUT',
body: { sourceAccount: '1920', targetAccount: '1930' },
})
const response = await PUT(request, emptyParams)
const { status } = await parseJsonResponse(response)
expect(status).toBe(500)
})
it('PUT keeps the stored SIE label when the client does not resend it', async () => {
// Re-save: the mapping row already carries the label from the SIE file's
// #KONTO record. The client only sends the new target account.
const { supabase: recording, upserts, readBackColumns } =
createRecordingSupabase({ source_name: 'Kassa' })
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording })
const request = createMockRequest('/api/import/sie/mappings', {
method: 'PUT',
body: { sourceAccount: '1910', targetAccount: '1930' },
})
const response = await PUT(request, emptyParams)
expect(response.status).toBe(200)
expect(readBackColumns).toEqual(['source_name'])
expect(upserts).toHaveLength(1)
// Full column set: every column the route is expected to write. id,
// created_at and updated_at are deliberately absent (DB defaults/trigger).
expect(upserts[0].payload).toEqual({
user_id: 'user-1',
company_id: 'company-1',
source_account: '1910',
source_name: 'Kassa',
target_account: '1930',
confidence: 1.0,
match_type: 'manual',
})
// (user_id, source_account) was dropped by the multi-tenant refactor.
expect(upserts[0].options?.onConflict).toBe('company_id,source_account')
})
it('PUT sets the SIE label on a first save when the client sends it', async () => {
const { supabase: recording, upserts } = createRecordingSupabase(null)
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording })
const request = createMockRequest('/api/import/sie/mappings', {
method: 'PUT',
body: { sourceAccount: '1910', targetAccount: '1930', sourceName: 'Bankgiro' },
})
const response = await PUT(request, emptyParams)
expect(response.status).toBe(200)
expect(upserts[0].payload.source_name).toBe('Bankgiro')
})
it('PUT prefers the label the client sends over the stored one', async () => {
const { supabase: recording, upserts } = createRecordingSupabase({ source_name: 'Kassa' })
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording })
const request = createMockRequest('/api/import/sie/mappings', {
method: 'PUT',
body: { sourceAccount: '1910', targetAccount: '1930', sourceName: 'Kassa och bank' },
})
await PUT(request, emptyParams)
expect(upserts[0].payload.source_name).toBe('Kassa och bank')
})
it('PUT leaves source_name null when no label is known anywhere', async () => {
const { supabase: recording, upserts } = createRecordingSupabase(null)
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording })
const request = createMockRequest('/api/import/sie/mappings', {
method: 'PUT',
body: { sourceAccount: '1910', targetAccount: '1930', sourceName: ' ' },
})
await PUT(request, emptyParams)
expect(upserts[0].payload.source_name).toBeNull()
})
it('DELETE returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
@@ -166,4 +460,18 @@ describe('/api/import/sie/mappings', () => {
expect(status).toBe(200)
expect(body.success).toBe(true)
})
it('DELETE of an unknown source account succeeds: the route has no 404 path', async () => {
enqueue({ data: null })
const request = createMockRequest('/api/import/sie/mappings', {
method: 'DELETE',
searchParams: { sourceAccount: '9999' },
})
const response = await DELETE(request, emptyParams)
const { status } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
})
})
+80 -16
View File
@@ -1,9 +1,39 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { saveMappings } from '@/lib/import/sie-import'
import type { AccountMapping } from '@/lib/import/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Mirrors what saveMappings() (lib/import/sie-import.ts) reads off each
// element: sourceAccount/sourceName/targetAccount/confidence/matchType land
// verbatim in sie_account_mappings columns. Everything except sourceAccount is
// optional because saveMappings itself tolerates absence: it filters out
// elements with a falsy targetAccount (unmapped accounts travel in the same
// array), and confidence/match_type fall back to their column defaults.
// The point of the schema is type safety: a wrongly-typed element used to
// reach Postgres and surface as a 500 instead of a 400.
const SieMappingElementSchema = z.object({
sourceAccount: z.string().min(1).max(20),
sourceName: z.string().max(200).nullish(),
targetAccount: z.string().max(20).nullish(),
targetName: z.string().max(200).nullish(),
confidence: z.number().min(0).max(1).nullish(),
matchType: z.enum(['exact', 'name', 'class', 'manual', 'bas_range']).nullish(),
isOverride: z.boolean().nullish(),
})
const SaveMappingsSchema = z.object({
mappings: z.array(SieMappingElementSchema),
})
const UpdateMappingSchema = z.object({
sourceAccount: z.string().min(1).max(20),
targetAccount: z.string().min(1).max(20),
sourceName: z.string().max(200).nullish(),
})
/**
* GET /api/import/sie/mappings
* Get all saved account mappings for the user
@@ -31,16 +61,26 @@ export const GET = withRouteContext(
*/
export const POST = withRouteContext(
'sie_import.mappings.save',
async (request, { supabase, user }) => {
const body = await request.json()
const mappings: AccountMapping[] = body.mappings
if (!mappings || !Array.isArray(mappings)) {
return NextResponse.json({ error: 'Invalid mappings data' }, { status: 400 })
}
async (request, { supabase, companyId, user }) => {
const validation = await validateBody(request, SaveMappingsSchema)
if (!validation.success) return validation.response
// Sparse elements are deliberate (see the schema comment): saveMappings
// handles absent fields itself, so the wire type is a relaxed superset of
// AccountMapping.
const mappings = validation.data.mappings as unknown as AccountMapping[]
try {
await saveMappings(supabase, user.id, mappings)
// saveMappings' second parameter is the tenant key: it lands verbatim in
// sie_account_mappings.company_id, which is NOT NULL and FK-bound to
// companies. This used to pass user.id, so every row the endpoint tried
// to write carried a user UUID in the company column. companyId is
// resolved by withRouteContext from the caller's own membership and is
// never taken from the request body, so a caller cannot write mappings
// into a company they do not belong to.
// userId is passed explicitly: sie_account_mappings.user_id is NOT NULL
// with no default, and saveMappings' session fallback returns nothing on
// the cookieless service client used by API-key and MCP callers.
await saveMappings(supabase, companyId, mappings, user.id)
return NextResponse.json({ success: true })
} catch (error) {
return NextResponse.json(
@@ -59,27 +99,51 @@ export const POST = withRouteContext(
export const PUT = withRouteContext(
'sie_import.mappings.update',
async (request, { supabase, user, companyId }) => {
const body = await request.json()
const { sourceAccount, targetAccount } = body
const validation = await validateBody(request, UpdateMappingSchema)
if (!validation.success) return validation.response
const { sourceAccount, targetAccount, sourceName } = validation.data
if (!sourceAccount || !targetAccount) {
return NextResponse.json(
{ error: 'sourceAccount and targetAccount are required' },
{ status: 400 }
)
// source_name is the label the exporting system gave the account in the SIE
// file's #KONTO record ('#KONTO 1910 "Kassa"'). During the mapping review it
// is the only thing that tells one unfamiliar source account number from
// another, and lib/import/account-mapper.ts reads it back for display. It is
// per-file and cannot be recomputed from the BAS chart, so read the stored
// value and carry it forward instead of requiring the caller to resend it: a
// client that doesn't know about the field is exactly how it got erased.
const { data: existing, error: existingError } = await supabase
.from('sie_account_mappings')
.select('source_name')
.eq('company_id', companyId)
.eq('source_account', sourceAccount)
.maybeSingle()
if (existingError) {
return NextResponse.json({ error: getUserErrorMessage(existingError) }, { status: 500 })
}
// A caller that does know the label (the import review sends it straight
// from the parsed file) wins, so the first save can set it; otherwise keep
// whatever is already stored.
const resolvedSourceName =
typeof sourceName === 'string' && sourceName.trim().length > 0
? sourceName
: (existing?.source_name ?? null)
const { data, error } = await supabase
.from('sie_account_mappings')
.upsert({
user_id: user.id,
company_id: companyId,
source_account: sourceAccount,
source_name: resolvedSourceName,
target_account: targetAccount,
confidence: 1.0,
match_type: 'manual',
}, {
onConflict: 'user_id,source_account',
// The (user_id, source_account) unique constraint was dropped by the
// multi-tenant refactor; (company_id, source_account) is the one that
// exists, and the one saveMappings() upserts against.
onConflict: 'company_id,source_account',
})
.select()
.single()
@@ -32,6 +32,9 @@ const mockGetAvailableVatRates = vi.fn()
vi.mock('@/lib/invoices/vat-rules', () => ({
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
// The builder gates on the permitted set (taxed-where-performed exceptions);
// these route tests only care that the gate reads the stubbed rates.
getPermittedVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
}))
vi.mock('@/lib/currency/riksbanken', () => ({
@@ -151,6 +154,7 @@ describe('PATCH /api/invoices/[id]', () => {
enqueue({ data: makeCustomer({ id: 'customer-1', customer_type: 'swedish_business' }), error: null }) // customer
enqueue({ data: { vat_registered: true }, error: null }) // company_settings.vat_registered
enqueue({ data: [{ id: 'inv-1' }], error: null }) // update ... select('id')
enqueue({ data: [], error: null }) // snapshot existing invoice_items
enqueue({ data: [], error: null }) // delete invoice_items
enqueue({ data: null, error: null }) // insert invoice_items
enqueue({
@@ -705,4 +705,228 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled()
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
// ------------------------------------------------------------------
// Foreign-currency unit handling.
// total / paid_amount / remaining_amount are stored in the INVOICE currency;
// custom lines are journal lines and therefore SEK. Everything below pins
// the conversion between the two.
// ------------------------------------------------------------------
it('converts a SEK custom-line payment to invoice currency before touching the ledger (EUR, partial)', async () => {
// 1 000 EUR invoice at 11,4967 SEK/EUR. The customer pays 5 748,35 kr,
// which is exactly 500 EUR: a genuine partial payment. Without the
// conversion the guard compared 5 748,35 (SEK) against 1 000 (EUR), read
// the partial as a full settlement and ran the duplicate-payment guard on
// it: a matching bank row would then 409 a perfectly valid partial.
const customer = makeCustomer()
const invoice = makeInvoice({
id: 'inv-1',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.4967,
total: 1000,
remaining_amount: 1000,
customer,
})
enqueue({ data: invoice, error: null })
// No duplicate-guard probes enqueued: 500 EUR < 1 000 EUR remaining, so the
// guard is skipped entirely.
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
mockFindFiscalPeriod.mockResolvedValue('fp-1')
mockCreateJournalEntry.mockResolvedValue({ id: 'je-eur-partial' })
const paidHandler = vi.fn()
eventBus.on('invoice.paid', paidHandler)
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
body: {
lines: [
{ account_number: '1930', debit_amount: 5748.35, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 5748.35 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
status: string
paid_amount: number
remaining_amount: number
journal_entry_id: string
}>(response)
expect(status).toBe(200)
expect(body.status).toBe('partially_paid')
// Both in EUR, never 5 748,35 and never a negative remainder.
expect(body.paid_amount).toBe(500)
expect(body.remaining_amount).toBe(500)
expect(body.journal_entry_id).toBe('je-eur-partial')
// The load-bearing assertion for the guard fix: the guard compares in
// invoice currency now, so a 500 EUR payment on a 1 000 EUR remaining is a
// partial and the transactions scan never runs. Comparing the raw SEK
// 5 748,35 against 1 000 read it as a full settlement and probed.
expect(mockSupabase.from).not.toHaveBeenCalledWith('transactions')
// The event carries the invoice-currency amount, matching the ledger.
expect(paidHandler).toHaveBeenCalledWith(
expect.objectContaining({ paymentAmount: 500 }),
)
})
it('still runs the duplicate guard when the converted SEK lines settle a EUR invoice in full', async () => {
// The complement of the test above: 11 496,70 kr at 11,4967 is exactly the
// 1 000 EUR remaining, so this IS a full settlement and the advisory must
// still fire. Converting for the comparison must not disable the guard on
// foreign-currency invoices.
const customer = makeCustomer()
const invoice = makeInvoice({
id: 'inv-1',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.4967,
total: 1000,
remaining_amount: 1000,
customer,
})
enqueue({ data: invoice, error: null })
// merchant_name ILIKE probe: the bank row is in kronor, because the
// candidate lookup scans transactions.amount, which is SEK.
enqueue({
data: [
{
id: 'tx-eur',
date: '2026-05-10',
amount: 11496.7,
description: 'Inbetalning Test AB',
merchant_name: 'Test AB',
reference: null,
},
],
error: null,
})
// description ILIKE probe: no additional match.
enqueue({ data: [], error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
body: {
lines: [
{ account_number: '1930', debit_amount: 11496.7, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 11496.7 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { candidates: Array<{ id: string }> } }
}>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_PAID_LIKELY_DUPLICATE')
expect(body.error.details.candidates[0].id).toBe('tx-eur')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 MATCH_INVOICE_BOOKING_RATE_MISSING when a EUR invoice carries no exchange rate', async () => {
// 11 496,70 kr against a 1 000 EUR invoice with no rate on file. Defaulting
// the rate to 1 would read the payment as 11 496,70 EUR.
const invoice = makeInvoice({
id: 'inv-1',
status: 'sent',
currency: 'EUR',
exchange_rate: null,
total: 1000,
remaining_amount: 1000,
})
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
body: {
lines: [
{ account_number: '1930', debit_amount: 11496.7, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 11496.7 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string; details?: unknown } }>(response)
expect(status).toBe(400)
// Same code the bank-match path throws for the same condition
// (lib/bookkeeping/invoice-payment-lines.ts).
expect(body.error.code).toBe('MATCH_INVOICE_BOOKING_RATE_MISSING')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
})
it('refuses a sub-remaining SEK payment on a rate-less EUR invoice instead of booking kronor as euro', async () => {
// The silent-corruption direction: 500 kr against a 1 000 EUR invoice sits
// below the invoice-currency remaining, so no overpayment guard catches it.
// With a rate-1 fallback it recorded 500 EUR paid and left 500 EUR
// remaining, when the customer had really paid ~43 EUR.
const invoice = makeInvoice({
id: 'inv-1',
status: 'sent',
currency: 'EUR',
exchange_rate: null,
total: 1000,
remaining_amount: 1000,
})
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
body: {
lines: [
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 500 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('MATCH_INVOICE_BOOKING_RATE_MISSING')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('leaves a rate-less EUR invoice payable when no custom lines are supplied', async () => {
// The default path pays remaining_amount, which is already in invoice
// currency: no conversion happens, so no rate is required.
const invoice = makeInvoice({
id: 'inv-1',
status: 'sent',
currency: 'EUR',
exchange_rate: null,
total: 1000,
remaining_amount: 1000,
})
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-eur-full' })
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
status: string
paid_amount: number
remaining_amount: number
}>(response)
expect(status).toBe(200)
expect(body.status).toBe('paid')
expect(body.paid_amount).toBe(1000)
expect(body.remaining_amount).toBe(0)
})
})
+49 -15
View File
@@ -116,7 +116,37 @@ export const POST = withRouteContext(
const paymentAmount = customLines
? customLines.reduce((s, l) => s + l.debit_amount, 0)
: remainingAmount
const paidRounded = Math.round(paymentAmount * 100) / 100
// Unit contract: total / paid_amount / remaining_amount are stored in the
// INVOICE currency (total_sek carries the SEK view of total); custom lines
// are journal lines, so they are always SEK. The SEK amount therefore has
// to be converted before it is compared against, or subtracted from, the
// invoice-currency remaining. The default path (no lines) already pays the
// remaining in invoice currency and needs no rate at all.
const isForeignCurrency = !!invoice.currency && invoice.currency !== 'SEK'
const needsFxConversion = isForeignCurrency && customLines !== undefined
const fxRate =
invoice.exchange_rate && invoice.exchange_rate > 0 ? invoice.exchange_rate : null
if (needsFxConversion && fxRate === null) {
// Never fall back to rate 1: that reads an 11 496,70 kr payment against a
// 1 000 EUR invoice as 11 496,70 EUR and corrupts the AR sub-ledger.
// Same code as buildInvoicePaymentClearingLines' refusal
// (MATCH_INVOICE_BOOKING_RATE_MISSING, lib/bookkeeping/invoice-payment-lines.ts):
// one condition, one code across every invoice-settlement surface.
opLog.warn('mark-paid rejected: foreign-currency invoice without exchange rate', {
invoiceId: id,
currency: invoice.currency,
})
return errorResponseFromCode('MATCH_INVOICE_BOOKING_RATE_MISSING', opLog, {
requestId,
details: { invoice_id: id, currency: invoice.currency },
})
}
const paymentAmountInInvoiceCurrency = needsFxConversion
? roundOre(paymentAmount / fxRate!)
: paymentAmount
const paidRounded = Math.round(paymentAmountInInvoiceCurrency * 100) / 100
const remainingRounded = Math.round(remainingAmount * 100) / 100
if (!force && paidRounded >= remainingRounded) {
const customerName = (invoice as Invoice & { customer?: { name?: string } }).customer?.name
@@ -126,10 +156,23 @@ export const POST = withRouteContext(
invoiceId: id,
})
} else {
// Invoice currency on purpose. The lookup scans transactions.amount,
// which is denominated in the BANK ROW's currency, not necessarily
// kronor; it therefore takes the payment in invoice currency plus the
// invoice's stored conversion and bands each currency separately.
// Handing it the raw SEK custom-line total would band a kronor figure
// against a EUR column (and vice versa).
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
companyId: companyId!,
invoice: { invoice_number: invoice.invoice_number, customer_name: customerName },
paymentAmount,
invoice: {
invoice_number: invoice.invoice_number,
customer_name: customerName,
currency: invoice.currency ?? null,
total: invoice.total ?? null,
total_sek: invoice.total_sek ?? null,
exchange_rate: invoice.exchange_rate ?? null,
},
paymentAmount: paymentAmountInInvoiceCurrency,
paymentDate,
})
if (candidates.length > 0) {
@@ -157,18 +200,9 @@ export const POST = withRouteContext(
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// paymentAmount and the duplicate-payment guard above operate in the
// booking currency (SEK for custom lines); convert to invoice currency for
// the ledger comparison so a foreign-currency invoice isn't falsely
// rejected as overpaid.
const fxRate =
invoice.currency && invoice.currency !== 'SEK' && invoice.exchange_rate
? invoice.exchange_rate
: 1
const paymentAmountInInvoiceCurrency = customLines
? roundOre(paymentAmount / fxRate)
: paymentAmount
// paymentAmountInInvoiceCurrency was resolved above, before the
// duplicate-payment guard, so the guard comparison and the ledger math run
// in the same unit as remaining_amount.
const result = await settleInvoicePayment(supabase, companyId!, user.id, {
invoice: invoice as Invoice & { customer?: { name?: string | null } | null },
paymentAmountInInvoiceCurrency,
@@ -0,0 +1,403 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse, createMockRouteParams, makeInvoice } from '@/tests/helpers'
/**
* Minimal recording Supabase mock: the queue behaviour of
* createQueuedMockSupabase plus captured table/op/payload/filters, so the
* "only the SEK columns are rewritten" contract can actually be asserted.
*/
interface QueryResult {
data: unknown
error: unknown
}
interface RecordedQuery {
table: string
op: 'select' | 'update' | 'insert' | 'upsert' | 'delete'
payload?: unknown
filters: Record<string, unknown>
}
function createRecordingSupabase() {
const queue: QueryResult[] = []
const recorded: RecordedQuery[] = []
const enqueue = (r: { data?: unknown; error?: unknown }) =>
queue.push({ data: r.data ?? null, error: r.error ?? null })
const reset = () => {
queue.length = 0
recorded.length = 0
}
const from = (table: string) => {
const result = queue.shift() ?? { data: null, error: null }
const rec: RecordedQuery = { table, op: 'select', filters: {} }
recorded.push(rec)
const api: unknown = new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (onFulfilled: (v: QueryResult) => void) => onFulfilled(result)
}
return (...args: unknown[]) => {
if (prop === 'update' || prop === 'insert' || prop === 'upsert') {
rec.op = prop
rec.payload = args[0]
} else if (prop === 'delete') {
rec.op = 'delete'
} else if (prop === 'eq' || prop === 'is') {
rec.filters[String(args[0])] = args[1]
}
return api
}
},
},
)
return api
}
const supabase = {
from: vi.fn(from),
rpc: vi.fn(),
auth: { getUser: vi.fn() },
}
return { supabase, enqueue, reset, recorded }
}
const { supabase: mockSupabase, enqueue, reset, recorded } = createRecordingSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockFetchExchangeRate = vi.fn()
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
}))
const mockResolvePeriodStatus = vi.fn()
vi.mock('@/lib/core/bookkeeping/period-service', () => ({
resolvePeriodStatusForDate: (...args: unknown[]) => mockResolvePeriodStatus(...args),
}))
import { POST } from '../route'
const INVOICE_ID = '11111111-2222-4333-8444-555555555555'
const sentEurInvoice = makeInvoice({
id: INVOICE_ID,
status: 'sent',
invoice_number: 'F-2026001',
currency: 'EUR',
invoice_date: '2026-06-15',
delivery_date: null,
subtotal: 1000,
vat_amount: 0,
total: 1000,
subtotal_sek: null,
vat_amount_sek: null,
total_sek: null,
exchange_rate: null,
exchange_rate_date: null,
journal_entry_id: null,
})
function post(id = INVOICE_ID) {
const request = createMockRequest(`/api/invoices/${id}/refresh-exchange-rate`, { method: 'POST' })
return POST(request, createMockRouteParams({ id }))
}
describe('POST /api/invoices/[id]/refresh-exchange-rate', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockResolvePeriodStatus.mockResolvedValue({ period_id: 'fp-1', status: 'open', lock_date: null })
mockFetchExchangeRate.mockResolvedValue({ currency: 'EUR', rate: 11.5, date: '2026-06-15' })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const { status, body } = await parseJsonResponse(await post())
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 400 when the invoice id is not a uuid', async () => {
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await post('not-a-uuid'),
)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
// Rejected before any DB work, including the sandbox lookup.
expect(mockSupabase.from).not.toHaveBeenCalled()
})
it('returns 404 when the invoice does not exist for the company', async () => {
enqueue({ data: { is_sandbox: false } })
enqueue({ data: null, error: { message: 'Not found' } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(404)
expect(body.error.code).toBe('INVOICE_NOT_FOUND')
})
it('fills in the taxable-event rate on an unbooked SENT invoice without touching its currency amounts', async () => {
enqueue({ data: { is_sandbox: false } })
enqueue({ data: sentEurInvoice })
enqueue({ data: [] }) // journal_entries: nothing references the invoice
enqueue({
data: [{ ...sentEurInvoice, exchange_rate: 11.5, exchange_rate_date: '2026-06-15', total_sek: 11500 }],
})
const { status, body } = await parseJsonResponse<{ data: { exchange_rate: number } }>(await post())
expect(status).toBe(200)
expect(body.data.exchange_rate).toBe(11.5)
// The rate is fetched for the invoice date (the taxable event here, since
// delivery_date is null) and WITH the supabase client, so the shared
// exchange_rates cache backs both the read-through and the 429 fallback.
expect(mockFetchExchangeRate).toHaveBeenCalledTimes(1)
const [currency, date, client] = mockFetchExchangeRate.mock.calls[0]
expect(currency).toBe('EUR')
expect((date as Date).toISOString().slice(0, 10)).toBe('2026-06-15')
expect(client).toBe(mockSupabase)
const update = recorded.find((q) => q.op === 'update')
expect(update?.table).toBe('invoices')
expect(update?.payload).toEqual({
exchange_rate: 11.5,
exchange_rate_date: '2026-06-15',
subtotal_sek: 11500,
vat_amount_sek: 0,
total_sek: 11500,
updated_at: expect.any(String),
})
// The debt stays denominated in the invoice currency.
const payloadKeys = Object.keys(update?.payload as Record<string, unknown>)
expect(payloadKeys).not.toContain('subtotal')
expect(payloadKeys).not.toContain('vat_amount')
expect(payloadKeys).not.toContain('total')
expect(payloadKeys).not.toContain('remaining_amount')
expect(payloadKeys).not.toContain('currency')
// TOCTOU guard: the write loses to a concurrent send/book.
expect(update?.filters).toMatchObject({ journal_entry_id: null, company_id: 'company-1' })
})
it('uses delivery_date as the rate date when it differs from the invoice date', async () => {
enqueue({ data: { is_sandbox: false } })
enqueue({ data: { ...sentEurInvoice, delivery_date: '2026-05-20' } })
enqueue({ data: [] })
enqueue({ data: [sentEurInvoice] })
await post()
const [, date] = mockFetchExchangeRate.mock.calls[0]
expect((date as Date).toISOString().slice(0, 10)).toBe('2026-05-20')
})
it('refuses a booked invoice and points at the rättelse tracks', async () => {
enqueue({ data: { is_sandbox: false } })
enqueue({ data: { ...sentEurInvoice, journal_entry_id: 'je-1' } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_FX_REFRESH_BOOKED')
// Nothing was fetched or written: the SEK amounts are in a verifikat.
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
expect(recorded.some((q) => q.op === 'update')).toBe(false)
})
it('refuses when a verifikat references the invoice even though journal_entry_id is null (legacy rows)', async () => {
enqueue({ data: { is_sandbox: false } })
enqueue({ data: sentEurInvoice })
enqueue({ data: [{ id: 'je-9', voucher_series: 'A', voucher_number: 17 }] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_FX_REFRESH_BOOKED')
expect(recorded.some((q) => q.op === 'update')).toBe(false)
})
it('refuses when the fiscal period covering the invoice date is locked', async () => {
mockResolvePeriodStatus.mockResolvedValue({
period_id: 'fp-1',
status: 'locked',
lock_date: '2026-06-30',
})
enqueue({ data: { is_sandbox: false } })
enqueue({ data: sentEurInvoice })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_FX_REFRESH_PERIOD_LOCKED')
expect(mockResolvePeriodStatus).toHaveBeenCalledWith(mockSupabase, 'company-1', '2026-06-15')
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
expect(recorded.some((q) => q.op === 'update')).toBe(false)
})
it('refuses fail-closed when the period lock state could not be read', async () => {
mockResolvePeriodStatus.mockResolvedValue({
period_id: null,
status: 'locked',
lock_date: null,
lookup_failed: true,
})
enqueue({ data: { is_sandbox: false } })
enqueue({ data: sentEurInvoice })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { lookup_failed: boolean } }
}>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_FX_REFRESH_PERIOD_LOCKED')
expect(body.error.details.lookup_failed).toBe(true)
})
it('returns 502 and writes nothing when Riksbanken and the cache both fail', async () => {
mockFetchExchangeRate.mockResolvedValue(null)
enqueue({ data: { is_sandbox: false } })
enqueue({ data: sentEurInvoice })
enqueue({ data: [] })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(502)
expect(body.error.code).toBe('INVOICE_FX_REFRESH_RATE_UNAVAILABLE')
// Never an invented rate: the invoice is left untouched and retryable.
expect(recorded.some((q) => q.op === 'update')).toBe(false)
})
it('is a no-op for a SEK invoice', async () => {
enqueue({ data: { is_sandbox: false } })
enqueue({ data: makeInvoice({ id: INVOICE_ID, status: 'sent', currency: 'SEK' }) })
const { status } = await parseJsonResponse(await post())
expect(status).toBe(200)
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
expect(recorded.some((q) => q.op === 'update')).toBe(false)
})
it('writes nothing when the stored rate is already the taxable-event rate', async () => {
enqueue({ data: { is_sandbox: false } })
enqueue({
data: { ...sentEurInvoice, exchange_rate: 11.5, exchange_rate_date: '2026-06-15' },
})
enqueue({ data: [] })
const { status } = await parseJsonResponse(await post())
expect(status).toBe(200)
expect(recorded.some((q) => q.op === 'update')).toBe(false)
})
it('reports the concurrent-booking race instead of silently succeeding', async () => {
enqueue({ data: { is_sandbox: false } })
enqueue({ data: sentEurInvoice })
enqueue({ data: [] })
enqueue({ data: [] }) // update matched 0 rows: journal_entry_id was set meanwhile
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_FX_REFRESH_BOOKED')
})
it('reverts the update with the prior values when a verifikat appears between the guard and the write', async () => {
// TOCTOU: a booking flow that read the invoice at the OLD rate commits
// its journal entry AFTER the source_id guard but BEFORE journal_entry_id
// is stamped on the invoice. The route must detect the entry on the
// post-update recheck, put the previous rate/SEK values back via a CAS on
// what it just wrote, and answer with the booked conflict.
const previouslyRated = {
...sentEurInvoice,
exchange_rate: 11.2,
exchange_rate_date: '2026-06-10',
subtotal_sek: 11200,
vat_amount_sek: 0,
total_sek: 11200,
}
enqueue({ data: { is_sandbox: false } })
enqueue({ data: previouslyRated })
enqueue({ data: [] }) // pre-write guard: no entry references the invoice yet
enqueue({ data: [{ ...previouslyRated, exchange_rate: 11.5 }] }) // guarded update succeeds
enqueue({ data: [{ id: 'je-race' }] }) // post-update recheck: an entry appeared
enqueue({ data: [] }) // revert update
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { journal_entry_id: string; reason: string } }
}>(await post())
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_FX_REFRESH_BOOKED')
expect(body.error.details.journal_entry_id).toBe('je-race')
expect(body.error.details.reason).toBe('booked_concurrently_reverted')
const updates = recorded.filter((q) => q.op === 'update')
expect(updates).toHaveLength(2)
// The revert restores exactly the values read before the update.
expect(updates[1].table).toBe('invoices')
expect(updates[1].payload).toEqual({
exchange_rate: 11.2,
exchange_rate_date: '2026-06-10',
subtotal_sek: 11200,
vat_amount_sek: 0,
total_sek: 11200,
updated_at: expect.any(String),
})
// CAS: the revert only touches the row if it still holds the values this
// request wrote, so a later legitimate write is never clobbered.
expect(updates[1].filters).toMatchObject({
id: INVOICE_ID,
company_id: 'company-1',
exchange_rate: 11.5,
exchange_rate_date: '2026-06-15',
})
})
it('does not revert when the post-update recheck finds no verifikat', async () => {
enqueue({ data: { is_sandbox: false } })
enqueue({ data: sentEurInvoice })
enqueue({ data: [] }) // pre-write guard
enqueue({ data: [{ ...sentEurInvoice, exchange_rate: 11.5 }] }) // update
enqueue({ data: [] }) // recheck: still unbooked
const { status } = await parseJsonResponse(await post())
expect(status).toBe(200)
expect(recorded.filter((q) => q.op === 'update')).toHaveLength(1)
})
it('is blocked in the sandbox', async () => {
enqueue({ data: { is_sandbox: true } })
const { status, body } = await parseJsonResponse<{ sandbox_blocked: boolean }>(await post())
expect(status).toBe(403)
expect(body.sandbox_blocked).toBe(true)
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,263 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { roundOre } from '@/lib/money'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { guardSandbox } from '@/lib/sandbox/guard'
import type { Currency, Invoice } from '@/types'
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* POST /api/invoices/[id]/refresh-exchange-rate
*
* Repair path for a foreign-currency invoice whose SEK conversion is missing
* or was stamped from the wrong day's rate. The invoice twin of
* /api/transactions/[id]/refresh-exchange-rate.
*
* Why it exists: buildInvoiceWriteData fetches the Riksbanken rate at create
* time. One transient 429 there used to leave exchange_rate NULL forever, and
* resolveSekAmount() then books the raw foreign number as if it were kronor
* (1 000 EUR posted as 1 000 kr on 1510/2611). PATCH /api/invoices/[id] cannot
* fix it: that route only accepts drafts, and the invoice is already sent.
* Several structured errors (MATCH_INVOICE_BOOKING_RATE_MISSING,
* BATCH_FX_RATE_MISSING) tell the user to "komplettera fakturans exchange_rate"
* and this is the endpoint that actually does it.
*
* What it changes: only exchange_rate, exchange_rate_date and the three *_sek
* columns. The invoice's own currency amounts (subtotal / vat_amount / total /
* remaining_amount) are never touched: the customer's debt is denominated in
* the invoice currency and re-rating it would silently reprice a sent invoice.
*
* Which rate: the one at the TAXABLE EVENT, per ML 8 kap 21-23 § ("Rate to
* use: at time of taxable event (delivery/supply date or advance payment date,
* not invoice date unless same)"). delivery_date when set, otherwise
* invoice_date, which is the same day by definition when delivery_date is null
* (ML 17 kap 24 § p.7 requires the delivery date on the invoice precisely when
* the two differ).
*
* Booked invoices are REFUSED, never re-rated. Once the invoice has a
* verifikat, the SEK amounts are bokförda poster: changing them is a rättelse
* of a posted entry and BFL 5 kap 5 § allows exactly two tracks for that
* (storno via reverseEntry/correctEntry, or the inline rättelse RPCs). A
* silent UPDATE of the invoice row behind a committed verifikat would also
* desync the invoice from the ledger it produced, which is the worse failure:
* the reports would keep showing the old, wrong SEK value.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.refreshExchangeRate',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
// Defense in depth: a malformed id would otherwise reach Postgres as an
// invalid uuid literal (22P02) and surface as a confusing 404.
if (!UUID_RE.test(id)) {
return errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { field: 'id', reason: 'not_a_uuid' },
})
}
// Riksbanken is a gated external service in the sandbox.
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
// The *_sek columns are projected solely so the post-update compensation
// below can restore the exact prior values if a concurrent booking wins.
const { data: invoice, error: fetchError } = await supabase
.from('invoices')
.select(
'id, status, currency, invoice_date, delivery_date, subtotal, vat_amount, total, exchange_rate, exchange_rate_date, subtotal_sek, vat_amount_sek, total_sek, journal_entry_id',
)
.eq('id', id)
.eq('company_id', companyId)
.single<
Pick<
Invoice,
| 'id'
| 'status'
| 'currency'
| 'invoice_date'
| 'delivery_date'
| 'subtotal'
| 'vat_amount'
| 'total'
| 'exchange_rate'
| 'exchange_rate_date'
| 'subtotal_sek'
| 'vat_amount_sek'
| 'total_sek'
| 'journal_entry_id'
>
>()
if (fetchError || !invoice) {
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
}
// A SEK invoice has no conversion to repair.
if (invoice.currency === 'SEK') {
return NextResponse.json({ data: invoice })
}
// Guard 1: already booked → the SEK values live in a verifikat.
// invoice.journal_entry_id is the primary link, but it was backfilled late
// (see mark-sent) so older booked rows can still carry NULL. The
// journal_entries lookup by source_id is the authoritative check.
if (invoice.journal_entry_id) {
return errorResponseFromCode('INVOICE_FX_REFRESH_BOOKED', log, {
requestId,
details: { invoice_id: id, journal_entry_id: invoice.journal_entry_id },
})
}
const { data: linkedEntries, error: entriesError } = await supabase
.from('journal_entries')
.select('id, voucher_series, voucher_number')
.eq('company_id', companyId)
.eq('source_id', id)
.limit(1)
if (entriesError) {
// Fail closed: an unknown booking state must not authorise a re-rate.
log.error('invoice fx refresh: journal entry lookup failed', entriesError, { invoiceId: id })
return errorResponse(entriesError, log, { requestId })
}
if (linkedEntries && linkedEntries.length > 0) {
return errorResponseFromCode('INVOICE_FX_REFRESH_BOOKED', log, {
requestId,
details: { invoice_id: id, journal_entry_id: linkedEntries[0].id },
})
}
// Guard 2: period locks. The invoice's verifikat is dated invoice_date
// (invoice-entries.ts), so that is the period this repair feeds. Behind a
// lock/close the whole räkenskapsinformation for the period is frozen and
// the only route is storno. resolvePeriodStatusForDate fails CLOSED: a
// failed lookup reports `locked`, which is the answer we want here too.
const periodStatus = await resolvePeriodStatusForDate(supabase, companyId, invoice.invoice_date)
if (periodStatus.status !== 'open') {
return errorResponseFromCode('INVOICE_FX_REFRESH_PERIOD_LOCKED', log, {
requestId,
details: {
invoice_id: id,
date: invoice.invoice_date,
period_status: periodStatus.status,
lock_date: periodStatus.lock_date,
lookup_failed: periodStatus.lookup_failed ?? false,
},
})
}
const rateDate = invoice.delivery_date || invoice.invoice_date
const rate = await fetchExchangeRate(
invoice.currency as Currency,
new Date(rateDate),
supabase,
)
if (!rate) {
return errorResponseFromCode('INVOICE_FX_REFRESH_RATE_UNAVAILABLE', log, {
requestId,
details: { invoice_id: id, currency: invoice.currency, date: rateDate },
})
}
// Idempotent: nothing to write when the stored rate already is the
// taxable-event rate.
if (invoice.exchange_rate === rate.rate && invoice.exchange_rate_date === rate.date) {
return NextResponse.json({ data: invoice })
}
const toSek = (amount: number) => roundOre(amount * rate.rate)
const { data: updated, error: updateError } = await supabase
.from('invoices')
.update({
exchange_rate: rate.rate,
exchange_rate_date: rate.date,
subtotal_sek: toSek(invoice.subtotal),
vat_amount_sek: toSek(invoice.vat_amount),
total_sek: toSek(invoice.total),
updated_at: new Date().toISOString(),
})
.eq('id', id)
.eq('company_id', companyId)
// TOCTOU: a concurrent send/book between the guard above and this write
// must lose, not silently re-rate a now-posted invoice.
.is('journal_entry_id', null)
.select('*')
if (updateError) {
log.error('invoice fx refresh: persist failed', updateError, { invoiceId: id })
return errorResponse(updateError, log, { requestId })
}
if (!updated || updated.length === 0) {
return errorResponseFromCode('INVOICE_FX_REFRESH_BOOKED', log, {
requestId,
details: { invoice_id: id, reason: 'booked_concurrently' },
})
}
// TOCTOU compensation. The `.is('journal_entry_id', null)` CAS above only
// covers the invoice ROW, but a booking flow that read the invoice at the
// OLD rate can commit its journal entry after our journal_entries check
// and before invoices.journal_entry_id is stamped: the verifikat then
// posts at the old rate while the row now stores the new one, the exact
// divergence this route exists to prevent. Re-check by source_id; if an
// entry appeared, restore the prior values via a CAS on the values this
// request just wrote (never clobbering a later legitimate write) and
// report the booked conflict. A perfect fix needs DB-level serialization
// (out of scope); this shrinks the race window from the whole request to
// the few milliseconds between this SELECT and the revert.
const { data: postEntries, error: postCheckError } = await supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('source_id', id)
.limit(1)
if (postCheckError) {
// Cannot tell whether a booking won the race: leave the update in place
// (same residual risk as before this compensation existed) but make the
// blind spot visible in logs.
log.warn('invoice fx refresh: post-update booking recheck failed', {
invoiceId: id,
error: postCheckError.message,
})
} else if (postEntries && postEntries.length > 0) {
const { error: revertError } = await supabase
.from('invoices')
.update({
exchange_rate: invoice.exchange_rate,
exchange_rate_date: invoice.exchange_rate_date,
subtotal_sek: invoice.subtotal_sek ?? null,
vat_amount_sek: invoice.vat_amount_sek ?? null,
total_sek: invoice.total_sek ?? null,
updated_at: new Date().toISOString(),
})
.eq('id', id)
.eq('company_id', companyId)
// CAS on exactly what this request wrote: if someone else already
// changed the rate again, their write wins and nothing is reverted.
.eq('exchange_rate', rate.rate)
.eq('exchange_rate_date', rate.date)
if (revertError) {
log.error('invoice fx refresh: revert after concurrent booking failed', revertError, {
invoiceId: id,
})
}
return errorResponseFromCode('INVOICE_FX_REFRESH_BOOKED', log, {
requestId,
details: {
invoice_id: id,
journal_entry_id: (postEntries[0] as { id: string }).id,
reason: 'booked_concurrently_reverted',
},
})
}
return NextResponse.json({ data: updated[0] })
},
{ requireWrite: true },
)
+10 -22
View File
@@ -7,6 +7,7 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateInvoiceSchema } from '@/lib/api/schemas'
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items'
import type { InvoiceDocumentType } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -237,30 +238,17 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
return errorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctxLog, { requestId })
}
// Replace line items wholesale. A draft has no journal entry or linked docs,
// so delete + reinsert is safe and lets the user add / remove / reorder rows
// freely. invoice_items cascade nothing else.
const { error: deleteItemsError } = await supabase
.from('invoice_items')
.delete()
.eq('invoice_id', id)
if (deleteItemsError) {
ctxLog.error('invoice items delete failed on update', deleteItemsError, { invoiceId: id })
// Replace line items wholesale (shared helper: the v1 REST route and the
// update_invoice commit executor use the same delete + reinsert). A draft
// has no journal entry or linked docs, so full replace is safe and lets
// the user add / remove / reorder rows freely. invoice_items cascade
// nothing else.
const replaced = await replaceInvoiceItems(supabase, id, build.items)
if (!replaced.ok) {
ctxLog.error(`invoice items ${replaced.stage} failed on update`, replaced.error, { invoiceId: id })
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, {
requestId,
details: { pgCode: deleteItemsError.code, pgMessage: getUserErrorMessage(deleteItemsError) },
})
}
const itemsToInsert = build.items.map((item) => ({ ...item, invoice_id: id }))
const { error: itemsError } = await supabase.from('invoice_items').insert(itemsToInsert)
if (itemsError) {
ctxLog.error('invoice items insert failed on update', itemsError, { invoiceId: id })
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, {
requestId,
details: { pgCode: itemsError.code, pgMessage: getUserErrorMessage(itemsError) },
details: { pgCode: replaced.error.code, pgMessage: getUserErrorMessage(replaced.error) },
})
}
+39
View File
@@ -33,6 +33,9 @@ vi.mock('@/lib/invoices/vat-rules', () => ({
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
calculateVat: (...args: unknown[]) => mockCalculateVat(...args),
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
// The builder gates on the permitted set (taxed-where-performed exceptions);
// these route tests only care that the gate reads the stubbed rates.
getPermittedVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
calculateTotal: vi.fn(),
}))
@@ -413,6 +416,42 @@ describe('POST /api/invoices (create credit note)', () => {
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
})
it('returns 400 when invoice is cancelled', async () => {
const original = makeInvoice({ id: VALID_UUID, status: 'cancelled' })
enqueue({ data: original, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
})
// Documents a KNOWN GAP, not a rule. ML (2023:200) 17 kap 22-23 SS permits an
// aendringsfaktura against a part-paid invoice; the app refuses it because
// issueCreditNote() cannot flip a 'partially_paid' original to 'credited' and
// would strand a posted reversing verifikat (see the comment on the guard in
// route.ts and the DECISIONS.md entry). This test exists so lifting the gap
// fails here and forces the coordinated change rather than passing silently.
it('refuses a partially paid invoice (known gap: needs issue-credit-note.ts too)', async () => {
const original = makeInvoice({ id: VALID_UUID, status: 'partially_paid' })
enqueue({ data: original, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
})
it('creates a credit note draft without booking or crediting the original', async () => {
const items = [
{
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
/**
* The public reminder action endpoint feeds /invoice-action/[token], an
* unauthenticated page that tells a customer what to pay. The statutory
* påminnelseavgift (Lag 1981:739) is a krona amount booked 1510/3990 in SEK, so
* it must never be relabelled as the invoice currency nor summed into a
* foreign-currency total.
*/
let queued: { data: unknown; error: unknown } = { data: null, error: null }
vi.mock('@supabase/ssr', () => {
const buildChain = (): unknown =>
new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(queued)
}
return () => buildChain()
},
},
)
return {
createServerClient: vi.fn(() => ({
from: vi.fn(() => buildChain()),
})),
}
})
import { GET } from '../route'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
interface ActionPayload {
currency: string
total: number
interestAmount: number
reminderFee: number
reminderFeeCurrency: string
totalDue: number
feeDueSeparately: number
}
function makeReminderRow(currency: string, total: number) {
return {
id: 'reminder-1',
reminder_level: 1,
sent_at: '2026-05-20T08:00:00Z',
response_type: null,
action_token_used: false,
interest_amount: 10,
interest_rate: 0.105,
interest_from_date: '2026-05-01',
interest_days: 30,
reminder_fee: 60,
invoice: {
id: 'invoice-1',
invoice_number: 'F2026011',
invoice_date: '2026-04-15',
due_date: '2026-05-01',
total,
currency,
status: 'overdue',
customer: { name: 'Erik Andersson' },
},
}
}
async function callGet() {
const response = await GET(
createMockRequest('/api/invoices/reminders/action', {
searchParams: { token: 'tok-1' },
}),
)
return parseJsonResponse<ActionPayload>(response)
}
describe('GET /api/invoices/reminders/action', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 400 without a token', async () => {
queued = { data: null, error: null }
const response = await GET(createMockRequest('/api/invoices/reminders/action'))
expect(response.status).toBe(400)
})
it('returns 404 for an unknown token', async () => {
queued = { data: null, error: { message: 'not found' } }
const { status } = await callGet()
expect(status).toBe(404)
})
it('folds the fee into the total for a SEK invoice (unchanged behaviour)', async () => {
queued = { data: makeReminderRow('SEK', 10_000), error: null }
const { status, body } = await callGet()
expect(status).toBe(200)
expect(body.currency).toBe('SEK')
expect(body.reminderFee).toBe(60)
expect(body.reminderFeeCurrency).toBe('SEK')
expect(body.totalDue).toBe(10_070)
expect(body.feeDueSeparately).toBe(0)
})
it('never sums the SEK fee into a EUR total', async () => {
queued = { data: makeReminderRow('EUR', 1_000), error: null }
const { status, body } = await callGet()
expect(status).toBe(200)
expect(body.currency).toBe('EUR')
// 1000 EUR + 10 EUR interest. The 60 kr fee stays out of the EUR figure:
// 1070 here would demand roughly 690 kr too much on a public page.
expect(body.totalDue).toBe(1_010)
expect(body.feeDueSeparately).toBe(60)
expect(body.reminderFeeCurrency).toBe('SEK')
})
it('always reports the fee currency as SEK so the page cannot label 60 as EUR', async () => {
queued = { data: makeReminderRow('USD', 500), error: null }
const { body } = await callGet()
expect(body.reminderFeeCurrency).toBe('SEK')
expect(body.reminderFee).toBe(60)
expect(body.totalDue).toBe(510)
})
})
+22 -3
View File
@@ -1,5 +1,9 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
import {
calculateReminderAmounts,
REMINDER_FEE_CURRENCY,
} from '@/lib/email/reminder-templates'
// Create a service client (no auth needed - public endpoint with token validation)
function createServiceClient() {
@@ -166,8 +170,18 @@ export async function GET(request: Request) {
const interestAmount = Number(reminder.interest_amount ?? 0)
const reminderFee = Number(reminder.reminder_fee ?? 0)
const totalDue =
Math.round((Number(invoice.total) + interestAmount + reminderFee) * 100) / 100
// The invoice total and the dröjsmålsränta are in the invoice currency; the
// påminnelseavgift is a statutory SEK amount booked 1510/3990 in SEK. They are
// split per currency instead of summed into one scalar: adding 60 kr to a EUR
// total, or relabelling it as 60 EUR, would demand the wrong money from the
// customer on a public page.
const amounts = calculateReminderAmounts({
invoiceTotal: Number(invoice.total),
interestAmount,
reminderFee,
currency: invoice.currency,
})
return NextResponse.json({
invoiceNumber: invoice.invoice_number,
@@ -187,6 +201,11 @@ export async function GET(request: Request) {
interestFromDate: reminder.interest_from_date,
interestDays: reminder.interest_days,
reminderFee,
totalDue,
/** Always 'SEK': the fee is a krona statute, never the invoice currency. */
reminderFeeCurrency: REMINDER_FEE_CURRENCY,
/** In `currency`. Includes the fee only when the invoice is itself in SEK. */
totalDue: amounts.totalDue,
/** In SEK. Non-zero only when the fee must be demanded outside `totalDue`. */
feeDueSeparately: amounts.feeDueSeparately,
})
}
+16
View File
@@ -275,6 +275,22 @@ async function createCreditNote(
return errorResponseFromCode('INVOICE_CREDIT_ALREADY_CREDITED', log, { requestId })
}
// 'partially_paid' is missing from this list and that is a real gap, not a
// rule: an aendringsfaktura per ML (2023:200) 17 kap 22-23 SS references the
// original's loepnummer, and whether the customer has paid nothing, part or
// all of it has no bearing on the right to issue one. It is NOT added here
// alone, because this door is not where the flow ends: issueCreditNote()
// (lib/invoices/issue-credit-note.ts) flips the original to 'credited' with
// the same three-status compare-and-set, and it runs AFTER the reversing
// verifikat is posted. Widening only this check would post an immutable
// voucher and then fail on the status flip, leaving a fully credited invoice
// sitting at 'partially_paid': open in the AR ledger and still chased by
// reminders. Widening it is a coordinated change across the six sites listed
// in DECISIONS.md, with issue-credit-note.ts first.
//
// Genuinely refused either way: 'draft' (never issued, so there is no
// loepnummer for ML 17 kap 22 to reference) and 'cancelled'. 'credited' is
// refused above.
if (!['sent', 'paid', 'overdue'].includes(originalInvoice.status)) {
return errorResponseFromCode('INVOICE_CREDIT_NOT_SENT', log, {
requestId,
@@ -28,9 +28,13 @@ vi.mock('@/lib/auth/require-write', () => ({
const mockGetVatRules = vi.fn()
const mockGetAvailableVatRates = vi.fn()
// Mocked SEPARATELY from getAvailableVatRates on purpose: the picker default and
// the validation gate are two different sets, and the gate must read this one.
const mockGetPermittedVatRates = vi.fn()
vi.mock('@/lib/invoices/vat-rules', () => ({
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
getPermittedVatRates: (...args: unknown[]) => mockGetPermittedVatRates(...args),
}))
vi.mock('@/lib/currency/riksbanken', () => ({
@@ -59,6 +63,13 @@ const validBody = {
items: [{ description: 'Konsulttjänst', quantity: 10, unit: 'tim', unit_price: 1000 }],
}
const DOMESTIC_RATES = [
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
]
function mockDomesticVat() {
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
@@ -66,11 +77,33 @@ function mockDomesticVat() {
momsRuta: '05',
reverseChargeText: null,
})
// Domestically the two sets are identical (getPermittedVatRates only widens
// for a foreign business customer).
mockGetAvailableVatRates.mockReturnValue(DOMESTIC_RATES)
mockGetPermittedVatRates.mockReturnValue(DOMESTIC_RATES)
}
/**
* A VAT-validated EU business: the picker DEFAULT is a single locked 0%
* (huvudregeln, ML 6 kap. 34 §), while the PERMITTED set also carries the
* Swedish rates for the supplies taxed where they are performed. The gate must
* read the permitted set, so the two mocks deliberately disagree here.
*/
function mockEuBusinessVat() {
mockGetVatRules.mockReturnValue({
treatment: 'reverse_charge',
rate: 0,
momsRuta: '39',
reverseChargeText: 'Omvänd skattskyldighet / Reverse charge',
})
mockGetAvailableVatRates.mockReturnValue([
{ rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' },
])
mockGetPermittedVatRates.mockReturnValue([
{ rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' },
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
}
@@ -118,11 +151,13 @@ describe('POST /api/invoices/self-billed', () => {
it('rejects an item VAT rate the customer is not allowed to use', async () => {
mockGetVatRules.mockReturnValue({ treatment: 'standard_25', rate: 25, momsRuta: '05', reverseChargeText: null })
// Domestic-only set: 0% is NOT allowed for this customer.
mockGetAvailableVatRates.mockReturnValue([
const noZero = [
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
])
]
mockGetAvailableVatRates.mockReturnValue(noZero)
mockGetPermittedVatRates.mockReturnValue(noZero)
enqueue({ data: makeCustomer({ id: VALID_UUID }), error: null })
const request = createMockRequest('/api/invoices/self-billed', {
@@ -136,6 +171,63 @@ describe('POST /api/invoices/self-billed', () => {
expect(body.error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION')
})
it('accepts 12% to a VAT-validated EU business (taxed where performed)', async () => {
// A Stockholm hotel night self-billed by a German customer: 12% Swedish VAT
// is lawful even though the picker default for that customer is 0%. Refusing
// it made the sale impossible to register at all.
mockEuBusinessVat()
const customer = makeCustomer({ id: VALID_UUID, customer_type: 'eu_business' })
const created = makeInvoice({
id: 'inv-1',
invoice_number: null,
is_self_billed: true,
external_invoice_number: 'KUND-55012',
total: 11200,
})
enqueue({ data: customer, error: null }) // fetch customer
enqueue({ data: created, error: null }) // insert invoice
enqueue({ data: null, error: null }) // insert items
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
enqueue({ data: null, error: null }) // update journal_entry_id
enqueue({ data: { ...created, customer, items: [], journal_entry_id: 'je-1' }, error: null }) // fetch final
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
const request = createMockRequest('/api/invoices/self-billed', {
method: 'POST',
body: {
...validBody,
items: [{ description: 'Hotellnatt Stockholm', quantity: 10, unit: 'st', unit_price: 1000, vat_rate: 12 }],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
it('still rejects a rate that is not a Swedish VAT rate at all', async () => {
// Widening the permitted set must not turn this into a pass-through: 10% is
// no Swedish rate. Here it never even reaches the domain gate, because
// SelfBillingInvoiceItemSchema only accepts 0/6/12/25.
mockEuBusinessVat()
const request = createMockRequest('/api/invoices/self-billed', {
method: 'POST',
body: {
...validBody,
items: [{ description: 'X', quantity: 1, unit: 'st', unit_price: 100, vat_rate: 10 }],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ type: string }>(response)
expect(status).toBe(400)
expect(body.type).toBe('validation_error')
})
it('creates a self-billed sale, books it (accrual), skips own numbering, and emits invoice.created', async () => {
mockDomesticVat()
const customer = makeCustomer({ id: VALID_UUID, name: 'Stora Bolaget AB' })
+121 -1
View File
@@ -91,7 +91,8 @@ describe('PUT /api/kpi/preferences', () => {
})
it('upserts and returns the stored value on the happy path', async () => {
enqueue({ data: { value: { accountOverrides: { some_kpi: ['3001'] } } } })
enqueue({ data: { value: {} } }) // existing row lookup
enqueue({ data: { value: { accountOverrides: { some_kpi: ['3001'] } } } }) // upsert
const req = createMockRequest('/api/kpi/preferences', {
method: 'PUT',
body: { accountOverrides: { some_kpi: ['3001'] } },
@@ -101,4 +102,123 @@ describe('PUT /api/kpi/preferences', () => {
expect(status).toBe(200)
expect(body.data.accountOverrides.some_kpi).toEqual(['3001'])
})
it('rejects an accountOverrides value that is not an array of strings', async () => {
const req = createMockRequest('/api/kpi/preferences', {
method: 'PUT',
body: { accountOverrides: { some_kpi: 'not-an-array' } },
})
const res = await PUT(req, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
})
/**
* A partial save used to reset every unmentioned setting: mergeWithDefaults()
* fills absent keys with FACTORY defaults, and the merged document was what got
* stored, so saving an account override wiped the user's visible-KPI selection
* and ordering. The merge base is now the STORED row. Asserted on the payload
* handed to upsert(); the shared queued mock proxies every chain method, so the
* payload is captured with a purpose-built recorder.
*/
describe('PUT /api/kpi/preferences merge semantics', () => {
function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) {
const upsertPayloads: Record<string, unknown>[] = []
const upsertOptions: ({ onConflict?: string } | undefined)[] = []
let idx = 0
const makeBuilder = () => {
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', 'maybeSingle', 'single']) {
b[m] = () => b
}
b.upsert = (payload: Record<string, unknown>, options?: { onConflict?: string }) => {
upsertPayloads.push(payload)
upsertOptions.push(options)
return b
}
b.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
return b
}
return { supabase: { from: () => makeBuilder() }, upsertPayloads, upsertOptions }
}
const stored = {
visibleKpis: ['kpi_a'],
kpiOrder: ['kpi_a', 'kpi_b'],
accountOverrides: { kpi_a: ['3001'] },
}
async function put(body: unknown, existing: unknown) {
const { supabase, upsertPayloads, upsertOptions } = createCapturingSupabase([
{ data: existing === undefined ? null : { value: existing } },
{ data: { value: 'ok' } },
])
requireAuthMock.mockResolvedValue({ user: mockUser, supabase, error: null })
const res = await PUT(
createMockRequest('/api/kpi/preferences', { method: 'PUT', body }),
{ params: Promise.resolve({}) },
)
return {
res,
value: upsertPayloads[0]?.value as Record<string, unknown> | undefined,
payload: upsertPayloads[0],
options: upsertOptions[0],
}
}
it('keeps the stored visibleKpis and kpiOrder when only accountOverrides is sent', async () => {
const { res, value } = await put({ accountOverrides: { kpi_b: ['4010'] } }, stored)
expect(res.status).toBe(200)
expect(value).toEqual({
visibleKpis: ['kpi_a'],
kpiOrder: ['kpi_a', 'kpi_b'],
accountOverrides: { kpi_b: ['4010'] },
})
})
it('replaces every key when the dialog sends the complete object', async () => {
const full = {
visibleKpis: ['kpi_z'],
kpiOrder: ['kpi_z'],
accountOverrides: { kpi_z: ['3010'] },
}
const { value } = await put(full, stored)
expect(value).toEqual(full)
})
it('falls back to defaults for a row that has never been written', async () => {
const { res, value } = await put({ visibleKpis: ['kpi_new'] }, undefined)
expect(res.status).toBe(200)
expect(value?.visibleKpis).toEqual(['kpi_new'])
// The other two keys come from the defaults, not from undefined.
expect(value?.kpiOrder).toBeDefined()
expect(value?.accountOverrides).toBeDefined()
})
it('an empty body stores the stored value unchanged', async () => {
const { value } = await put({}, stored)
expect(value).toEqual(stored)
})
it('upserts against the company-scoped unique constraint', async () => {
// Migration 20260330130000 dropped UNIQUE (user_id, extension_id, key) in
// favor of UNIQUE (company_id, extension_id, key). Naming the old trio in
// onConflict makes Postgres reject every save with 42P10, because the
// surviving user_id index is non-unique and cannot arbitrate ON CONFLICT.
const { res, payload, options } = await put({ visibleKpis: ['kpi_a'] }, stored)
expect(res.status).toBe(200)
expect(options?.onConflict).toBe('company_id,extension_id,key')
// The row still carries company scoping + last-writer attribution.
expect(payload).toMatchObject({
company_id: 'company-1',
user_id: 'user-1',
extension_id: 'core/kpi',
key: 'preferences',
})
})
})
+48 -17
View File
@@ -1,5 +1,8 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { sparsePatchBody } from '@/lib/api/sparse-patch'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import type { KPIPreferences } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -7,6 +10,19 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
const EXTENSION_ID = 'core/kpi'
const KEY = 'preferences'
/**
* Deliberately carries no `.default()`: the defaults belong to
* mergeWithDefaults() on the read path, not to the parse of a write. A schema
* default here would resurrect on every save exactly the way a `.partial()`
* schema's defaults do. Keys are all optional so the route can tell "the
* caller set this" from "the caller said nothing about this".
*/
const UpdateKPIPreferencesSchema = z.object({
visibleKpis: z.array(z.string()).optional(),
kpiOrder: z.array(z.string()).optional(),
accountOverrides: z.record(z.string(), z.array(z.string())).optional(),
})
export const GET = withRouteContext('kpi.preferences.get', async (_request, { supabase, companyId }) => {
const { data } = await supabase
.from('extension_data')
@@ -23,26 +39,15 @@ export const GET = withRouteContext('kpi.preferences.get', async (_request, { su
export const PUT = withRouteContext(
'kpi.preferences.update',
async (request, { supabase, companyId, user }) => {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const prefs = body as Partial<KPIPreferences>
const validation = await validateBody(request, sparsePatchBody(UpdateKPIPreferencesSchema))
if (!validation.success) return validation.response
const prefs = validation.data
// Validate account overrides: must be 4-digit numeric strings
if (prefs.accountOverrides) {
for (const [kpiId, accounts] of Object.entries(prefs.accountOverrides)) {
if (!Array.isArray(accounts)) {
return NextResponse.json(
{ error: `accountOverrides.${kpiId} must be an array` },
{ status: 400 }
)
}
for (const acc of accounts) {
if (typeof acc !== 'string' || !/^\d{4}$/.test(acc)) {
if (!/^\d{4}$/.test(acc)) {
return NextResponse.json(
{ error: `Invalid account number "${acc}" in ${kpiId}: must be 4 digits` },
{ status: 400 }
@@ -52,8 +57,34 @@ export const PUT = withRouteContext(
}
}
const merged = mergeWithDefaults(prefs)
// Merge over what is STORED, not over the defaults. mergeWithDefaults()
// fills every absent key with its default value, so saving one setting
// used to reset the other two: a PUT of `{ accountOverrides: … }` wiped
// visibleKpis and kpiOrder back to factory settings. Defaults still apply
// to a row that has never been written (and on the read path), but they no
// longer overwrite a stored choice the caller never mentioned. The
// settings dialog always sends the complete object (its reset button fills
// the draft with getDefaultPreferences() before saving), so its behaviour
// is unchanged; only sparse callers stop losing data.
const { data: existing } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', EXTENSION_ID)
.eq('key', KEY)
.maybeSingle()
const stored = mergeWithDefaults((existing?.value as Partial<KPIPreferences>) ?? {})
const merged: KPIPreferences = { ...stored, ...prefs }
// KPI preferences are COMPANY-scoped, not per-user: the read paths (GET
// above and the KPI report route) filter on (company_id, extension_id,
// key) with no user filter, and the key carries no user id. user_id is
// stored purely as "who wrote this last" attribution. The upsert must
// therefore arbitrate on the company-scoped unique constraint: migration
// 20260330130000 dropped UNIQUE (user_id, extension_id, key) in favor of
// UNIQUE (company_id, extension_id, key), so naming the old column trio
// here makes Postgres fail every save with 42P10 (no matching constraint).
const { data, error } = await supabase
.from('extension_data')
.upsert(
@@ -64,7 +95,7 @@ export const PUT = withRouteContext(
key: KEY,
value: merged,
},
{ onConflict: 'user_id,extension_id,key' }
{ onConflict: 'company_id,extension_id,key' }
)
.select()
.single()
@@ -0,0 +1,251 @@
/**
* Tests for GET /api/reconciliation/bank/unmatched-entries.
*
* Exercises the route through the real withRouteContext wrapper and the REAL
* matcher (tryReconcileTransaction / ledgerLineAmountIn); only the RPC-backed
* candidate fetch is mocked, so the currency guard is tested end to end rather
* than against a stubbed scorer.
*
* The focus is the ranking currency: it is the CASH ACCOUNT's, never the
* transaction's own. Sourcing it from the transaction made
* tryReconcileTransaction's `transaction.currency !== expectedCurrency` guard
* compare the transaction against itself, so it never rejected anything and a
* foreign bank line was ranked against this account's SEK vouchers.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import type { GLLineForMatching } from '@/lib/reconciliation/bank-reconciliation'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
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'),
}))
// Only the candidate fetch is stubbed: the scorer and ledgerLineAmountIn stay
// real so a regression in the currency handling actually fails here.
const fetchGLLinesForMatchingMock = vi.fn()
vi.mock('@/lib/reconciliation/bank-reconciliation', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/reconciliation/bank-reconciliation')>()
return {
...actual,
fetchGLLinesForMatching: (...args: unknown[]) => fetchGLLinesForMatchingMock(...args),
}
})
import { GET } from '../route'
const emptyParams = { params: Promise.resolve({}) }
const TX_ID = '11111111-1111-4111-8111-111111111111'
type RankedLine = GLLineForMatching & { confidence: number }
type Body = { data: RankedLine[]; not_rankable_reason?: string }
function makeGLLine(overrides: Partial<GLLineForMatching> = {}): GLLineForMatching {
return {
line_id: 'line-1',
journal_entry_id: 'je-1',
// The RPC projects the SEK debit/credit columns only: see the currency /
// amount_in_currency note on UnlinkedGLLine.
debit_amount: 0,
credit_amount: 1150,
line_description: null,
entry_date: '2026-03-10',
voucher_number: 42,
voucher_series: 'A',
entry_description: 'Leverantörsbetalning',
source_type: 'manual',
linked_transaction_count: 0,
...overrides,
}
}
/** Queue the cash_accounts lookup the route always performs first. */
function enqueueCashAccount(currency: string | null = 'SEK') {
enqueue({ data: { id: 'cash-1', currency } })
}
/** Queue the transaction lookup the ranked path performs second. */
function enqueueTransaction(overrides: Record<string, unknown> = {}) {
enqueue({
data: {
id: TX_ID,
amount: -1150,
date: '2026-03-10',
currency: 'SEK',
reference: null,
...overrides,
},
})
}
function request(searchParams: Record<string, string>) {
return createMockRequest('/api/reconciliation/bank/unmatched-entries', { searchParams })
}
describe('GET /api/reconciliation/bank/unmatched-entries', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
fetchGLLinesForMatchingMock.mockResolvedValue([])
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(request({ account_number: '1930' }), emptyParams)
expect(response.status).toBe(401)
expect(fetchGLLinesForMatchingMock).not.toHaveBeenCalled()
})
it('rejects an account the company has not registered as a cash account', async () => {
// The route's "not found": an unregistered ledger account is refused rather
// than probed, so this endpoint cannot be used to read arbitrary GL accounts.
enqueue({ data: null })
const response = await GET(request({ account_number: '1510' }), emptyParams)
expect(response.status).toBe(400)
expect(fetchGLLinesForMatchingMock).not.toHaveBeenCalled()
})
it('returns no candidates when transaction_id does not resolve in this company', async () => {
enqueueCashAccount('SEK')
enqueue({ data: null })
fetchGLLinesForMatchingMock.mockResolvedValue([makeGLLine()])
const response = await GET(
request({ account_number: '1930', transaction_id: TX_ID }),
emptyParams,
)
const { status, body } = await parseJsonResponse<Body>(response)
expect(status).toBe(200)
expect(body.data).toEqual([])
})
// ----------------------------------------------------------------
// SEK: the 95% path, byte-for-byte unchanged
// ----------------------------------------------------------------
it('ranks a SEK bank line against SEK vouchers exactly as before', async () => {
enqueueCashAccount('SEK')
enqueueTransaction({ currency: 'SEK', amount: -1150, date: '2026-03-10' })
fetchGLLinesForMatchingMock.mockResolvedValue([
makeGLLine({ line_id: 'line-far', journal_entry_id: 'je-far', entry_date: '2026-02-01' }),
makeGLLine({ line_id: 'line-hit', journal_entry_id: 'je-hit', entry_date: '2026-03-10' }),
])
const response = await GET(
request({ account_number: '1930', transaction_id: TX_ID }),
emptyParams,
)
const { status, body } = await parseJsonResponse<Body>(response)
expect(status).toBe(200)
// Nothing withheld on a SEK account: ledgerLineAmountIn never returns null there.
expect(body.data).toHaveLength(2)
expect(body.not_rankable_reason).toBeUndefined()
// Exact amount + exact date = auto_exact, sorted to the top.
expect(body.data[0].journal_entry_id).toBe('je-hit')
expect(body.data[0].confidence).toBe(0.95)
expect(body.data[1].confidence).toBe(0)
})
it('leaves the unranked list untouched when no transaction_id is passed', async () => {
enqueueCashAccount('SEK')
const lines = [makeGLLine()]
fetchGLLinesForMatchingMock.mockResolvedValue(lines)
const response = await GET(request({ account_number: '1930' }), emptyParams)
const { status, body } = await parseJsonResponse<Body>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
expect(body.data[0]).not.toHaveProperty('confidence')
})
// ----------------------------------------------------------------
// Foreign currency: never ranked against an unconverted SEK ledger leg
// ----------------------------------------------------------------
it('does not rank a foreign bank line against a same-magnitude SEK voucher', async () => {
// A 1 150 EUR payment out of the EUR account. The voucher's 1932 leg holds
// 1 150 in the SEK columns for an unrelated amount of money; the RPC
// projects no currency / amount_in_currency, so there is no rate to convert
// with. Offering it as the settlement would be a coincidence of magnitude.
enqueueCashAccount('EUR')
enqueueTransaction({ currency: 'EUR', amount: -1150, date: '2026-03-10' })
fetchGLLinesForMatchingMock.mockResolvedValue([makeGLLine({ credit_amount: 1150 })])
const response = await GET(
request({ account_number: '1932', transaction_id: TX_ID }),
emptyParams,
)
const { status, body } = await parseJsonResponse<Body>(response)
expect(status).toBe(200)
expect(body.data).toEqual([])
// Honest about WHY the list is empty: candidates exist, they just cannot be
// expressed in the account's currency. Not "no unmatched verifikationer".
expect(body.not_rankable_reason).toBe('gl_lines_missing_currency_amount')
})
it('refuses to rank a bank line denominated in another currency than the account', async () => {
// The tautology this fixes: with the reconciliation currency read off the
// transaction, this EUR row was ranked against the SEK account's vouchers.
enqueueCashAccount('SEK')
enqueueTransaction({ currency: 'EUR', amount: -1150, date: '2026-03-10' })
fetchGLLinesForMatchingMock.mockResolvedValue([makeGLLine({ credit_amount: 1150 })])
const response = await GET(
request({ account_number: '1930', transaction_id: TX_ID }),
emptyParams,
)
const { status, body } = await parseJsonResponse<Body>(response)
expect(status).toBe(200)
expect(body.data).toEqual([])
expect(body.not_rankable_reason).toBe('transaction_currency_mismatch')
})
it('ranks a foreign bank line when the ledger line carries a foreign amount', async () => {
// Proves the filter is per-line, not a blanket "non-SEK account cannot
// rank": the day the RPC projects currency + amount_in_currency, ranking
// resumes with no further change here.
enqueueCashAccount('EUR')
enqueueTransaction({ currency: 'EUR', amount: -100, date: '2026-03-10' })
fetchGLLinesForMatchingMock.mockResolvedValue([
makeGLLine({
// Debit/credit are SEK; the EUR figure lives in amount_in_currency.
credit_amount: 1150,
currency: 'EUR',
amount_in_currency: 100,
}),
])
const response = await GET(
request({ account_number: '1932', transaction_id: TX_ID }),
emptyParams,
)
const { status, body } = await parseJsonResponse<Body>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
expect(body.data[0].confidence).toBe(0.95)
expect(body.not_rankable_reason).toBeUndefined()
})
})
@@ -1,6 +1,10 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { fetchGLLinesForMatching, tryReconcileTransaction } from '@/lib/reconciliation/bank-reconciliation'
import {
fetchGLLinesForMatching,
ledgerLineAmountIn,
tryReconcileTransaction,
} from '@/lib/reconciliation/bank-reconciliation'
import type { Transaction } from '@/types'
export const GET = withRouteContext(
@@ -28,9 +32,11 @@ export const GET = withRouteContext(
// including '1930': the cash_accounts backfill seeds 1930 for every company
// that had a SEK PSD2 account, and the AccountPickerDialog seeds it for new
// companies on first connection.
// `currency` comes along because it, not the transaction's own currency, is
// the unit this account is reconciled in: see the ranking block below.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('id')
.select('id, currency')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
@@ -42,6 +48,11 @@ export const GET = withRouteContext(
)
}
// cash_accounts.currency is NOT NULL in the schema; the fallback only guards
// against a legacy/hand-built row so a missing value can never silently turn
// into "no currency" and disable ranking for a SEK company.
const accountCurrency = (cashAccount.currency as string | null) ?? 'SEK'
const lines = await fetchGLLinesForMatching(supabase, companyId, accountNumber, dateFrom, dateTo, includeMatched)
if (transactionId) {
@@ -62,14 +73,45 @@ export const GET = withRouteContext(
return NextResponse.json({ data: [] })
}
// The reconciliation currency is the CASH ACCOUNT's, never the
// transaction's. Reading it off `tx` made tryReconcileTransaction's
// `transaction.currency !== expectedCurrency` guard compare the
// transaction against itself: a tautology that passed for every row, so
// the guard never rejected anything and a foreign bank line was ranked
// against this account's (SEK) vouchers.
const txCurrency = (tx.currency as string | null) ?? 'SEK'
if (txCurrency !== accountCurrency) {
// A bank line in another currency cannot be settled on this account, so
// there is no honest ranking to produce. Same posture as the unresolved
// transaction above: no candidates beats plausible-looking wrong ones.
return NextResponse.json({
data: [],
not_rankable_reason: 'transaction_currency_mismatch',
})
}
// Withhold any candidate that cannot be expressed in the account's
// currency. journal_entry_lines.debit_amount / credit_amount are ALWAYS
// SEK; `currency` on the line labels the underlying DOCUMENT and
// amount_in_currency carries the foreign figure (see ledgerLineAmountIn).
// On a SEK account this filter is a no-op: ledgerLineAmountIn never
// returns null there, so SEK-only companies see exactly the list they
// always did. On a foreign account it currently drops EVERY candidate,
// because get_account_gl_lines_for_matching projects neither currency nor
// amount_in_currency (verified against prod), so no row carries a rate to
// convert with. Ranking them anyway would offer a 1 150 SEK ledger leg as
// the settlement for a 1 150 EUR bank line. Written per-line rather than
// as an `accountCurrency !== 'SEK'` short-circuit so that ranking resumes
// by itself the day the RPC projects those two columns.
const rankable = lines.filter((line) => ledgerLineAmountIn(line, accountCurrency) !== null)
const txDate = tx.date as string
const ranked = lines
const ranked = rankable
.map((line) => {
// Score each line in isolation; confidence 0 means "no auto-match
// rule fired": the line still appears so the user can pick it
// manually (e.g. a salary or Fortnox voucher with a tweaked date).
const match = tryReconcileTransaction(tx as unknown as Transaction, [line], txCurrency)
const match = tryReconcileTransaction(tx as unknown as Transaction, [line], accountCurrency)
return { ...line, confidence: match?.confidence ?? 0 }
})
.sort((a, b) => {
@@ -78,6 +120,15 @@ export const GET = withRouteContext(
const db = Math.abs(new Date(b.entry_date).getTime() - new Date(txDate).getTime())
return da - db
})
// Say so when candidates were withheld, instead of letting an empty list
// read as "this account has no unmatched verifikationer in the period".
if (rankable.length !== lines.length) {
return NextResponse.json({
data: ranked,
not_rankable_reason: 'gl_lines_missing_currency_amount',
})
}
return NextResponse.json({ data: ranked })
}
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { isValidElement } from 'react'
import { NextResponse } from 'next/server'
const mockSupabase = {
@@ -36,9 +37,41 @@ vi.mock('@/lib/reports/ar-ledger', () => ({
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { renderToBuffer } from '@react-pdf/renderer'
const mockUser = { id: 'user-1', email: 'test@test.se' }
/**
* The template renders for real (only renderToBuffer is stubbed), so the
* document handed to it can be walked for its text. Joining the leaves with ''
* reproduces the text of any single <Text>.
*/
function renderedText(node: unknown): string {
const out: string[] = []
const walk = (n: unknown): void => {
if (n === null || n === undefined || typeof n === 'boolean') return
if (typeof n === 'string' || typeof n === 'number') {
out.push(String(n))
return
}
if (Array.isArray(n)) {
n.forEach(walk)
return
}
if (isValidElement(n)) {
walk((n.props as { children?: unknown }).children)
}
}
walk(node)
// Intl's sv-SE group separator is a non-breaking space; normalise every
// space-like character so the assertions below can use ordinary spaces.
return out.join('').replace(/\s/g, ' ')
}
function renderedDocumentText(): string {
return renderedText(vi.mocked(renderToBuffer).mock.calls[0][0])
}
function companySettingsQuery(data: unknown) {
return {
select: vi.fn().mockReturnThis(),
@@ -83,6 +116,71 @@ function makeLedger() {
}
}
/**
* One SEK invoice plus one EUR invoice with a rate, plus one USD invoice with
* no rate (outstanding_sek null, so it is missing from the aging buckets).
* The aging totals are SEK: 1 000 + 11 475.
*/
function makeFxLedger() {
return {
entries: [
{
customer_id: 'cust-1',
customer_name: 'Acme AB',
invoices: [
{
invoice_id: 'inv-1',
invoice_number: 'F001',
invoice_date: '2026-05-01',
due_date: '2026-06-01',
total: 1000,
paid_amount: 0,
outstanding: 1000,
outstanding_sek: 1000,
days_overdue: 14,
currency: 'SEK',
},
{
invoice_id: 'inv-2',
invoice_number: 'F002',
invoice_date: '2026-05-02',
due_date: '2026-06-02',
total: 1000,
paid_amount: 0,
outstanding: 1000,
outstanding_sek: 11475,
days_overdue: 13,
currency: 'EUR',
},
{
invoice_id: 'inv-3',
invoice_number: 'F003',
invoice_date: '2026-05-03',
due_date: '2026-06-03',
total: 500,
paid_amount: 0,
outstanding: 500,
outstanding_sek: null,
days_overdue: 12,
currency: 'USD',
},
],
current: 0,
days_1_30: 12475,
days_31_60: 0,
days_61_90: 0,
days_90_plus: 0,
total_outstanding: 12475,
},
],
total_outstanding: 12475,
total_current: 0,
total_overdue: 12475,
unpaid_count: 3,
unconverted_fx_count: 1,
}
}
function makeRequest(query = '') {
return new Request(`http://localhost/api/reports/ar-ledger/pdf${query}`)
}
@@ -149,4 +247,39 @@ describe('GET /api/reports/ar-ledger/pdf', () => {
const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(500)
})
it('forwards outstanding_sek so the PDF carries the SEK bridge column', async () => {
vi.mocked(generateARLedger).mockResolvedValue(makeFxLedger() as never)
const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(200)
const text = renderedDocumentText()
// The bridge column the XLSX export has ("Utestående (SEK)") now exists here too.
expect(text).toContain('Utest. SEK')
expect(text).toContain('11 475,00')
// ...and the two units on the page are named rather than left implicit.
expect(text).toContain('Åldersfördelning per kund (SEK)')
expect(text).toContain('Fakturor (fakturans valuta)')
})
it('marks an unconvertible FX invoice instead of dropping it from the PDF', async () => {
vi.mocked(generateARLedger).mockResolvedValue(makeFxLedger() as never)
await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
const text = renderedDocumentText()
expect(text).toContain('F003')
expect(text).toContain('saknas')
expect(text).toContain('1 faktura i utländsk valuta saknar växelkurs')
})
it('leaves a SEK-only PDF without the bridge column', async () => {
const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(200)
const text = renderedDocumentText()
expect(text).not.toContain('Utest. SEK')
expect(text).toContain('Fakturor (SEK)')
})
})
+6
View File
@@ -39,6 +39,12 @@ export const GET = withRouteContext('report.ar_ledger.pdf', async (request, { su
invoice_date: inv.invoice_date,
due_date: inv.due_date,
outstanding: inv.outstanding,
// The SEK bridge: `outstanding` is in the invoice's own currency
// while the aging table above it is in SEK, so the PDF needs the
// converted amount to let a reader tie the two together. `null`
// means the FX rate was missing, i.e. the row is absent from the
// aging totals. Matches the XLSX export's "Utestående (SEK)".
outstanding_sek: inv.outstanding_sek,
currency: inv.currency,
days_overdue: inv.days_overdue,
})
@@ -0,0 +1,217 @@
/**
* Contract tests for the kassaflödesanalys PDF route.
*
* Two of these are load-bearing for the client. `KassaflodesanalysClient` now
* fetches this route with `downloadFile` instead of assigning
* `window.location.href`, which means:
*
* 1. The client names the saved file itself, as
* `kassaflodesanalys-<report.period_start>.pdf`. It reads period_start from
* the sibling JSON route, which returns the same generator output this
* route renders, so the two names agree. The Content-Disposition assertion
* below pins the server half of that agreement: change the filename here
* and the archived statutory artefact silently gets a different name than
* the one the browser writes.
* 2. The route's error bodies are now read and shown in a toast instead of
* being rendered as a raw JSON document after the browser navigated the
* whole app away. `getErrorMessage` must therefore find a real sentence in
* them rather than falling back to the generic HTTP status text, which is
* what the last test checks.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { getErrorMessage } from '@/lib/errors/get-error-message'
const mockSupabase = {
auth: { getUser: vi.fn() },
from: vi.fn(),
}
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
// Stub the renderer so no real PDF layout runs. The primitives the template
// imports at module load (StyleSheet.create) still have to exist.
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('%PDF-1.4 test')),
StyleSheet: { create: (s: unknown) => s },
Document: (p: unknown) => p,
Page: (p: unknown) => p,
Text: (p: unknown) => p,
View: (p: unknown) => p,
}))
vi.mock('@/lib/reports/kassaflodesanalys', () => ({
generateKassaflodesanalys: vi.fn(),
}))
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
const mockUser = { id: 'user-1', email: 'test@test.se' }
const PERIOD = { period_start: '2025-01-01', period_end: '2025-12-31' }
function makeReport() {
return {
fiscal_period_id: 'period-1',
...PERIOD,
lopande: {
resultat_efter_finansiella_poster: 120000,
avskrivningar: 20000,
ovriga_ej_kassaflodesposter: 0,
delta_kortfristiga_fordringar: -5000,
delta_varulager: 0,
delta_kortfristiga_skulder: 3000,
skatt_betald: -25000,
total: 113000,
},
investerings: {
forvarv_anlaggningar: -40000,
avyttring_anlaggningar: 0,
total: -40000,
},
finansierings: {
delta_lan: 0,
utdelningar: -30000,
nyemission: 0,
erhallna_aktieagartillskott: 0,
total: -30000,
},
total_cash_flow: 43000,
reconciliation: {
opening_cash_1xxx: 100000,
closing_cash_1xxx: 143000,
delta_actual: 43000,
delta_calculated: 43000,
mismatch_amount: 0,
is_reconciled: true,
},
}
}
/** One chain object serves both queries: select/eq chain, single resolves. */
function tableQuery(data: unknown) {
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data, error: null }),
}
}
let periodRow: unknown = PERIOD
let companyRow: unknown = { company_name: 'Testbolaget AB', org_number: '5566778899' }
function makeRequest(query = '') {
return new Request(`http://localhost/api/reports/kassaflodesanalys/pdf${query}`)
}
function call(query = '') {
return GET(makeRequest(query), { params: Promise.resolve({}) } as never)
}
describe('GET /api/reports/kassaflodesanalys/pdf', () => {
beforeEach(() => {
vi.clearAllMocks()
periodRow = PERIOD
companyRow = { company_name: 'Testbolaget AB', org_number: '5566778899' }
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: mockSupabase as never,
error: null,
})
mockSupabase.from.mockImplementation((table: string) =>
tableQuery(table === 'fiscal_periods' ? periodRow : companyRow),
)
vi.mocked(generateKassaflodesanalys).mockResolvedValue(makeReport() as never)
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: mockSupabase as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await call('?period_id=period-1')
expect(res.status).toBe(401)
expect(generateKassaflodesanalys).not.toHaveBeenCalled()
})
it('returns 400 when period_id is missing', async () => {
const res = await call()
expect(res.status).toBe(400)
expect(generateKassaflodesanalys).not.toHaveBeenCalled()
})
it('refuses to render when the fiscal period cannot be read', async () => {
// An identifiable period is part of räkenskapsinformation (BFL 7 kap): a
// PDF that cannot be archived with the period it refers to is not produced.
periodRow = null
const res = await call('?period_id=missing')
expect(res.status).toBe(400)
expect(generateKassaflodesanalys).not.toHaveBeenCalled()
})
it('returns 404 when company settings are missing', async () => {
companyRow = null
const res = await call('?period_id=period-1')
expect(res.status).toBe(404)
expect(generateKassaflodesanalys).not.toHaveBeenCalled()
})
it('names the file the way the client saves it', async () => {
const res = await call('?period_id=period-1')
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
// KassaflodesanalysClient builds exactly this name from the report it
// already holds. Both sides read period_start from the same generator
// output, so this literal is the contract between them.
expect(res.headers.get('Content-Disposition')).toBe(
'attachment; filename="kassaflodesanalys-2025-01-01.pdf"',
)
expect(generateKassaflodesanalys).toHaveBeenCalledWith(mockSupabase, 'company-1', 'period-1')
})
it('returns 500 when the generator throws', async () => {
vi.mocked(generateKassaflodesanalys).mockRejectedValue(new Error('boom'))
const res = await call('?period_id=period-1')
expect(res.status).toBe(500)
})
it('answers failures with a body the download toast can show', async () => {
// The client passes the parsed body to getErrorMessage. If a body carried
// no recognisable sentence, the user would get the generic status text
// ("Förfrågan innehåller ogiltiga uppgifter") and learn nothing about which
// period or setting is actually missing.
periodRow = null
const badPeriod = await call('?period_id=missing')
const badPeriodBody = await badPeriod.json()
expect(getErrorMessage(badPeriodBody, { statusCode: 400 })).toContain(
'Räkenskapsperioden kunde inte läsas',
)
periodRow = PERIOD
companyRow = null
const noSettings = await call('?period_id=period-1')
const noSettingsBody = await noSettings.json()
expect(getErrorMessage(noSettingsBody, { statusCode: 404 })).toBe(
'Företagsinställningar saknas',
)
})
})
+132 -3
View File
@@ -105,12 +105,63 @@ const PAID_INVOICES = Array.from({ length: 5 }, () => ({
paid_at: '2026-01-11',
}))
function supplierRow(
overrides: Partial<{
supplier_id: string
total: number
total_sek: number | null
currency: string
exchange_rate: number | null
supplier: { id: string; name: string }
}> = {}
) {
const supplierId = overrides.supplier_id ?? 'sup-1'
return {
supplier_id: supplierId,
total: 100,
total_sek: null,
currency: 'SEK',
exchange_rate: null,
supplier: { id: supplierId, name: 'Leverantören AB' },
...overrides,
}
}
const SUPPLIER_ROWS = [
{ supplier_id: 'sup-1', total_sek: 400, total: 400, supplier: { id: 'sup-1', name: 'Leverantören AB' } },
{ supplier_id: 'sup-1', total_sek: 100, total: 100, supplier: { id: 'sup-1', name: 'Leverantören AB' } },
{ supplier_id: 'sup-2', total_sek: 200, total: 200, supplier: { id: 'sup-2', name: 'Andra AB' } },
supplierRow({ supplier_id: 'sup-1', total: 400, total_sek: 400 }),
supplierRow({ supplier_id: 'sup-1', total: 100, total_sek: 100 }),
supplierRow({
supplier_id: 'sup-2',
total: 200,
total_sek: 200,
supplier: { id: 'sup-2', name: 'Andra AB' },
}),
]
/**
* The realistic shape of an ordinary Swedish supplier invoice: currency SEK,
* no exchange rate, and `total_sek` NULL because no conversion ever ran.
*/
const SEK_ONLY_ROWS = [
supplierRow({ supplier_id: 'sup-1', total: 1250, supplier: { id: 'sup-1', name: 'Städbolaget AB' } }),
supplierRow({ supplier_id: 'sup-1', total: 750.5, supplier: { id: 'sup-1', name: 'Städbolaget AB' } }),
supplierRow({
supplier_id: 'sup-2',
total: 400,
supplier: { id: 'sup-2', name: 'Kontorsvaror AB' },
}),
]
/** Queue the six responses the hot path consumes after the fiscal period. */
function enqueueHotPath(supplierRows: unknown[]) {
enqueue({ data: aggPayload() }) // rpc get_kpi_report_aggregates
enqueue({ data: [] }) // rpc compute_prior_opening_balances
enqueue({ data: CHART }) // chart_of_accounts
enqueue({ data: null }) // extension_data prefs
enqueue({ data: [] }) // invoices
enqueue({ data: supplierRows }) // supplier_invoices
}
function kpiRequest(searchParams: Record<string, string> = { period_id: 'period-1' }) {
return createMockRequest('/api/reports/kpi', { searchParams })
}
@@ -186,6 +237,7 @@ describe('GET /api/reports/kpi', () => {
{ supplier_id: 'sup-1', supplier_name: 'Leverantören AB', total: 500 },
{ supplier_id: 'sup-2', supplier_name: 'Andra AB', total: 200 },
],
topSuppliersUnconvertedFxCount: 0,
})
expect(supabase.rpc).toHaveBeenCalledWith('get_kpi_report_aggregates', {
@@ -279,6 +331,83 @@ describe('GET /api/reports/kpi', () => {
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('populates Största leverantörer for a SEK-only company whose total_sek is NULL', async () => {
// Headline regression: total_sek is only written when a conversion runs,
// so an ordinary Swedish supplier invoice has it NULL. Reading total_sek
// alone left the panel permanently empty for these companies.
enqueue({ data: makePeriod() }) // fiscal_periods
enqueueHotPath(SEK_ONLY_ROWS)
const res = await GET(kpiRequest(), noParams)
const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res)
expect(status).toBe(200)
expect(body.data.topSuppliers).toEqual([
{ supplier_id: 'sup-1', supplier_name: 'Städbolaget AB', total: 2000.5 },
{ supplier_id: 'sup-2', supplier_name: 'Kontorsvaror AB', total: 400 },
])
expect(body.data.topSuppliersUnconvertedFxCount).toBe(0)
})
it('aggregates a mixed SEK/EUR company in SEK', async () => {
enqueue({ data: makePeriod() }) // fiscal_periods
enqueueHotPath([
// SEK invoice: total is the SEK total, exactly.
supplierRow({ supplier_id: 'sup-1', total: 1000, supplier: { id: 'sup-1', name: 'Svensk Lev AB' } }),
// EUR invoice with a rate: converted at 100 * 11.4567 = 1145.67.
supplierRow({
supplier_id: 'sup-1',
total: 100,
currency: 'EUR',
exchange_rate: 11.4567,
supplier: { id: 'sup-1', name: 'Svensk Lev AB' },
}),
// EUR invoice with a stored SEK total: that value wins.
supplierRow({
supplier_id: 'sup-2',
total: 200,
total_sek: 2300,
currency: 'EUR',
exchange_rate: 11.5,
supplier: { id: 'sup-2', name: 'Euro Supplier GmbH' },
}),
])
const res = await GET(kpiRequest(), noParams)
const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res)
expect(status).toBe(200)
expect(body.data.topSuppliers).toEqual([
{ supplier_id: 'sup-2', supplier_name: 'Euro Supplier GmbH', total: 2300 },
{ supplier_id: 'sup-1', supplier_name: 'Svensk Lev AB', total: 2145.67 },
])
expect(body.data.topSuppliersUnconvertedFxCount).toBe(0)
})
it('reports an unconvertible FX invoice instead of silently dropping it', async () => {
enqueue({ data: makePeriod() }) // fiscal_periods
enqueueHotPath([
supplierRow({ supplier_id: 'sup-1', total: 1000, supplier: { id: 'sup-1', name: 'Svensk Lev AB' } }),
// USD, no rate and no SEK total: cannot be expressed in SEK.
supplierRow({
supplier_id: 'sup-2',
total: 500,
currency: 'USD',
supplier: { id: 'sup-2', name: 'US Vendor Inc' },
}),
])
const res = await GET(kpiRequest(), noParams)
const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res)
expect(status).toBe(200)
// The raw 500 USD is neither added as SEK nor hidden: it is counted.
expect(body.data.topSuppliers).toEqual([
{ supplier_id: 'sup-1', supplier_name: 'Svensk Lev AB', total: 1000 },
])
expect(body.data.topSuppliersUnconvertedFxCount).toBe(1)
})
it('returns 500 when the aggregates RPC fails', async () => {
enqueue({ data: makePeriod() }) // fiscal_periods
enqueue({ data: null, error: { message: 'connection reset' } }) // rpc get_kpi_report_aggregates
+13 -36
View File
@@ -23,6 +23,9 @@ import {
calculateExpenseRatio,
calculateAvgPaymentDays,
calculateVatLiability,
aggregateTopSuppliers,
fetchTopSupplierInvoices,
type KpiSupplierInvoiceRow,
} from '@/lib/reports/kpi'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
@@ -79,14 +82,10 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co
.eq('company_id', companyId)
.eq('status', 'paid')
.not('paid_at', 'is', null)
// Paginated: awaiting the bare query capped the rows at PostgREST's 1000
// default and silently corrupted the supplier totals for large companies.
const topSuppliersQuery = () =>
supabase
.from('supplier_invoices')
.select('supplier_id, total_sek, total, supplier:suppliers(id, name)')
.eq('company_id', companyId)
.gte('invoice_date', period.period_start)
.lte('invoice_date', period.period_end)
.neq('status', 'credited')
fetchTopSupplierInvoices(supabase, companyId, period.period_start, period.period_end)
let prefsValue: unknown
let incomeStatement: IncomeStatementReport
@@ -252,40 +251,17 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co
.sort((a, b) => b.total - a.total)
.slice(0, 5)
// Top suppliers by spend within the fiscal period. Sum total_sek to avoid
// mixing currencies. Drop FX invoices without a SEK conversion (total_sek
// null): they would otherwise inflate a supplier's total with raw
// foreign-currency amounts.
type SupplierInvoiceRow = {
supplier_id: string | null
total_sek: number | null
total: number | null
supplier: { id: string; name: string } | { id: string; name: string }[] | null
}
// Top suppliers by spend within the fiscal period, in SEK. The per-row SEK
// resolution and the FX exclusion count both live in aggregateTopSuppliers,
// which the xlsx export calls with the same query, so the two reports cannot
// disagree about the same company.
if (topSuppliersResult.error) {
// Surface the failure rather than silently rendering an empty chart that
// matches the legitimate "no supplier invoices" empty state.
console.error('[kpi] topSuppliersResult error:', topSuppliersResult.error)
}
const supplierTotals = new Map<string, { name: string; total: number }>()
for (const row of (topSuppliersResult.data ?? []) as SupplierInvoiceRow[]) {
if (!row.supplier_id) continue
const supplier = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier
if (!supplier?.name) continue
const amount = row.total_sek ?? null
if (amount == null) continue
const existing = supplierTotals.get(row.supplier_id)
if (existing) existing.total += amount
else supplierTotals.set(row.supplier_id, { name: supplier.name, total: amount })
}
const topSuppliers = Array.from(supplierTotals.entries())
.map(([supplier_id, v]) => ({
supplier_id,
supplier_name: v.name,
total: Math.round(v.total * 100) / 100,
}))
.sort((a, b) => b.total - a.total)
.slice(0, 7)
const { suppliers: topSuppliers, unconvertedFxCount: topSuppliersUnconvertedFxCount } =
aggregateTopSuppliers((topSuppliersResult.data ?? []) as KpiSupplierInvoiceRow[])
const report: KPIReport = {
netResult: incomeStatement.net_result,
@@ -309,6 +285,7 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co
},
topExpenseAccounts,
topSuppliers,
topSuppliersUnconvertedFxCount,
}
return NextResponse.json({ data: report })
@@ -0,0 +1,266 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import type { KPIReport } from '@/types'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
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'),
}))
// The heavy report generators are stubbed: this suite is about the supplier
// figures. The pure builders the KPI JSON hot path uses stay real so the
// cross-route consistency test compares two genuinely computed reports.
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
vi.mock('@/lib/reports/ar-ledger', () => ({
generateARLedger: vi.fn(),
}))
vi.mock('@/lib/reports/income-statement', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/reports/income-statement')>()
return { ...actual, generateIncomeStatement: vi.fn() }
})
vi.mock('@/lib/reports/monthly-breakdown', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/reports/monthly-breakdown')>()
return { ...actual, generateMonthlyBreakdown: vi.fn() }
})
import { GET } from '../route'
import { GET as GET_JSON } from '../../route'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
const mockTrialBalance = vi.mocked(generateTrialBalance)
const mockARLedger = vi.mocked(generateARLedger)
const mockIncomeStatement = vi.mocked(generateIncomeStatement)
const mockMonthlyBreakdown = vi.mocked(generateMonthlyBreakdown)
const noParams = { params: Promise.resolve({}) }
function authed() {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
}
function unauthed() {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
const PERIOD = {
period_start: '2026-01-01',
period_end: '2026-03-31',
is_closed: false,
}
function supplierRow(
overrides: Partial<{
supplier_id: string
total: number
total_sek: number | null
currency: string
exchange_rate: number | null
supplier: { id: string; name: string }
}> = {}
) {
const supplierId = overrides.supplier_id ?? 'sup-1'
return {
supplier_id: supplierId,
total: 100,
total_sek: null,
currency: 'SEK',
exchange_rate: null,
supplier: { id: supplierId, name: 'Leverantören AB' },
...overrides,
}
}
/**
* An ordinary Swedish supplier invoice: currency SEK, no exchange rate and
* `total_sek` NULL because no conversion ever ran.
*/
const SEK_ONLY_ROWS = [
supplierRow({ supplier_id: 'sup-1', total: 1250, supplier: { id: 'sup-1', name: 'Städbolaget AB' } }),
supplierRow({ supplier_id: 'sup-1', total: 750.5, supplier: { id: 'sup-1', name: 'Städbolaget AB' } }),
supplierRow({
supplier_id: 'sup-2',
total: 400,
supplier: { id: 'sup-2', name: 'Kontorsvaror AB' },
}),
]
const MIXED_ROWS = [
supplierRow({ supplier_id: 'sup-1', total: 1000, supplier: { id: 'sup-1', name: 'Svensk Lev AB' } }),
supplierRow({
supplier_id: 'sup-1',
total: 100,
currency: 'EUR',
exchange_rate: 11.4567,
supplier: { id: 'sup-1', name: 'Svensk Lev AB' },
}),
supplierRow({
supplier_id: 'sup-2',
total: 200,
total_sek: 2300,
currency: 'EUR',
exchange_rate: 11.5,
supplier: { id: 'sup-2', name: 'Euro Supplier GmbH' },
}),
// USD without a rate and without a SEK total: unconvertible.
supplierRow({
supplier_id: 'sup-3',
total: 500,
currency: 'USD',
supplier: { id: 'sup-3', name: 'US Vendor Inc' },
}),
]
function xlsxRequest(searchParams: Record<string, string> = { period_id: 'period-1' }) {
return createMockRequest('/api/reports/kpi/xlsx', { searchParams })
}
/** Queue the four responses the xlsx route consumes, in call order. */
function enqueueXlsx(supplierRows: unknown[]) {
enqueue({ data: PERIOD }) // fiscal_periods
enqueue({ data: { company_name: 'Acme AB' } }) // company_settings
enqueue({ data: [] }) // invoices (paid)
enqueue({ data: supplierRows }) // supplier_invoices
}
/** Parse the produced workbook. The response body can only be read once. */
async function readWorkbook(res: Response): Promise<XLSX.WorkBook> {
const buf = Buffer.from(await res.arrayBuffer())
return XLSX.read(new Uint8Array(buf), { type: 'array' })
}
/** Read one sheet back out of the workbook as label/value pairs. */
function sheetRows(wb: XLSX.WorkBook, sheetName: string): [string, unknown][] {
const sheet = wb.Sheets[sheetName]
expect(sheet, `sheet ${sheetName} missing`).toBeDefined()
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1 })
// Drop the header row.
return rows.slice(1).map((r) => [String(r[0]), r[1]] as [string, unknown])
}
beforeEach(() => {
vi.clearAllMocks()
reset()
authed()
mockARLedger.mockResolvedValue({ total_outstanding: 0, total_overdue: 0 } as never)
mockIncomeStatement.mockResolvedValue({
revenue_sections: [],
total_revenue: 0,
expense_sections: [],
total_expenses: 0,
financial_sections: [],
total_financial: 0,
net_result: 0,
period: { start: '2026-01-01', end: '2026-03-31' },
})
mockTrialBalance.mockResolvedValue({ rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true })
mockMonthlyBreakdown.mockResolvedValue({ months: [] })
})
describe('GET /api/reports/kpi/xlsx', () => {
it('returns 401 when not authenticated', async () => {
unauthed()
const res = await GET(xlsxRequest(), noParams)
expect(res.status).toBe(401)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns 400 when period_id is missing', async () => {
const res = await GET(xlsxRequest({}), noParams)
expect(res.status).toBe(400)
})
it('returns 404 for an unknown fiscal period', async () => {
enqueue({ data: null }) // fiscal_periods
enqueue({ data: null }) // company_settings
const res = await GET(xlsxRequest(), noParams)
const { status } = await parseJsonResponse(res)
expect(status).toBe(404)
})
it('fills Topp leverantörer for a SEK-only company whose total_sek is NULL', async () => {
// Headline regression: reading total_sek alone produced an empty sheet for
// every ordinary Swedish company.
enqueueXlsx(SEK_ONLY_ROWS)
const res = await GET(xlsxRequest(), noParams)
expect(res.status).toBe(200)
expect(sheetRows(await readWorkbook(res), 'Topp leverantörer')).toEqual([
['Städbolaget AB', 2000.5],
['Kontorsvaror AB', 400],
])
})
it('aggregates mixed SEK/EUR rows and reports the unconvertible FX invoice', async () => {
enqueueXlsx(MIXED_ROWS)
const res = await GET(xlsxRequest(), noParams)
expect(res.status).toBe(200)
const wb = await readWorkbook(res)
expect(sheetRows(wb, 'Topp leverantörer')).toEqual([
['Euro Supplier GmbH', 2300],
['Svensk Lev AB', 2145.67],
])
// The 500 USD row is neither converted at a made-up rate nor dropped in
// silence: the export states how many rows it could not convert.
expect(sheetRows(wb, 'Nyckeltal (övrigt)')).toContainEqual([
'Ej omräknade valutafakturor (leverantörer)',
1,
])
})
})
describe('KPI JSON and xlsx agree', () => {
it('reports identical supplier totals for the same company and period', async () => {
// JSON route (hot path) queue order.
enqueue({ data: { id: 'period-1', company_id: 'company-1', ...PERIOD, opening_balance_entry_id: null } })
enqueue({ data: { tb: [], tb_ex_year_end: [], ob: [], monthly: [] } }) // aggregates RPC
enqueue({ data: [] }) // compute_prior_opening_balances
enqueue({ data: [] }) // chart_of_accounts
enqueue({ data: null }) // extension_data prefs
enqueue({ data: [] }) // invoices
enqueue({ data: MIXED_ROWS }) // supplier_invoices
const jsonRes = await GET_JSON(
createMockRequest('/api/reports/kpi', { searchParams: { period_id: 'period-1' } }),
noParams
)
const { body } = await parseJsonResponse<{ data: KPIReport }>(jsonRes)
// xlsx route over the identical supplier rows.
enqueueXlsx(MIXED_ROWS)
const xlsxRes = await GET(xlsxRequest(), noParams)
const wb = await readWorkbook(xlsxRes)
const suppliers = sheetRows(wb, 'Topp leverantörer')
const other = sheetRows(wb, 'Nyckeltal (övrigt)')
expect(suppliers).toEqual(
body.data.topSuppliers.map((s) => [s.supplier_name, s.total])
)
expect(other).toContainEqual([
'Ej omräknade valutafakturor (leverantörer)',
body.data.topSuppliersUnconvertedFxCount,
])
expect(body.data.topSuppliersUnconvertedFxCount).toBe(1)
})
})
+15 -31
View File
@@ -10,6 +10,9 @@ import {
calculateExpenseRatio,
calculateAvgPaymentDays,
calculateVatLiability,
aggregateTopSuppliers,
fetchTopSupplierInvoices,
type KpiSupplierInvoiceRow,
} from '@/lib/reports/kpi'
import {
reportToWorkbook,
@@ -87,13 +90,9 @@ export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabas
.eq('company_id', companyId)
.eq('status', 'paid')
.not('paid_at', 'is', null),
supabase
.from('supplier_invoices')
.select('supplier_id, total_sek, total, supplier:suppliers(id, name)')
.eq('company_id', companyId)
.gte('invoice_date', period.period_start)
.lte('invoice_date', period.period_end)
.neq('status', 'credited'),
// Paginated past PostgREST's 1000-row cap so the export sums every
// supplier invoice in the period, same as the KPI JSON route.
fetchTopSupplierInvoices(supabase, companyId, period.period_start, period.period_end),
])
const cashPosition = calculateCashPosition(trialBalanceResult.rows)
@@ -119,30 +118,12 @@ export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabas
{ class4: 0, class5: 0, class6: 0, class7: 0 },
)
type SupplierInvoiceRow = {
supplier_id: string | null
total_sek: number | null
total: number | null
supplier: { id: string; name: string } | { id: string; name: string }[] | null
}
const supplierTotals = new Map<string, { name: string; total: number }>()
for (const row of (topSuppliersResult.data ?? []) as SupplierInvoiceRow[]) {
if (!row.supplier_id) continue
const supplier = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier
if (!supplier?.name) continue
const amount = row.total_sek ?? null
if (amount == null) continue
const existing = supplierTotals.get(row.supplier_id)
if (existing) existing.total += amount
else supplierTotals.set(row.supplier_id, { name: supplier.name, total: amount })
}
const topSuppliers = Array.from(supplierTotals.values())
.map((v) => ({
supplier_name: v.name,
total: Math.round(v.total * 100) / 100,
}))
.sort((a, b) => b.total - a.total)
.slice(0, 7)
// Same query, same aggregation as the KPI JSON route: both go through
// topSupplierInvoicesQuery + aggregateTopSuppliers, so the export and the
// in-app panel report identical supplier totals for a given period.
const { suppliers: topSuppliers, unconvertedFxCount } = aggregateTopSuppliers(
(topSuppliersResult.data ?? []) as KpiSupplierInvoiceRow[],
)
// Sheet 1: scalar KPIs, label + value. Currency by default; percent rows
// are split into a separate sheet so the formatting is unambiguous.
@@ -166,6 +147,9 @@ export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabas
const integerKpis: KpiKv[] = [
{ label: 'Genomsnittliga betaldagar', value: calculateAvgPaymentDays(paidInvoices) },
// Antal valutafakturor som saknar både SEK-belopp och kurs och därför
// inte kan räknas in i "Topp leverantörer". 0 = inget är exkluderat.
{ label: 'Ej omräknade valutafakturor (leverantörer)', value: unconvertedFxCount },
]
const monthRows: MonthRow[] = monthlyBreakdown.months
@@ -13,7 +13,14 @@ vi.mock('@/lib/company/context', () => ({
}))
vi.mock('@/lib/bookkeeping/currency-utils', () => ({
resolveSekAmount: vi.fn((amount: number) => amount),
resolveSekAmount: vi.fn(
(
amount: number,
_amountSek: number | null,
currency: string | null,
rate: number | null
) => (currency && currency !== 'SEK' && rate ? amount * rate : amount)
),
}))
import { GET } from '../route'
@@ -23,12 +30,20 @@ interface QueryResult {
error: unknown
}
/**
* The invoices query is paginated via fetchAllRows (.range per page), so the
* mock serves one page result per .range() call. A single QueryResult is a
* one-page company; pass an array to exercise multi-page paging.
*/
function buildSupabase(
supplier: { id: string; name: string } | null,
invoicesResult: QueryResult,
invoicesResults: QueryResult | QueryResult[],
entriesResult: QueryResult
) {
return {
const pages = Array.isArray(invoicesResults) ? [...invoicesResults] : [invoicesResults]
const rangeCalls: Array<[number, number]> = []
const orderCalls: unknown[][] = []
const supabase = {
from: vi.fn().mockImplementation((table: string) => {
if (table === 'suppliers') {
return {
@@ -42,9 +57,14 @@ function buildSupabase(
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
then: (resolve: (v: QueryResult) => void) => resolve(invoicesResult),
order: vi.fn().mockImplementation(function (this: unknown, ...args: unknown[]) {
orderCalls.push(args)
return this
}),
range: vi.fn().mockImplementation((from: number, to: number) => {
rangeCalls.push([from, to])
return Promise.resolve(pages.shift() ?? { data: [], error: null })
}),
}
}
// journal_entries
@@ -56,6 +76,7 @@ function buildSupabase(
}
}),
}
return Object.assign(supabase, { rangeCalls, orderCalls })
}
beforeEach(() => {
@@ -149,4 +170,200 @@ describe('GET /api/reports/supplier-ledger/supplier/[supplierId]/invoices', () =
expect(body.data.lines[0].voucher_number).toBe(33)
expect(body.data.lines[0].credit).toBe(2500)
})
it('never reports a foreign amount as SEK when the exchange rate is missing', async () => {
// 1 000 EUR with no rate: there is no SEK figure for the 2440 balance, so
// the Kredit column (always SEK) must not be handed the EUR number.
const invoices = [
{
id: 'si-fx',
supplier_invoice_number: 'EUR-1',
invoice_date: '2026-05-10',
due_date: '2026-06-10',
total: 1000,
paid_amount: 0,
remaining_amount: 1000,
currency: 'EUR',
exchange_rate: null,
registration_journal_entry_id: null,
},
]
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase: buildSupabase(
{ id: 'sup-1', name: 'Euro Supply GmbH' },
{ data: invoices, error: null },
{ data: [], error: null }
),
error: null,
})
const req = createMockRequest(
'/api/reports/supplier-ledger/supplier/sup-1/invoices'
)
const res = await GET(req, createMockRouteParams({ supplierId: 'sup-1' }))
expect(res.status).toBe(200)
const body = (await res.json()) as {
data: {
unconverted_fx_count: number
lines: Array<{
credit: number
remaining: number
remaining_sek: number | null
currency: string
}>
}
}
const line = body.data.lines[0]
// The EUR amount must not leak into the SEK column.
expect(line.credit).not.toBe(1000)
expect(line.credit).toBe(0)
// ... but the invoice stays visible, with its own-currency amount intact.
expect(line.remaining).toBe(1000)
expect(line.currency).toBe('EUR')
// Explicit null marker + count, same contract as the ledger report.
expect(line.remaining_sek).toBeNull()
expect(body.data.unconverted_fx_count).toBe(1)
})
it('lets the consumer tell an unconverted row from a settled one', async () => {
const invoices = [
// Unconvertible: SEK value unknown.
{
id: 'si-fx',
supplier_invoice_number: 'EUR-1',
invoice_date: '2026-05-10',
due_date: '2026-06-10',
total: 1000,
paid_amount: 0,
remaining_amount: 1000,
currency: 'EUR',
exchange_rate: null,
registration_journal_entry_id: null,
},
// Convertible: 100 EUR at 11.50.
{
id: 'si-fx-rate',
supplier_invoice_number: 'EUR-2',
invoice_date: '2026-05-11',
due_date: '2026-06-11',
total: 100,
paid_amount: 0,
remaining_amount: 100,
currency: 'EUR',
exchange_rate: 11.5,
registration_journal_entry_id: null,
},
// Genuinely settled: nothing left on 2440.
{
id: 'si-settled',
supplier_invoice_number: 'SEK-1',
invoice_date: '2026-05-12',
due_date: '2026-06-12',
total: 500,
paid_amount: 500,
remaining_amount: 0,
currency: 'SEK',
exchange_rate: null,
registration_journal_entry_id: null,
},
]
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase: buildSupabase(
{ id: 'sup-1', name: 'Euro Supply GmbH' },
{ data: invoices, error: null },
{ data: [], error: null }
),
error: null,
})
const req = createMockRequest(
'/api/reports/supplier-ledger/supplier/sup-1/invoices'
)
const res = await GET(req, createMockRouteParams({ supplierId: 'sup-1' }))
const body = (await res.json()) as {
data: {
unconverted_fx_count: number
lines: Array<{
supplier_invoice_id: string
credit: number
remaining_sek: number | null
}>
}
}
const byId = new Map(body.data.lines.map((l) => [l.supplier_invoice_id, l]))
// Unknown SEK value: null, not 0.
expect(byId.get('si-fx')!.remaining_sek).toBeNull()
// Settled: 0, not null.
expect(byId.get('si-settled')!.remaining_sek).toBe(0)
// Converted rows are unaffected: still the SEK amount in the Kredit column.
expect(byId.get('si-fx-rate')!.remaining_sek).toBe(1150)
expect(byId.get('si-fx-rate')!.credit).toBe(1150)
expect(body.data.unconverted_fx_count).toBe(1)
})
it('paginates past the old hardcoded cap and counts FX rows over the full set', async () => {
// 1000 SEK invoices fill page one; page two carries one more SEK invoice
// plus an unconvertible FX invoice. The old 500-row limit truncated both
// the lines AND unconverted_fx_count while next_cursor: null claimed the
// list was complete.
const makeInvoice = (i: number) => ({
id: `si-${i}`,
supplier_invoice_number: `INV-${i}`,
invoice_date: '2026-05-10',
due_date: '2026-06-10',
total: 100,
paid_amount: 0,
remaining_amount: 100,
currency: 'SEK',
exchange_rate: null,
registration_journal_entry_id: null,
})
const page1 = Array.from({ length: 1000 }, (_, i) => makeInvoice(i))
const page2 = [
makeInvoice(1000),
{ ...makeInvoice(1001), currency: 'EUR', exchange_rate: null },
]
const supabase = buildSupabase(
{ id: 'sup-1', name: 'Volym AB' },
[
{ data: page1, error: null },
{ data: page2, error: null },
],
{ data: [], error: null }
)
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
const res = await GET(
createMockRequest('/api/reports/supplier-ledger/supplier/sup-1/invoices'),
createMockRouteParams({ supplierId: 'sup-1' })
)
expect(res.status).toBe(200)
const body = (await res.json()) as {
data: {
lines: Array<{ remaining_sek: number | null }>
unconverted_fx_count: number
next_cursor: null
}
}
// Every row made it through, not just the first page.
expect(body.data.lines).toHaveLength(1002)
expect(supabase.rangeCalls).toEqual([
[0, 999],
[1000, 1999],
])
// Stable paging order: invoice_date is not unique, so id must break ties.
expect(supabase.orderCalls).toContainEqual(['invoice_date', { ascending: true }])
expect(supabase.orderCalls).toContainEqual(['id', { ascending: true }])
// The honesty counter sees the FX row on page two.
expect(body.data.unconverted_fx_count).toBe(1)
// The shape is unchanged, and the null cursor is now truthful.
expect(body.data.next_cursor).toBeNull()
})
})
@@ -1,6 +1,7 @@
import { withRouteContext } from '@/lib/api/with-route-context'
import { NextResponse } from 'next/server'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { ReportSourceLine } from '@/lib/reports/source-lines'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -10,9 +11,16 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
* Returns the supplier invoices behind a supplier's outstanding balance.
* Each row's `journal_entry_id` points at the registration journal entry
* (when posted) so the UI can link to `/bookkeeping/[id]`.
*
* Currency contract (mirrors `generateSupplierLedger`): `credit` is the open
* balance on 2440 and is therefore always SEK. A foreign-currency invoice with
* no `exchange_rate` has no known SEK amount, so it gets `remaining_sek: null`
* and `credit: 0` instead of its raw foreign amount, and is counted in
* `unconverted_fx_count`. A consumer tells the two apart on `remaining_sek`:
* `null` means "SEK value unknown, excluded from the ledger totals", `0` means
* "genuinely settled". `remaining` (+ `currency`) always carries the invoice's
* own-currency amount, so the row stays visible and readable either way.
*/
const PAGE_LIMIT = 500
export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>(
'report.supplier_ledger.invoices',
async (request, { supabase, companyId }, { params }) => {
@@ -31,32 +39,42 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>
// Mirror `generateSupplierLedger`'s filter: registered/approved/partially
// paid/overdue invoices that still have an outstanding balance.
const { data, error } = await supabase
.from('supplier_invoices')
.select(`
id,
supplier_invoice_number,
invoice_date,
due_date,
total,
paid_amount,
remaining_amount,
currency,
exchange_rate,
registration_journal_entry_id
`)
.eq('company_id', companyId)
.eq('supplier_id', supplierId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
.order('invoice_date', { ascending: true })
.limit(PAGE_LIMIT)
if (error) {
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
//
// fetchAllRows: bounded by one supplier's OPEN invoices, so the volume is
// naturally small, but the previous hardcoded 500-row .limit() silently
// truncated the page for outliers while next_cursor: null claimed the list
// was complete, and `unconverted_fx_count` was computed over the truncated
// page (the honesty counter under-reported). The secondary .order('id')
// gives .range() paging the stable total order it needs (invoice_date is
// not unique).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const invoices = (data || []) as any[]
let invoices: any[]
try {
invoices = await fetchAllRows(({ from, to }) =>
supabase
.from('supplier_invoices')
.select(`
id,
supplier_invoice_number,
invoice_date,
due_date,
total,
paid_amount,
remaining_amount,
currency,
exchange_rate,
registration_journal_entry_id
`)
.eq('company_id', companyId)
.eq('supplier_id', supplierId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
.order('invoice_date', { ascending: true })
.order('id', { ascending: true })
.range(from, to)
)
} catch (err) {
return NextResponse.json({ error: getUserErrorMessage(err) }, { status: 500 })
}
// Pull the registration entries in one batch to get voucher numbers.
const entryIds = invoices
@@ -86,6 +104,9 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>
const lines: (ReportSourceLine & {
supplier_invoice_id: string
supplier_invoice_number: string
/** Open amount in the invoice's own currency. Display only: never summed. */
remaining: number
/** `remaining` in SEK, or `null` when the FX rate is missing. */
remaining_sek: number | null
currency: string
paid_amount: number
@@ -112,10 +133,15 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>
entry?.description ??
`Leverantörsfaktura ${inv.supplier_invoice_number || ''}`,
debit: 0,
// For an unpaid AP entry, the open balance is a credit on 2440.
credit: remainingSek ?? remaining,
// For an unpaid AP entry, the open balance is a credit on 2440, which is
// posted in SEK. With no rate there is no SEK figure to show: fall back
// to 0 rather than to the foreign amount, which would render as kronor in
// the Kredit column. `remaining_sek: null` is what marks the difference
// between "unknown" and "settled".
credit: remainingSek ?? 0,
supplier_invoice_id: inv.id,
supplier_invoice_number: inv.supplier_invoice_number || '',
remaining,
remaining_sek: remainingSek,
currency: inv.currency || 'SEK',
paid_amount: Number(inv.paid_amount) || 0,
@@ -128,6 +154,12 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>
supplier_id: supplier.id,
supplier_name: supplier.name,
lines,
// Same contract as the ledger report itself: the rows are listed, but
// their SEK value is unknown and missing from every SEK total. Computed
// over the FULL row set now that the query paginates.
unconverted_fx_count: lines.filter((l) => l.remaining_sek === null).length,
// Kept for response-shape compatibility. Truthful now: every open
// invoice for the supplier is in `lines`, so there is never a next page.
next_cursor: null,
},
})
@@ -18,36 +18,67 @@ interface SupabaseShape {
from: ReturnType<typeof vi.fn>
}
interface EmbedShapedLine {
debit_amount: number
credit_amount: number
journal_entry_id: string
dimensions?: Record<string, string> | null
journal_entries: Record<string, unknown>
}
/**
* The route uses the two-step entry-lines fetch
* (lib/bookkeeping/entry-lines.ts): journal_entries is queried first, then
* journal_entry_lines by parent id, and the parent is reattached under
* `journal_entries`. Both steps page with `.order('id').range(from, to)`, so
* `.range()` is the terminal and one short page (< the 1000-row PAGE_SIZE)
* ends the loop.
*
* Fixtures stay embed-shaped; the mock splits them into the two row sets, so
* the route sees exactly what the old `journal_entries!inner` embed returned.
*/
function buildSupabase(
account: { account_number: string; account_name: string } | null,
linesResult: { data: unknown; error: unknown }
): SupabaseShape {
const fixtures = (linesResult.data ?? []) as EmbedShapedLine[]
const entries = linesResult.error
? []
: [...new Map(fixtures.map((l) => [l.journal_entry_id, l.journal_entries])).values()]
const bareLines = linesResult.error
? []
: fixtures.map((l, i) => ({
id: `line-${String(i).padStart(5, '0')}`,
journal_entry_id: l.journal_entry_id,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
dimensions: l.dimensions ?? null,
}))
const rowsChain = (rows: unknown[]) => ({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
contains: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
or: vi.fn().mockReturnThis(),
range: vi.fn().mockResolvedValue({ data: rows, error: linesResult.error }),
then: (resolve: (v: unknown) => void) => resolve({ data: rows, error: linesResult.error }),
})
return {
from: vi.fn().mockImplementation((table: string) => {
if (table === 'chart_of_accounts') {
const chain = {
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: account, error: null }),
}
return chain
}
// journal_entry_lines: terminates on `.range()` (fetchAllRows), which
// resolves to the line result. `data.length < PAGE_SIZE` so a single
// page is fetched.
const chain = {
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(),
range: vi.fn().mockResolvedValue(linesResult),
then: (resolve: (v: unknown) => void) => resolve(linesResult),
}
return chain
return rowsChain(table === 'journal_entries' ? entries : bareLines)
}),
}
}
@@ -1,6 +1,6 @@
import { withRouteContext } from '@/lib/api/with-route-context'
import { NextResponse } from 'next/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
import type { ReportSourceLine } from '@/lib/reports/source-lines'
@@ -77,48 +77,38 @@ export const GET = withRouteContext<{ params: Promise<{ accountNumber: string }>
// cannot give us a chronological parent order. Without a stable parent order
// a raw `.limit()` returns an arbitrary subset that varies between identical
// requests, which surfaced as the trial-balance drill-down showing
// "different rows on every reload" for high-volume accounts. We instead page
// on the line PK (`id`) for a stable total order (see fetch-all.ts) and do
// the chronological sort here, mirroring `generateGeneralLedger`.
const rows = await fetchAllRows<{
// "different rows on every reload" for high-volume accounts. The two-step
// fetch pages both sides on their PK for a stable total order (see
// fetch-all.ts) and we do the chronological sort here, mirroring
// `generateGeneralLedger`.
//
// The scope filters used to live on a `journal_entries!inner` embed, which
// PostgREST compiles into a correlated LATERAL join that walks the ENTIRE
// journal_entry_lines table across all tenants (see
// lib/bookkeeping/entry-lines.ts); driving from the entry side keeps the
// work inside this company's period.
const rows = await fetchEntryLines<{
id: string
debit_amount: number
credit_amount: number
dimensions: Record<string, string> | null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
journal_entries: any
}>(({ from, to }) => {
let query = supabase
.from('journal_entry_lines')
.select(`
id,
debit_amount,
credit_amount,
dimensions,
journal_entry_id,
journal_entries!inner(
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id,
fiscal_period_id
)
`)
.eq('account_number', accountNumber)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.in('journal_entries.status', ['posted', 'reversed'])
if (dimFilter.dimensions) {
}>({
supabase,
entryColumns: 'id, voucher_number, voucher_series, entry_date, description, status, company_id, fiscal_period_id',
lineColumns: 'id, debit_amount, credit_amount, dimensions, journal_entry_id',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed']),
filterLines: (q: EntryLinesQuery) => {
const scoped = q.eq('account_number', accountNumber)
// jsonb containment (@>): served by idx_jel_dimensions_gin.
query = query.contains('dimensions', dimFilter.dimensions)
}
return query.order('id', { ascending: true }).range(from, to)
}, { dedupeBy: (r) => r.id })
return dimFilter.dimensions ? scoped.contains('dimensions', dimFilter.dimensions) : scoped
},
})
// Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as
// a final deterministic tiebreak for lines sharing a date and voucher number
@@ -25,6 +25,8 @@ vi.mock('@/lib/auth/require-auth', () => ({
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { rcInputTotalsFromDeclaration } from '@/lib/reports/vat-declaration'
import { runVatDeclarationChecks } from '@/lib/reports/vat-declaration-checks'
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -161,6 +163,65 @@ describe('GET /api/reports/vat-declaration', () => {
)
})
// The declaration is what the momsdeklaration UI runs its local
// "Kontroll av underlaget" checks on, and that client has no ledger access of
// its own. Without the 2645/2647 pair on the response it could only compare
// rutor 30-32 against the ruta 48 aggregate, where ordinary debiterad ingående
// moms on 2641 hides a missing beräknad ingående moms completely.
describe('rcInputAccountTotals travels with the response', () => {
/** The masking ledger: 50 000 kr RC output, underlag booked, no 2645/2647. */
function maskedRcPayload() {
return {
totals: [
{ account_number: '4535', debit: 200000, credit: 0 }, // ruta 21 basis
{ account_number: '2614', debit: 0, credit: 50000 }, // ruta 30
{ account_number: '2641', debit: 60000, credit: 0 }, // ordinary input VAT
],
settlement_shaped_entries: [],
source_type_counts: {},
}
}
async function fetchDeclaration() {
const res = await GET(
makeRequest('?periodType=monthly&year=2026&period=1'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(200)
return (await res.json()).data
}
it('carries both accounts, zeros included, on an ordinary SEK period', async () => {
const data = await fetchDeclaration()
// The default fixture has no reverse charge at all: the pair is still
// present, so a client can tell "no RC activity" from "field missing".
expect(data.rcInputAccountTotals).toEqual({
'2645': { debit: 0, credit: 0 },
'2647': { debit: 0, credit: 0 },
})
expect(runVatDeclarationChecks(data.rutor, rcInputTotalsFromDeclaration(data))).toEqual([])
})
it('lets the client run the sharp RC input check off the response alone', async () => {
mockSupabase.rpc.mockResolvedValue({ data: maskedRcPayload(), error: null })
const data = await fetchDeclaration()
expect(data.rutor.ruta30).toBe(50000)
expect(data.rutor.ruta48).toBe(60000)
expect(data.rcInputAccountTotals['2645']).toEqual({ debit: 0, credit: 0 })
// Same call the momsdeklaration view makes.
const checks = runVatDeclarationChecks(data.rutor, rcInputTotalsFromDeclaration(data))
const mismatch = checks.find((c) => c.code === 'RC_INPUT_VAT_MISMATCH')
expect(mismatch?.status).toBe('WARNING')
// \s, not a literal space: sv-SE groups thousands with a no-break space.
expect(mismatch?.message).toMatch(/50\s000 kr saknas/)
// Ruta 48 alone: silent, which is the state the wiring replaced.
expect(runVatDeclarationChecks(data.rutor)).toEqual([])
})
})
it('returns the VAT_REPORT_GENERATION_FAILED envelope when the RPC errors', async () => {
mockSupabase.rpc.mockResolvedValue({
data: null,
@@ -13,14 +13,21 @@ vi.mock('@/lib/company/context', () => ({
}))
import { GET } from '../route'
import { resolvePeriodDates } from '@/lib/reports/vat-declaration'
interface SupabaseShape {
from: ReturnType<typeof vi.fn>
rpc: ReturnType<typeof vi.fn>
}
/**
* `fiscalPeriodResult` is what a `fiscal_periods` lookup resolves to: both the
* explicit-id lookup and the "räkenskapsår ending in `year`" lookup that
* `resolvePeriodDates` performs for helårsmoms.
*/
function buildSupabase(
linesResult: { data: unknown; error: unknown }
linesResult: { data: unknown; error: unknown },
fiscalPeriodResult: { data: unknown; error: unknown } = { data: null, error: null }
): SupabaseShape {
return {
rpc: vi.fn().mockResolvedValue(linesResult),
@@ -33,13 +40,19 @@ function buildSupabase(
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
or: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
maybeSingle: vi.fn().mockResolvedValue(fiscalPeriodResult),
range: vi.fn().mockResolvedValue(linesResult),
then: (resolve: (v: unknown) => void) => resolve(linesResult),
})),
}
}
/** The { p_start, p_end } the route handed to get_vat_ruta_source_lines. */
function rpcPeriod(supabase: SupabaseShape): { start: string; end: string } {
const args = supabase.rpc.mock.calls[0][1] as { p_start: string; p_end: string }
return { start: args.p_start, end: args.p_end }
}
function authOk(supabase: SupabaseShape) {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
}
@@ -193,3 +206,124 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => {
])
})
})
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
// (SFL 26 kap 10-11 §§).
const EXTENDED_FIRST_YEAR = { period_start: '2025-07-03', period_end: '2026-12-31' }
const CALENDAR_YEAR = { period_start: '2026-01-01', period_end: '2026-12-31' }
const noLines = () => ({ data: [], error: null })
function get(searchParams: Record<string, string>, ruta = '05') {
const req = createMockRequest(
`/api/reports/vat-declaration/ruta/${ruta}/sources`,
{ searchParams }
)
return GET(req, createMockRouteParams({ ruta }))
}
it('yearly: drills into the räkenskapsår, agreeing with the declaration resolver', async () => {
const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null })
authOk(supabase)
const res = await get({ periodType: 'yearly', year: '2026', period: '1' })
expect(res.status).toBe(200)
// The old calendar-span behaviour would have started 2026-01-01 and hidden
// every verifikat from 2025-07-03 to 2025-12-31 that the declaration counts.
expect(rpcPeriod(supabase)).toEqual({ start: '2025-07-03', end: '2026-12-31' })
// And it is the exact span `calculateVatDeclaration` computes the figure
// from: both go through resolvePeriodDates with the same arguments.
const declarationPeriod = await resolvePeriodDates(
buildSupabase(noLines(), {
data: EXTENDED_FIRST_YEAR,
error: null,
}) as unknown as Parameters<typeof resolvePeriodDates>[0],
'company-1',
'yearly',
2026,
1
)
expect(rpcPeriod(supabase)).toEqual(declarationPeriod)
})
it('yearly: an explicit fiscal_period_id selects that räkenskapsår', async () => {
const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null })
authOk(supabase)
const res = await get({
periodType: 'yearly',
year: '2026',
period: '1',
fiscal_period_id: '11111111-1111-4111-8111-111111111111',
})
expect(res.status).toBe(200)
expect(rpcPeriod(supabase)).toEqual({ start: '2025-07-03', end: '2026-12-31' })
})
it('yearly: calendar-year company is unchanged (Jan-Dec)', async () => {
const supabase = buildSupabase(noLines(), { data: CALENDAR_YEAR, error: null })
authOk(supabase)
const res = await get({ periodType: 'yearly', year: '2026', period: '1' })
expect(res.status).toBe(200)
expect(rpcPeriod(supabase)).toEqual({ start: '2026-01-01', end: '2026-12-31' })
})
it('yearly: falls back to the calendar span when no fiscal period is found', async () => {
const supabase = buildSupabase(noLines(), { data: null, error: null })
authOk(supabase)
const res = await get({ periodType: 'yearly', year: '2026', period: '1' })
expect(res.status).toBe(200)
expect(rpcPeriod(supabase)).toEqual({ start: '2026-01-01', end: '2026-12-31' })
})
it('monthly: stays a calendar month even for a broken fiscal year', async () => {
// kalendermånad per SFL 26 kap: fiscal_period_id must not widen the span.
const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null })
authOk(supabase)
const res = await get({
periodType: 'monthly',
year: '2026',
period: '5',
fiscal_period_id: '11111111-1111-4111-8111-111111111111',
})
expect(res.status).toBe(200)
expect(rpcPeriod(supabase)).toEqual({ start: '2026-05-01', end: '2026-05-31' })
})
it('quarterly: stays a calendar quarter', async () => {
const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null })
authOk(supabase)
const res = await get({ periodType: 'quarterly', year: '2026', period: '2' })
expect(res.status).toBe(200)
expect(rpcPeriod(supabase)).toEqual({ start: '2026-04-01', end: '2026-06-30' })
})
it('returns 400 for an unknown periodType', async () => {
authOk(buildSupabase(noLines()))
const res = await get({ periodType: 'weekly', year: '2026', period: '5' })
expect(res.status).toBe(400)
})
it('fiscal_period_id alone still selects the period by its own bounds', async () => {
const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null })
authOk(supabase)
const res = await get({ fiscal_period_id: '11111111-1111-4111-8111-111111111111' })
expect(res.status).toBe(200)
expect(rpcPeriod(supabase)).toEqual({ start: '2025-07-03', end: '2026-12-31' })
})
it('returns 404 when fiscal_period_id alone matches no period', async () => {
authOk(buildSupabase(noLines(), { data: null, error: null }))
const res = await get({ fiscal_period_id: '11111111-1111-4111-8111-111111111111' })
expect(res.status).toBe(404)
})
})
@@ -2,7 +2,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { NextResponse } from 'next/server'
import {
ACCOUNT_RUTA,
calculatePeriodDates,
resolvePeriodDates,
} from '@/lib/reports/vat-declaration'
import type { ReportSourceLine } from '@/lib/reports/source-lines'
import type { VatDeclarationRutor, VatPeriodType } from '@/types'
@@ -15,10 +15,13 @@ import type { VatDeclarationRutor, VatPeriodType } from '@/types'
* `ACCOUNT_RUTA` in `lib/reports/vat-declaration.ts`.
*
* Period can be specified either via:
* ?periodType=monthly|quarterly|yearly&year=2026&period=5
* ?periodType=monthly|quarterly|yearly&year=2026&period=5[&fiscal_period_id=<uuid>]
* ?fiscal_period_id=<uuid>
*
* The periodType form mirrors the way the main VAT report is fetched.
* The periodType form mirrors the way the main VAT report is fetched, down to
* the period resolution itself: it goes through `resolvePeriodDates`, the same
* helper the declaration uses, so the drill-down can never answer a query
* string with a different span than the figure it drills into.
*/
const PAGE_LIMIT = 500
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
@@ -48,11 +51,43 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>(
)
}
// Resolve the period: either by fiscal_period_id or periodType/year/period.
let start: string | null = null
let end: string | null = null
// Resolve the period. The periodType form is primary and resolves through
// `resolvePeriodDates`, the same helper the declaration itself uses, with
// fiscal_period_id threaded through exactly as the sibling vat-declaration
// routes do. Helårsmoms is filed per räkenskapsår (SFL 26 kap 10-11 §§) and
// a räkenskapsår is not always a calendar year (a first/changed year runs up
// to 18 months, BFL 3 kap 3 §), so a plain Jan-Dec span would list a
// different set of verifikat than the declaration was computed from.
// Monthly/quarterly are calendar periods and resolve identically to before.
// The fiscal_period_id-only form (no periodType) still selects the span from
// the fiscal period's own bounds.
const periodType = searchParams.get('periodType') as VatPeriodType | null
const yearStr = searchParams.get('year')
const periodStr = searchParams.get('period')
const fiscalPeriodId = searchParams.get('fiscal_period_id')
if (fiscalPeriodId) {
let start: string
let end: string
if (periodType && yearStr && periodStr) {
if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) {
return NextResponse.json({ error: 'Invalid periodType' }, { status: 400 })
}
const year = parseInt(yearStr, 10)
const periodNum = parseInt(periodStr, 10)
if (isNaN(year) || isNaN(periodNum)) {
return NextResponse.json({ error: 'Invalid period' }, { status: 400 })
}
const dates = await resolvePeriodDates(
supabase,
companyId,
periodType,
year,
periodNum,
fiscalPeriodId ?? undefined
)
start = dates.start
end = dates.end
} else if (fiscalPeriodId) {
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
@@ -65,23 +100,10 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>(
start = period.period_start
end = period.period_end
} else {
const periodType = searchParams.get('periodType') as VatPeriodType | null
const yearStr = searchParams.get('year')
const periodStr = searchParams.get('period')
if (!periodType || !yearStr || !periodStr) {
return NextResponse.json(
{ error: 'periodType/year/period or fiscal_period_id is required' },
{ status: 400 }
)
}
const year = parseInt(yearStr, 10)
const periodNum = parseInt(periodStr, 10)
if (isNaN(year) || isNaN(periodNum)) {
return NextResponse.json({ error: 'Invalid period' }, { status: 400 })
}
const dates = calculatePeriodDates(periodType, year, periodNum)
start = dates.start
end = dates.end
return NextResponse.json(
{ error: 'periodType/year/period or fiscal_period_id is required' },
{ status: 400 }
)
}
// New cursors include entry and line IDs so multiple rows with the same
@@ -0,0 +1,179 @@
/**
* PATCH /api/salary/employees/[id]: the personnummer wipe guard.
*
* The route used to build its update payload with `{ ...body }` and then apply
* the encrypt branch only under `if (body.personnummer)`. For an empty string
* that branch is skipped, but the spread has already put '' into the payload:
* the UPDATE would overwrite the AES-256-GCM ciphertext with an empty string and
* leave personnummer_last4 pointing at the old value. Nothing in the route
* stopped it. The only thing standing in the way was the 12-digit regex in
* UpdateEmployeeSchema, i.e. a guard in a DIFFERENT file, and house style in
* lib/api/schemas.ts adds `.or(z.literal(''))` to optional string fields
* (see clearing_number, account_number, bankgiro, tax_contact_email, ...).
* One such edit to the employee schema would have turned a routine "clear this
* field" idiom into silent destruction of encrypted PII.
*
* These tests deliberately mock @/lib/api/schemas so the personnummer field is
* waved through validation and the request reaches the route body. They therefore
* assert the defence that lives in the write path, not the one that lives in the
* schema. `captured.updates` staying null means no UPDATE was issued at all, so
* the ciphertext survives.
*
* Fixtures are obviously synthetic: no real personnummer appears here.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest } from '@/tests/helpers'
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'),
getCompanyEntityType: vi.fn().mockResolvedValue('aktiebolag'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
// A schema that validates everything EXCEPT the personnummer shape. The point
// is to prove the route defends on its own, so the field is waved through here
// and the only remaining guard is the one in the write path. The concrete
// production edit that produces the empty-string case is a single
// `.or(z.literal(''))` on the real schema's personnummer field, matching how
// clearing_number, account_number, bankgiro and tax_contact_email are already
// written in lib/api/schemas.ts.
vi.mock('@/lib/api/schemas', async () => {
const { z } = await import('zod')
return {
UpdateEmployeeSchema: z.object({
first_name: z.string().min(1).max(200).optional(),
personnummer: z.string().nullable().optional(),
}),
}
})
import { PATCH } from '../route'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
const params = { params: Promise.resolve({ id: 'emp-1' }) } as never
const STORED_CIPHERTEXT = encryptPersonnummer('190203040000')
const EXISTING_ROW = {
id: 'emp-1',
company_id: 'company-1',
first_name: 'Test',
last_name: 'Testsson',
personnummer: STORED_CIPHERTEXT,
personnummer_last4: '0000',
employment_type: 'employee',
salary_type: 'monthly',
monthly_salary: 30000,
f_skatt_status: 'a_skatt',
is_sidoinkomst: false,
tax_table_number: 34,
}
function employeeSupabase() {
const captured: { updates: Record<string, unknown> | null } = { updates: null }
function chainFor(state: { isUpdate: boolean }): unknown {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const data = state.isUpdate
? { ...EXISTING_ROW, ...(captured.updates ?? {}) }
: EXISTING_ROW
return (resolve: (v: unknown) => void) => resolve({ data, error: null })
}
return (...args: unknown[]) => {
if (prop === 'update') {
state.isUpdate = true
captured.updates = args[0] as Record<string, unknown>
}
return chainFor(state)
}
},
}
return new Proxy({}, handler)
}
return { supabase: { from: vi.fn(() => chainFor({ isUpdate: false })) }, captured }
}
describe('PATCH /api/salary/employees/[id]: personnummer cannot be wiped', () => {
let captured: { updates: Record<string, unknown> | null }
beforeEach(() => {
vi.clearAllMocks()
const mock = employeeSupabase()
captured = mock.captured
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: mock.supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('rejects an empty-string personnummer instead of overwriting the ciphertext', async () => {
const response = await PATCH(
createMockRequest('/api/salary/employees/emp-1', {
method: 'PATCH',
body: { personnummer: '' },
}),
params,
)
expect(response.status).toBe(400)
// No UPDATE at all: the ciphertext and personnummer_last4 both survive.
expect(captured.updates).toBeNull()
})
it('rejects a null personnummer (the column is NOT NULL and AGI/KU need it)', async () => {
const response = await PATCH(
createMockRequest('/api/salary/employees/emp-1', {
method: 'PATCH',
body: { personnummer: null },
}),
params,
)
expect(response.status).toBe(400)
expect(captured.updates).toBeNull()
})
it('refuses the whole PATCH rather than applying the other fields silently', async () => {
// Failing loudly matters more than partial success: a caller that thought it
// was clearing the personnummer must learn that it did not happen.
const response = await PATCH(
createMockRequest('/api/salary/employees/emp-1', {
method: 'PATCH',
body: { first_name: 'Ny', personnummer: '' },
}),
params,
)
expect(response.status).toBe(400)
expect(captured.updates).toBeNull()
})
it('rejects a masked personnummer that reached the route past a loose schema', async () => {
// validatePersonnummer() strips non-digits, so a decorated value carrying 12
// real digits would sail through it. The explicit mask check is what makes
// the refusal deterministic.
const response = await PATCH(
createMockRequest('/api/salary/employees/emp-1', {
method: 'PATCH',
body: { personnummer: '190203040000-XXXX' },
}),
params,
)
expect(response.status).toBe(400)
expect(captured.updates).toBeNull()
})
})
@@ -5,6 +5,17 @@
* its auth/company/write dependencies and injecting a queued Supabase mock via
* requireAuth. Covers 401 (unauth), 403 (viewer role), and a DELETE happy path
* (soft delete, BFL retention).
*
* Plus the personnummer contract on this route, which handles encrypted PII on
* both the read and the write side:
* - reads expose the mask under `personnummer_masked`, never under the
* writable `personnummer` key (no mask round-trip into the encrypt path),
* - a PATCH that omits personnummer must leave the ciphertext and
* personnummer_last4 completely untouched,
* - a masked value offered as a write is refused,
* - a genuine new value is stored encrypted, never plaintext.
* The empty-string / null wipe guard is pinned in personnummer-write-guard.test.ts,
* which loosens the Zod schema to prove the defence lives in the route.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
@@ -30,10 +41,67 @@ vi.mock('@/lib/auth/require-write', () => ({
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { DELETE } from '../route'
import { DELETE, GET, PATCH } from '../route'
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
const params = { params: Promise.resolve({ id: 'emp-1' }) } as never
// Obviously synthetic fixtures. STORED_PNR is only ever masked, so it needs no
// check digit; NEW_PNR goes through validatePersonnummer, so its Luhn check
// digit is genuinely correct (sum over 0001010008 = 10).
const STORED_PNR = '190203040000'
const STORED_CIPHERTEXT = encryptPersonnummer(STORED_PNR)
const NEW_PNR = '190001010008'
const EXISTING_ROW = {
id: 'emp-1',
company_id: 'company-1',
first_name: 'Test',
last_name: 'Testsson',
personnummer: STORED_CIPHERTEXT,
personnummer_last4: '0000',
employment_type: 'employee',
salary_type: 'monthly',
monthly_salary: 30000,
f_skatt_status: 'a_skatt',
is_sidoinkomst: false,
tax_table_number: 34,
} as const
/**
* Supabase double that returns EXISTING_ROW on reads, records the payload handed
* to `.update()`, and resolves the update chain with the merged row. `captured.updates`
* staying null is the assertion that no write was attempted at all.
*/
function employeeSupabase(existing: Record<string, unknown> = { ...EXISTING_ROW }) {
const captured: { updates: Record<string, unknown> | null } = { updates: null }
function chainFor(state: { isUpdate: boolean }): unknown {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const data = state.isUpdate ? { ...existing, ...(captured.updates ?? {}) } : existing
return (resolve: (v: unknown) => void) => resolve({ data, error: null })
}
return (...args: unknown[]) => {
if (prop === 'update') {
state.isUpdate = true
captured.updates = args[0] as Record<string, unknown>
}
return chainFor(state)
}
},
}
return new Proxy({}, handler)
}
return { supabase: { from: vi.fn(() => chainFor({ isUpdate: false })) }, captured }
}
function patchRequest(body: Record<string, unknown>) {
return createMockRequest('/api/salary/employees/emp-1', { method: 'PATCH', body })
}
describe('DELETE /api/salary/employees/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -73,3 +141,72 @@ describe('DELETE /api/salary/employees/[id]', () => {
expect(body.data).toEqual({ id: 'emp-1', is_active: false })
})
})
describe('personnummer contract on /api/salary/employees/[id]', () => {
let captured: { updates: Record<string, unknown> | null }
beforeEach(() => {
vi.clearAllMocks()
reset()
const mock = employeeSupabase()
captured = mock.captured
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: mock.supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('GET returns the mask under personnummer_masked and drops the ciphertext', async () => {
const response = await GET(createMockRequest('/api/salary/employees/emp-1'), params)
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(response)
expect(status).toBe(200)
expect(body.data.personnummer_masked).toBe('19020304-XXXX')
// The writable key name must not carry the mask: a client that reads this
// object and writes it back would otherwise send the mask to the encrypt path.
expect('personnummer' in body.data).toBe(false)
// personnummer_last4 must not ride along either: the mask is
// YYYYMMDD-XXXX, so mask + last4 reassembles the full personnummer.
expect('personnummer_last4' in body.data).toBe(false)
// Neither the plaintext nor the stored ciphertext may leave the server.
expect(JSON.stringify(body)).not.toContain(STORED_PNR)
expect(JSON.stringify(body)).not.toContain(STORED_CIPHERTEXT)
})
it('PATCH without personnummer leaves the ciphertext and last4 untouched', async () => {
const response = await PATCH(patchRequest({ first_name: 'Ny' }), params)
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(response)
expect(status).toBe(200)
expect(captured.updates).toEqual({ first_name: 'Ny' })
// Absent key means "identity unchanged": neither column may appear.
expect(captured.updates).not.toHaveProperty('personnummer')
expect(captured.updates).not.toHaveProperty('personnummer_last4')
expect(body.data.personnummer_masked).toBe('19020304-XXXX')
expect('personnummer' in body.data).toBe(false)
expect('personnummer_last4' in body.data).toBe(false)
})
it('PATCH refuses a masked personnummer instead of encrypting the mask', async () => {
const response = await PATCH(patchRequest({ personnummer: '19020304-XXXX' }), params)
expect(response.status).toBe(400)
expect(captured.updates).toBeNull()
})
it('PATCH stores a genuine new personnummer encrypted, never plaintext', async () => {
const response = await PATCH(patchRequest({ personnummer: NEW_PNR }), params)
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(response)
expect(status).toBe(200)
const stored = captured.updates?.personnummer as string
expect(stored).not.toBe(NEW_PNR)
expect(decryptPersonnummer(stored)).toBe(NEW_PNR)
expect(captured.updates?.personnummer_last4).toBe('0008')
// The response echoes only the mask, under the read-only key. The updated
// last4 is written to the row but must not appear in the response.
expect(body.data.personnummer_masked).toBe('19000101-XXXX')
expect('personnummer' in body.data).toBe(false)
expect('personnummer_last4' in body.data).toBe(false)
expect(JSON.stringify(body)).not.toContain(NEW_PNR)
expect(JSON.stringify(body)).not.toContain('"0008"')
})
})
@@ -2,7 +2,10 @@
* Auth-wiring tests for /api/salary/employees/[id]/benefits/[benefitId]
* (PATCH/DELETE). Runs through the real withRouteContext wrapper; mocks auth/
* company/write and injects a queued Supabase mock via requireAuth. Covers 401,
* 403 (viewer), and a DELETE happy path.
* 403 (viewer), a DELETE happy path, and the validity-period contract on PATCH
* (CHECK (valid_to IS NULL OR valid_to >= valid_from), migration
* 20260512200100), including the error-mapping split that used to report a
* check_violation as 404 "Förmån hittades inte".
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
@@ -27,7 +30,7 @@ vi.mock('@/lib/auth/require-write', () => ({
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { DELETE } from '../route'
import { DELETE, PATCH } from '../route'
const params = { params: Promise.resolve({ id: 'emp-1', benefitId: 'ben-1' }) } as never
@@ -35,6 +38,18 @@ function del() {
return createMockRequest('/api/salary/employees/emp-1/benefits/ben-1', { method: 'DELETE' })
}
function patch(body: unknown) {
return createMockRequest('/api/salary/employees/emp-1/benefits/ben-1', { method: 'PATCH', body })
}
/** Stored row as the PATCH route fetches it before writing. */
const storedBenefit = {
benefit_type: 'other',
metadata: {},
valid_from: '2026-06-01',
valid_to: '2026-12-31',
}
describe('DELETE /api/salary/employees/[id]/benefits/[benefitId]', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -74,3 +89,185 @@ describe('DELETE /api/salary/employees/[id]/benefits/[benefitId]', () => {
expect(body.data).toEqual({ id: 'ben-1', deleted: true })
})
})
describe('PATCH /api/salary/employees/[id]/benefits/[benefitId]', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await PATCH(patch({ monthly_value: 100 }), params)
expect(response.status).toBe(401)
})
it('returns 403 for a viewer (no write permission)', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const response = await PATCH(patch({ monthly_value: 100 }), params)
expect(response.status).toBe(403)
})
it('updates the benefit (happy path)', async () => {
enqueue({ data: storedBenefit }) // existence check
enqueue({ data: { id: 'ben-1', monthly_value: 100 } }) // update
const response = await PATCH(patch({ monthly_value: 100 }), params)
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
expect(status).toBe(200)
expect(body.data.id).toBe('ben-1')
})
it('returns 404 when the benefit does not exist in the company', async () => {
enqueue({ data: null, error: { code: 'PGRST116', message: 'no rows' } })
const response = await PATCH(patch({ monthly_value: 100 }), params)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect(body.error).toBe('Förmån hittades inte')
})
describe('valid_from / valid_to ordering', () => {
// Both dates in the body: the schema can compare them itself, so this 400
// lands before any DB call.
it('rejects both-dates-in-body with valid_to before valid_from', async () => {
const response = await PATCH(
patch({ valid_from: '2026-06-01', valid_to: '2026-05-31' }),
params,
)
const { status, body } = await parseJsonResponse<{
error: string
errors: { field: string }[]
}>(response)
expect(status).toBe(400)
expect(body.errors).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'valid_to' })]),
)
expect(body.error).toContain('Gäller till')
expect(supabase.from).not.toHaveBeenCalled()
})
// Single-field PATCH: the schema cannot see the stored half, so the route
// compares the merged pair against the fetched row and answers 400 itself.
// This is the case that used to reach Postgres and return 404.
it('rejects a valid_to-only patch that predates the stored valid_from', async () => {
enqueue({ data: storedBenefit }) // stored valid_from 2026-06-01
const response = await PATCH(patch({ valid_to: '2026-05-31' }), params)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('Gäller till')
expect(body.error).not.toContain('hittades inte')
// Fetched once, never written.
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects a valid_from-only patch that postdates the stored valid_to', async () => {
enqueue({ data: storedBenefit }) // stored valid_to 2026-12-31
const response = await PATCH(patch({ valid_from: '2027-01-01' }), params)
expect(response.status).toBe(400)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('accepts a valid_to-only patch that is still on or after the stored valid_from', async () => {
enqueue({ data: storedBenefit })
enqueue({ data: { id: 'ben-1', valid_to: '2026-06-01' } })
// Equal to the stored valid_from: legal, the bound is inclusive.
const response = await PATCH(patch({ valid_to: '2026-06-01' }), params)
expect(response.status).toBe(200)
})
it('accepts both dates equal (the bound is inclusive)', async () => {
enqueue({ data: storedBenefit })
enqueue({ data: { id: 'ben-1' } })
const response = await PATCH(
patch({ valid_from: '2026-06-01', valid_to: '2026-06-01' }),
params,
)
expect(response.status).toBe(200)
})
it('accepts valid_to: null (clearing the end date keeps the benefit open-ended)', async () => {
enqueue({ data: storedBenefit })
enqueue({ data: { id: 'ben-1', valid_to: null } })
const response = await PATCH(patch({ valid_to: null }), params)
expect(response.status).toBe(200)
})
it('accepts a valid_from-only patch when the stored valid_to is null', async () => {
enqueue({ data: { ...storedBenefit, valid_to: null } })
enqueue({ data: { id: 'ben-1' } })
const response = await PATCH(patch({ valid_from: '2030-01-01' }), params)
expect(response.status).toBe(200)
})
})
describe('write-error mapping (not "hittades inte")', () => {
it('maps a check_violation on the update to 400 with the period message', async () => {
enqueue({ data: storedBenefit })
enqueue({
data: null,
error: { code: '23514', message: 'violates check constraint "employee_benefits_check"' },
})
const response = await PATCH(patch({ monthly_value: 100 }), params)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('Gäller till')
expect(body.error).not.toContain('hittades inte')
})
it('maps a real DB failure on the update to 500, not 404', async () => {
enqueue({ data: storedBenefit })
enqueue({ data: null, error: { code: '08006', message: 'connection failure' } })
const response = await PATCH(patch({ monthly_value: 100 }), params)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect(body.error).not.toContain('hittades inte')
})
it('keeps 404 when the row disappears between the fetch and the update', async () => {
enqueue({ data: storedBenefit })
enqueue({ data: null, error: { code: 'PGRST116', message: 'no rows' } })
const response = await PATCH(patch({ monthly_value: 100 }), params)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect(body.error).toBe('Förmån hittades inte')
})
it('maps a failed existence lookup (non-PGRST116) to 500, not 404', async () => {
enqueue({ data: null, error: { code: '08006', message: 'connection failure' } })
const response = await PATCH(patch({ monthly_value: 100 }), params)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect(body.error).not.toContain('hittades inte')
})
})
})
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateEmployeeBenefitSchema } from '@/lib/api/schemas'
import { UpdateEmployeeBenefitSchema, BENEFIT_PERIOD_ORDER_MESSAGE } from '@/lib/api/schemas'
import { calculateBikeBenefit } from '@/lib/salary/benefits'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -19,16 +19,36 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string; benefitId:
const { data: existing, error: fetchError } = await supabase
.from('employee_benefits')
.select('benefit_type, metadata')
.select('benefit_type, metadata, valid_from, valid_to')
.eq('id', benefitId)
.eq('employee_id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !existing) {
// Only zero rows (PGRST116) means the benefit really isn't there. A
// transport/DB failure is not a missing record and must not be reported as
// one.
if (fetchError && fetchError.code !== 'PGRST116') {
return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
}
if (!existing) {
return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 })
}
// Validity period against the MERGED state. UpdateEmployeeBenefitSchema can
// only compare the two dates when the body carries both; when only one is
// patched, the other half lives on the row we just fetched. Without this the
// CHECK (valid_to IS NULL OR valid_to >= valid_from) fired in Postgres and
// the error branch below dressed it up as "Förmån hittades inte".
// Inclusive bound, and a null/cleared valid_to stays legal.
const mergedValidFrom = (body.valid_from ?? existing.valid_from ?? null) as string | null
const mergedValidTo = (
body.valid_to !== undefined ? body.valid_to : existing.valid_to ?? null
) as string | null
if (mergedValidFrom !== null && mergedValidTo !== null && mergedValidTo < mergedValidFrom) {
return NextResponse.json({ error: BENEFIT_PERIOD_ORDER_MESSAGE }, { status: 400 })
}
const updates: Record<string, unknown> = { ...body }
if (body.annual_market_value !== undefined) {
@@ -59,7 +79,29 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string; benefitId:
.select()
.single()
if (error || !data) {
if (error) {
// The row's existence was already established above, so `error` here is a
// write failure, not a lookup miss. Collapsing every error into 404
// "Förmån hittades inte" sent users hunting for a record that exists: a
// check_violation on valid_to >= valid_from is the one they actually hit.
// PGRST116 (zero rows) is the only shape that still means not-found: the
// row was deleted or moved out of the company between fetch and update.
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 })
}
// employee_benefits has exactly three CHECKs (migration 20260512200100):
// benefit_type IN (...) and monthly_value >= 0 are unreachable from this
// body (benefit_type is not patchable; the schema and the bike calc both
// keep monthly_value non-negative), leaving the validity-period range as
// the only one an UPDATE can trip: a concurrent write that moved the other
// date after the merged check above.
if (error.code === '23514') {
return NextResponse.json({ error: BENEFIT_PERIOD_ORDER_MESSAGE }, { status: 400 })
}
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
if (!data) {
return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 })
}
@@ -89,4 +89,76 @@ describe('POST /api/salary/employees/[id]/benefits', () => {
const response = await POST(post(validBenefit), params)
expect(response.status).toBe(404)
})
// Validity period: mirrors CHECK (valid_to IS NULL OR valid_to >= valid_from)
// on employee_benefits (migration 20260512200100). Before the schema mirrored
// it, an end date before the start date reached Postgres and came back as an
// opaque 500.
describe('valid_from / valid_to ordering', () => {
it('rejects valid_to before valid_from with an actionable 400', async () => {
// Queued as the DB would answer without the schema mirror: the employee
// resolves, then the insert trips the CHECK. The route must never get
// there; before the mirror this exact input returned an opaque 500.
enqueue({ data: { id: 'emp-1' } })
enqueue({
data: null,
error: { code: '23514', message: 'violates check constraint "employee_benefits_check"' },
})
const response = await POST(
post({ ...validBenefit, valid_from: '2026-06-01', valid_to: '2026-05-31' }),
params,
)
const { status, body } = await parseJsonResponse<{
error: string
errors: { field: string; message: string }[]
}>(response)
expect(status).toBe(400)
expect(body.errors).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'valid_to' })]),
)
// Names both fields as labelled in the form and how to express open-ended.
expect(body.error).toContain('Gäller till')
expect(body.error).toContain('Gäller från')
expect(body.error).toContain('löpande')
// Nothing was attempted against the DB.
expect(supabase.from).not.toHaveBeenCalled()
})
it('accepts valid_to equal to valid_from (the bound is inclusive)', async () => {
enqueue({ data: { id: 'emp-1' } })
enqueue({ data: { id: 'ben-1' } })
const response = await POST(
post({ ...validBenefit, valid_from: '2026-06-01', valid_to: '2026-06-01' }),
params,
)
expect(response.status).toBe(201)
})
it('accepts an omitted valid_to (open-ended benefit stays legal)', async () => {
enqueue({ data: { id: 'emp-1' } })
enqueue({ data: { id: 'ben-1' } })
const response = await POST(post(validBenefit), params)
expect(response.status).toBe(201)
})
it('maps a check_violation from the insert to 400, not 500', async () => {
enqueue({ data: { id: 'emp-1' } })
enqueue({ data: null, error: { code: '23514', message: 'violates check constraint' } })
const response = await POST(post(validBenefit), params)
expect(response.status).toBe(400)
})
it('still reports a genuine DB failure as 500', async () => {
enqueue({ data: { id: 'emp-1' } })
enqueue({ data: null, error: { code: '08006', message: 'connection failure' } })
const response = await POST(post(validBenefit), params)
expect(response.status).toBe(500)
})
})
})
@@ -76,7 +76,15 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.select()
.single()
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
if (error) {
// A CHECK violation is bad input, not a server fault. The create schema
// now mirrors every CHECK on the table (benefit_type, monthly_value >= 0,
// valid_to >= valid_from), so this is only the backstop for non-schema
// callers; answering 500 told the user to retry an insert that can never
// succeed.
const status = error.code === '23514' ? 400 : 500
return NextResponse.json({ error: getUserErrorMessage(error) }, { status })
}
return NextResponse.json({ data }, { status: 201 })
},
+59 -20
View File
@@ -4,13 +4,20 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateEmployeeSchema } from '@/lib/api/schemas'
import { getCompanyEntityType } from '@/lib/company/context'
import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
import { encryptPersonnummer, extractLast4, maskEmployeeForResponse, validatePersonnummer } from '@/lib/salary/personnummer'
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
// Response shaping lives in the shared maskEmployeeForResponse: it drops the
// ciphertext AND personnummer_last4 (mask + last4 reassembles the full
// personnummer) and returns the mask under the read-only `personnummer_masked`
// key, never the writable `personnummer` key. This route both reads and writes
// the same object shape, so a mask under the write key would round-trip
// 'ÅÅÅÅMMDD-XXXX' straight into the encrypt path.
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.employees.get',
async (request, { supabase, companyId }, { params }) => {
@@ -27,12 +34,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
return NextResponse.json({
data: {
...employee,
personnummer: maskPersonnummer(decryptPersonnummer(employee.personnummer)),
},
})
return NextResponse.json({ data: maskEmployeeForResponse(employee) })
},
)
@@ -100,17 +102,59 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
}
}
// Build update object
const updates: Record<string, unknown> = { ...body }
// Build update object. `personnummer` is destructured OUT of the spread and
// handled explicitly below: spreading it verbatim would let any
// falsy-but-present value reach the row and overwrite the AES-256-GCM
// ciphertext with it (`if (body.personnummer)` skips the encrypt branch for
// an empty string, but `{ ...body }` has already put the empty string in
// `updates`). The only thing that stopped that today was the 12-digit regex
// in UpdateEmployeeSchema, i.e. a guard in another file: house style there
// adds `.or(z.literal(''))` to optional string fields, and one such edit
// would have turned this spread into a silent wipe of encrypted PII plus a
// stale `personnummer_last4`. The guard belongs in the write path.
const { personnummer: pnrInput, ...bodyWithoutPnr } = body
const updates: Record<string, unknown> = { ...bodyWithoutPnr }
// Handle personnummer update if provided
if (body.personnummer) {
const pnrValidation = validatePersonnummer(body.personnummer)
// personnummer semantics on PATCH:
// • key absent / undefined → identity unchanged; ciphertext and
// personnummer_last4 are preserved untouched.
// • '' or null → 400. There is no "clear the personnummer"
// operation: the column is NOT NULL, and AGI/KU filing (Skatteverket
// FK215) cannot be produced without it. Omit the key to leave it alone.
// • masked display value → 400. Read surfaces return the mask under
// `personnummer_masked` so it cannot be written back by accident, but
// reject it here too so a hand-built round-trip fails loudly instead of
// encrypting 'ÅÅÅÅMMDD-XXXX' as somebody's identity. Note that
// validatePersonnummer() strips non-digits, so a decorated value
// carrying 12 real digits would otherwise pass.
// • 12 digits → validate (format, date range, Luhn) and
// re-encrypt, refreshing personnummer_last4 in the same update.
const rawPnr: unknown = pnrInput
if (rawPnr !== undefined) {
if (typeof rawPnr !== 'string' || rawPnr.trim() === '') {
return NextResponse.json(
{
error:
'Personnummer kan inte tömmas. Utelämna fältet för att lämna det oförändrat.',
},
{ status: 400 },
)
}
if (/x/i.test(rawPnr)) {
return NextResponse.json(
{
error:
'Maskerat personnummer kan inte sparas. Skicka hela personnummret (12 siffror) eller utelämna fältet.',
},
{ status: 400 },
)
}
const pnrValidation = validatePersonnummer(rawPnr)
if (!pnrValidation.valid) {
return NextResponse.json({ error: pnrValidation.error }, { status: 400 })
}
updates.personnummer = encryptPersonnummer(body.personnummer)
updates.personnummer_last4 = extractLast4(body.personnummer)
updates.personnummer = encryptPersonnummer(rawPnr)
updates.personnummer_last4 = extractLast4(rawPnr)
}
const { data: updated, error } = await supabase
@@ -128,12 +172,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
data: {
...updated,
personnummer: maskPersonnummer(decryptPersonnummer(updated.personnummer)),
},
})
return NextResponse.json({ data: maskEmployeeForResponse(updated) })
},
{ requireWrite: true },
)
@@ -0,0 +1,92 @@
/**
* Supabase mock that records what each query actually did.
*
* `createQueuedMockSupabase()` in tests/helpers.ts throws call arguments away,
* so it cannot tell "the route wrote start_time" apart from "the route silently
* dropped it": both produce the same 201. The worked-hours bug was exactly that
* shape (validated, documented, never written), so these tests assert on the
* payload handed to `.insert()` and on the column list handed to `.select()`.
*
* Result queue semantics match createQueuedMockSupabase: one enqueued result is
* consumed per `.from()` / `.rpc()` call, in order.
*
* Not a `*.test.ts` file, so Vitest does not collect it as a suite.
*/
import { vi } from 'vitest'
/** One `.from()` chain, flattened. */
export interface RecordedOp {
table: string
/** First data verb seen on the chain: select / insert / update / upsert / delete. */
verb: string
/** Column list passed to `.select('...')`, if any. */
columns: string | null
/** Payload passed to `.insert()` / `.update()` / `.upsert()`, if any. */
payload: Record<string, unknown> | null
}
const DATA_VERBS = new Set(['select', 'insert', 'update', 'upsert', 'delete'])
export function createRecordingSupabase() {
const queue: { data: unknown; error: unknown }[] = []
const ops: RecordedOp[] = []
const enqueue = (result: { data?: unknown; error?: unknown } = {}) => {
queue.push({ data: result.data ?? null, error: result.error ?? null })
}
const reset = () => {
queue.length = 0
ops.length = 0
}
const buildChain = (op: RecordedOp, result: { data: unknown; error: unknown }): unknown =>
new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
const name = String(prop)
return (...args: unknown[]) => {
if (DATA_VERBS.has(name)) {
if (op.verb === '') op.verb = name
if (name === 'select' && typeof args[0] === 'string') {
op.columns = args[0]
}
if (
(name === 'insert' || name === 'update' || name === 'upsert') &&
args[0] !== undefined
) {
op.payload = args[0] as Record<string, unknown>
}
}
return buildChain(op, result)
}
},
},
)
const start = (table: string): unknown => {
const op: RecordedOp = { table, verb: '', columns: null, payload: null }
ops.push(op)
return buildChain(op, queue.shift() ?? { data: null, error: null })
}
const supabase = {
from: vi.fn((table: string) => start(table)),
rpc: vi.fn((fn: string) => start(`rpc:${fn}`)),
auth: { getUser: vi.fn() },
}
/** Payloads of every recorded insert, in call order. */
const insertedRows = () =>
ops.filter((op) => op.verb === 'insert' && op.payload).map((op) => op.payload!)
/** Column lists of every recorded read (select-first chains), in call order. */
const selectedColumns = () =>
ops.filter((op) => op.verb === 'select').map((op) => op.columns)
return { supabase, enqueue, reset, ops, insertedRows, selectedColumns }
}
@@ -1,15 +1,26 @@
/**
* Auth-wiring tests for /api/salary/employees/[id]/worked-hours (POST upsert).
* Tests for /api/salary/employees/[id]/worked-hours (GET list, POST upsert).
*
* Runs the route through the real withRouteContext wrapper; mocks auth/company/
* write and injects a queued Supabase mock via requireAuth. Covers 401, 403
* (viewer), and a POST happy path.
* Runs the routes through the real withRouteContext wrapper; mocks auth/company/
* write and injects a recording Supabase mock via requireAuth. Covers 401, 403
* (viewer), 400, 404 and the happy path, plus the shift-window round trip.
*
* The shift-window tests are the point of this file. `start_time` / `end_time`
* are validated and documented by UpsertWorkedDaySchema, but used to be dropped
* on the way to the database. A row without times makes the shift-premium engine
* assume a default 08:00-17:00 day, so OB-tillägg for night and weekend work
* could never trigger and a night shift was paid as office hours. The last two
* tests therefore feed the payload the route actually inserts into the real
* engine and assert the premium appears.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { createRecordingSupabase } from './recording-supabase'
import { computePremiumLines } from '@/lib/salary/shift-premium-engine'
import type { ShiftPremiumRule } from '@/types'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const { supabase, enqueue, reset, insertedRows, selectedColumns } = createRecordingSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
@@ -28,7 +39,7 @@ vi.mock('@/lib/auth/require-write', () => ({
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../route'
import { GET, POST } from '../route'
const params = { params: Promise.resolve({ id: 'emp-1' }) } as never
@@ -36,6 +47,31 @@ function post(body: unknown) {
return createMockRequest('/api/salary/employees/emp-1/worked-hours', { method: 'POST', body })
}
function get(searchParams: Record<string, string>) {
return createMockRequest('/api/salary/employees/emp-1/worked-hours', { searchParams })
}
function makeRule(overrides: Partial<ShiftPremiumRule> = {}): ShiftPremiumRule {
return {
id: 'rule-1',
company_id: 'company-1',
name: 'OB natt',
applies_to_all_employees: true,
applies_to_employee_ids: [],
day_of_week: [1, 2, 3, 4, 5, 6, 7],
start_time: '22:00',
end_time: '06:00',
premium_percent: 70,
item_type: 'ob_night',
priority: 10,
is_active: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
created_by: null,
...overrides,
}
}
describe('POST /api/salary/employees/[id]/worked-hours', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -65,6 +101,18 @@ describe('POST /api/salary/employees/[id]/worked-hours', () => {
expect(response.status).toBe(403)
})
it('returns 400 when only one half of the shift window is given', async () => {
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee
const response = await POST(
post({ work_date: '2026-07-01', hours: 8, start_time: '22:00' }),
params,
)
expect(response.status).toBe(400)
// Nothing was written.
expect(insertedRows()).toHaveLength(0)
})
it('upserts a worked day (happy path)', async () => {
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee
enqueue({ data: null }) // delete existing
@@ -83,4 +131,190 @@ describe('POST /api/salary/employees/[id]/worked-hours', () => {
const response = await POST(post({ work_date: '2026-07-01', hours: 8 }), params)
expect(response.status).toBe(404)
})
it('writes start_time and end_time to the database', async () => {
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee
enqueue({ data: null }) // delete existing
enqueue({
data: {
id: 'wd-1',
work_date: '2026-07-01',
hours: 8,
start_time: '22:00',
end_time: '06:00',
},
}) // insert
const response = await POST(
post({ work_date: '2026-07-01', hours: 8, start_time: '22:00', end_time: '06:00' }),
params,
)
expect(response.status).toBe(201)
const rows = insertedRows()
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
work_date: '2026-07-01',
hours: 8,
start_time: '22:00',
end_time: '06:00',
})
})
it('stores NULL times when the caller omits the shift window', async () => {
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } })
enqueue({ data: null })
enqueue({ data: { id: 'wd-1' } })
await POST(post({ work_date: '2026-07-01', hours: 8 }), params)
expect(insertedRows()[0]).toMatchObject({ start_time: null, end_time: null })
})
it('makes a night shift trigger OB natt (regression: times used to be dropped)', async () => {
// Wednesday 2026-07-01, 22:00 -> 06:00. A pure-night OB rule (22:00-06:00)
// has zero overlap with the engine's 08:00-17:00 fallback, so before the
// times were persisted this employee got no OB-tillägg at all.
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } })
enqueue({ data: null })
enqueue({ data: { id: 'wd-1' } })
await POST(
post({ work_date: '2026-07-01', hours: 8, start_time: '22:00', end_time: '06:00' }),
params,
)
const inserted = insertedRows()[0]
const lines = computePremiumLines({
employeeId: 'emp-1',
baseHourlyRate: 200,
workedDays: [
{
work_date: inserted.work_date as string,
hours: inserted.hours as number,
start_time: inserted.start_time as string | null,
end_time: inserted.end_time as string | null,
},
],
rules: [makeRule()],
})
expect(lines).toHaveLength(1)
expect(lines[0].itemType).toBe('ob_night')
// 22:00-24:00 plus 00:00-06:00 = 8 h at 70 % of 200 SEK/h.
expect(lines[0].hours).toBe(8)
expect(lines[0].amount).toBe(1120)
})
it('prices a Saturday evening shift on its real hours, not the fabricated day', async () => {
// Saturday 2026-07-04, 18:00 -> 23:00 = 5 h. With a full-day weekend rule the
// 08:00-17:00 fallback would have billed 9 h of the wrong hours instead.
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } })
enqueue({ data: null })
enqueue({ data: { id: 'wd-1' } })
await POST(
post({ work_date: '2026-07-04', hours: 5, start_time: '18:00', end_time: '23:00' }),
params,
)
const inserted = insertedRows()[0]
const lines = computePremiumLines({
employeeId: 'emp-1',
baseHourlyRate: 200,
workedDays: [
{
work_date: inserted.work_date as string,
hours: inserted.hours as number,
start_time: inserted.start_time as string | null,
end_time: inserted.end_time as string | null,
},
],
rules: [
makeRule({
id: 'rule-weekend',
name: 'OB helg',
item_type: 'ob_weekend',
day_of_week: [6, 7],
start_time: '00:00',
end_time: '00:00', // full 24 h on Saturday and Sunday
premium_percent: 100,
}),
],
})
expect(lines).toHaveLength(1)
expect(lines[0].itemType).toBe('ob_weekend')
expect(lines[0].hours).toBe(5)
expect(lines[0].amount).toBe(1000)
})
})
describe('GET /api/salary/employees/[id]/worked-hours', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(get({ from: '2026-07-01', to: '2026-07-31' }), params)
expect(response.status).toBe(401)
})
it('returns 404 when the employee is not in the company', async () => {
enqueue({ data: null }) // loadEmployee → not found
const response = await GET(get({ from: '2026-07-01', to: '2026-07-31' }), params)
expect(response.status).toBe(404)
})
it('returns 400 when the range is inverted', async () => {
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee
const response = await GET(get({ from: '2026-07-31', to: '2026-07-01' }), params)
expect(response.status).toBe(400)
})
it('selects the shift window back and returns it', async () => {
enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee
enqueue({
data: [
{
id: 'wd-1',
work_date: '2026-07-01',
hours: 8,
notes: null,
salary_run_employee_id: null,
start_time: '22:00:00',
end_time: '06:00:00',
created_at: '2026-07-01T00:00:00Z',
updated_at: '2026-07-01T00:00:00Z',
},
],
})
const response = await GET(get({ from: '2026-07-01', to: '2026-07-31' }), params)
const { status, body } = await parseJsonResponse<{
data: Array<{ start_time: string | null; end_time: string | null }>
total_hours: number
}>(response)
expect(status).toBe(200)
// The read path has to ask for the columns, otherwise the round trip is
// silently lossy no matter what the write path stored.
const readColumns = selectedColumns().find((cols) => cols?.includes('work_date'))
expect(readColumns).toContain('start_time')
expect(readColumns).toContain('end_time')
expect(body.data[0].start_time).toBe('22:00:00')
expect(body.data[0].end_time).toBe('06:00:00')
expect(body.total_hours).toBe(8)
})
})
@@ -1,15 +1,22 @@
/**
* Auth-wiring tests for /api/salary/employees/[id]/worked-hours/batch (POST).
* Tests for /api/salary/employees/[id]/worked-hours/batch (POST).
*
* Runs the route through the real withRouteContext wrapper; mocks auth/company/
* write and injects a queued Supabase mock via requireAuth. Covers 401, 403
* (viewer), and a POST happy path (bulk insert).
* write and injects a recording Supabase mock via requireAuth. Covers 401, 403
* (viewer), 400, 404 and the happy path.
*
* The rest of the file guards the delete-and-reinsert. The batch carries one
* shared body for N dates, so anything it omits (per-day notes, per-day shift
* windows, per-day run links) has to survive the replace. It used to write the
* shared value or NULL over every day, which wiped notes the user had written
* and shift windows that made OB-tillägg computable.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { createRecordingSupabase } from '../../__tests__/recording-supabase'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const { supabase, enqueue, reset, insertedRows, ops } = createRecordingSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
@@ -33,11 +40,21 @@ import { POST } from '../route'
const params = { params: Promise.resolve({ id: 'emp-1' }) } as never
function post(body: unknown) {
return createMockRequest('/api/salary/employees/emp-1/worked-hours/batch', { method: 'POST', body })
return createMockRequest('/api/salary/employees/emp-1/worked-hours/batch', {
method: 'POST',
body,
})
}
const validBatch = { dates: ['2026-07-01', '2026-07-02'], hours: 8 }
/** Queue the reads the route does before its inserts: employee, prefetch, delete. */
function enqueuePreamble(existing: unknown[] = []) {
enqueue({ data: { id: 'emp-1' } }) // employee ownership check
enqueue({ data: existing }) // prefetch of rows about to be replaced
enqueue({ data: null }) // bulk delete
}
describe('POST /api/salary/employees/[id]/worked-hours/batch', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -67,14 +84,31 @@ describe('POST /api/salary/employees/[id]/worked-hours/batch', () => {
expect(response.status).toBe(403)
})
it('bulk-inserts worked days (happy path)', async () => {
it('returns 400 for an empty date list', async () => {
enqueue({ data: { id: 'emp-1' } }) // employee ownership check
enqueue({ data: null }) // bulk delete
const response = await POST(post({ dates: [], hours: 8 }), params)
expect(response.status).toBe(400)
expect(insertedRows()).toHaveLength(0)
})
it('returns 400 when only one half of the shift window is given', async () => {
enqueue({ data: { id: 'emp-1' } }) // employee ownership check
const response = await POST(post({ ...validBatch, end_time: '06:00' }), params)
expect(response.status).toBe(400)
expect(insertedRows()).toHaveLength(0)
})
it('bulk-inserts worked days (happy path)', async () => {
enqueuePreamble()
enqueue({ data: null }) // insert date 1
enqueue({ data: null }) // insert date 2
const response = await POST(post(validBatch), params)
const { status, body } = await parseJsonResponse<{ data: { inserted: number; conflicts: unknown[] } }>(response)
const { status, body } = await parseJsonResponse<{
data: { inserted: number; conflicts: unknown[] }
}>(response)
expect(status).toBe(201)
expect(body.data.inserted).toBe(2)
@@ -87,4 +121,249 @@ describe('POST /api/salary/employees/[id]/worked-hours/batch', () => {
const response = await POST(post(validBatch), params)
expect(response.status).toBe(404)
})
it('keeps each day its own note when the batch carries none', async () => {
enqueuePreamble([
{
work_date: '2026-07-01',
notes: 'Inventering lager',
salary_run_employee_id: null,
start_time: null,
end_time: null,
},
{
work_date: '2026-07-02',
notes: 'Kundbesök Göteborg',
salary_run_employee_id: null,
start_time: null,
end_time: null,
},
])
enqueue({ data: null })
enqueue({ data: null })
const response = await POST(post(validBatch), params)
expect(response.status).toBe(201)
const rows = insertedRows()
expect(rows).toHaveLength(2)
expect(rows[0]).toMatchObject({ work_date: '2026-07-01', notes: 'Inventering lager' })
expect(rows[1]).toMatchObject({ work_date: '2026-07-02', notes: 'Kundbesök Göteborg' })
})
it('lets an explicit batch note override the stored per-day notes', async () => {
enqueuePreamble([
{
work_date: '2026-07-01',
notes: 'Inventering lager',
salary_run_employee_id: null,
start_time: null,
end_time: null,
},
])
enqueue({ data: null })
enqueue({ data: null })
await POST(post({ ...validBatch, notes: 'Projektvecka' }), params)
const rows = insertedRows()
expect(rows[0]).toMatchObject({ work_date: '2026-07-01', notes: 'Projektvecka' })
expect(rows[1]).toMatchObject({ work_date: '2026-07-02', notes: 'Projektvecka' })
})
it('writes the shift window when the batch supplies one', async () => {
enqueuePreamble()
enqueue({ data: null })
enqueue({ data: null })
await POST(post({ ...validBatch, start_time: '22:00', end_time: '06:00' }), params)
for (const row of insertedRows()) {
expect(row).toMatchObject({ start_time: '22:00', end_time: '06:00' })
}
})
it('keeps a stored shift window when the batch supplies none', async () => {
// Re-marking hours must not silently erase the times: that would send the
// day back to the engine's 08:00-17:00 fallback and drop the OB-tillägg.
enqueuePreamble([
{
work_date: '2026-07-01',
notes: null,
salary_run_employee_id: null,
start_time: '22:00:00',
end_time: '06:00:00',
},
])
enqueue({ data: null })
enqueue({ data: null })
await POST(post(validBatch), params)
const rows = insertedRows()
expect(rows[0]).toMatchObject({ start_time: '22:00:00', end_time: '06:00:00' })
// The untouched day has no stored window, so it stays NULL.
expect(rows[1]).toMatchObject({ start_time: null, end_time: null })
})
it('keeps a stored salary_run_employee_id when the batch supplies none', async () => {
enqueuePreamble([
{
work_date: '2026-07-01',
notes: null,
salary_run_employee_id: 'sre-1',
start_time: null,
end_time: null,
},
])
enqueue({ data: null })
enqueue({ data: null })
await POST(post(validBatch), params)
const rows = insertedRows()
expect(rows[0]).toMatchObject({ salary_run_employee_id: 'sre-1' })
expect(rows[1]).toMatchObject({ salary_run_employee_id: null })
})
it('reads the existing rows before deleting them', async () => {
enqueuePreamble()
enqueue({ data: null })
enqueue({ data: null })
await POST(post(validBatch), params)
const workedDayOps = ops.filter((op) => op.table === 'salary_worked_days')
expect(workedDayOps[0].verb).toBe('select')
expect(workedDayOps[0].columns).toContain('notes')
expect(workedDayOps[0].columns).toContain('start_time')
// hours must be captured too: it is what makes a destroyed row restorable
// when a reinsert fails after the bulk delete already ran.
expect(workedDayOps[0].columns).toContain('hours')
expect(workedDayOps[1].verb).toBe('delete')
})
it('restores the pre-existing row when a date conflicts on the 24h cap', async () => {
// The conflict is reported as "nothing changed" for that date, so the row
// the bulk delete destroyed must be put back verbatim: original hours,
// notes and shift window, not the batch's values.
enqueuePreamble([
{
work_date: '2026-07-01',
hours: 5,
notes: 'Halvdag',
salary_run_employee_id: 'sre-1',
start_time: '08:00:00',
end_time: '13:00:00',
},
])
enqueue({ error: { message: 'Total tid över 24h', code: '23514' } }) // insert date 1 trips the cap
enqueue({ data: null }) // restore of date 1
enqueue({ data: null }) // insert date 2
const response = await POST(post(validBatch), params)
const { status, body } = await parseJsonResponse<{
data: { inserted: number; conflicts: { date: string }[] }
}>(response)
expect(status).toBe(207)
expect(body.data.inserted).toBe(1)
expect(body.data.conflicts).toHaveLength(1)
expect(body.data.conflicts[0].date).toBe('2026-07-01')
const rows = insertedRows()
// 1: failed replacement attempt, 2: restore, 3: date-2 replacement.
expect(rows).toHaveLength(3)
expect(rows[1]).toMatchObject({
work_date: '2026-07-01',
hours: 5,
notes: 'Halvdag',
salary_run_employee_id: 'sre-1',
start_time: '08:00:00',
end_time: '13:00:00',
})
expect(rows[2]).toMatchObject({ work_date: '2026-07-02', hours: 8 })
})
it('does not attempt a restore for a conflicting date that had no prior row', async () => {
enqueuePreamble() // nothing stored on either date
enqueue({ error: { message: 'Total tid över 24h', code: '23514' } }) // insert date 1
enqueue({ data: null }) // insert date 2
const response = await POST(post(validBatch), params)
const { status, body } = await parseJsonResponse<{
data: { inserted: number; conflicts: unknown[] }
}>(response)
expect(status).toBe(207)
expect(body.data.inserted).toBe(1)
expect(body.data.conflicts).toHaveLength(1)
// Only the two replacement attempts: no phantom restore insert.
expect(insertedRows()).toHaveLength(2)
})
it('restores the remaining dates when an unexpected error aborts the batch', async () => {
// Three dates; the bulk delete destroyed all three stored rows. Date 1
// replaces fine, date 2 hits an unexpected error: dates 2 and 3 must be
// put back before the 500 goes out, otherwise their rows are simply gone.
const threeDates = { dates: ['2026-07-01', '2026-07-02', '2026-07-03'], hours: 8 }
enqueuePreamble([
{
work_date: '2026-07-02',
hours: 4,
notes: 'Halvdag',
salary_run_employee_id: null,
start_time: null,
end_time: null,
},
{
work_date: '2026-07-03',
hours: 6,
notes: 'Kundbesök',
salary_run_employee_id: 'sre-9',
start_time: '10:00:00',
end_time: '16:00:00',
},
])
enqueue({ data: null }) // insert date 1: ok
enqueue({ error: { message: 'connection reset', code: '08006' } }) // insert date 2: unexpected
enqueue({ data: null }) // restore date 2
enqueue({ data: null }) // restore date 3
const response = await POST(post(threeDates), params)
const { status, body } = await parseJsonResponse<{ inserted: number }>(response)
expect(status).toBe(500)
expect(body.inserted).toBe(1)
const rows = insertedRows()
// 1: date-1 replacement, 2: failed date-2 attempt, 3-4: restores.
expect(rows).toHaveLength(4)
expect(rows[2]).toMatchObject({ work_date: '2026-07-02', hours: 4, notes: 'Halvdag' })
expect(rows[3]).toMatchObject({
work_date: '2026-07-03',
hours: 6,
notes: 'Kundbesök',
salary_run_employee_id: 'sre-9',
start_time: '10:00:00',
end_time: '16:00:00',
})
// Date 1 was successfully replaced: it must NOT be clobbered by a restore.
expect(rows.filter((r) => r.work_date === '2026-07-01')).toHaveLength(1)
})
it('aborts without deleting anything when the prefetch fails', async () => {
enqueue({ data: { id: 'emp-1' } }) // employee ownership check
enqueue({ error: { message: 'connection reset', code: '08006' } }) // prefetch fails
const response = await POST(post(validBatch), params)
expect(response.status).toBe(500)
expect(insertedRows()).toHaveLength(0)
// The only salary_worked_days query issued was the failed read: had the
// delete run first, the batch would have destroyed rows it never replaced.
const workedDayOps = ops.filter((op) => op.table === 'salary_worked_days')
expect(workedDayOps).toHaveLength(1)
expect(workedDayOps[0].verb).toBe('select')
})
})
@@ -12,9 +12,24 @@ interface BatchConflict {
reason: string
}
/**
* The per-day values the delete-and-reinsert below must not destroy. `hours`
* is captured too: it is not merged into the replacement rows (the batch's
* whole point is to overwrite hours), but it is what makes a destroyed row
* restorable when the replacement insert fails after the delete already ran.
*/
interface ExistingWorkedDay {
work_date: string
hours: number
notes: string | null
salary_run_employee_id: string | null
start_time: string | null
end_time: string | null
}
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.employees.worked_hours.batch',
async (request, { supabase, companyId }, { params }) => {
async (request, { supabase, companyId, log }, { params }) => {
const { id: employeeId } = await params
const { data: employee } = await supabase
@@ -35,6 +50,36 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
// (e.g. shift-clicking over the same date twice).
const uniqueDates = Array.from(new Set(body.dates))
// Read the rows we are about to replace BEFORE deleting them. The batch
// carries one shared value for N dates, so it cannot express per-day notes,
// per-day shift windows or per-day run links: anything the body omits has to
// survive the replace. Without this, "mark Mon-Fri as 8 h" silently wipes
// every note the user wrote on those days and every shift window that made
// OB-tillägg computable. Read first so a failure here aborts before we have
// deleted anything.
const { data: existingRows, error: existingError } = await supabase
.from('salary_worked_days')
.select('work_date, hours, notes, salary_run_employee_id, start_time, end_time')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.in('work_date', uniqueDates)
if (existingError) {
return NextResponse.json({ error: getUserErrorMessage(existingError) }, { status: 500 })
}
const existingByDate = new Map<string, ExistingWorkedDay>(
((existingRows ?? []) as ExistingWorkedDay[]).map((row) => [row.work_date, row]),
)
// A supplied shift window applies to every date in the batch; an omitted one
// leaves each day's stored window alone. Resolved as a pair so a body start
// time is never mixed with a stored end time (the schema pairs them too).
const bodyShiftWindow =
body.start_time != null && body.end_time != null
? { start_time: body.start_time, end_time: body.end_time }
: null
// Bulk delete existing rows on these dates first so the per-row insert step
// is a clean replace. Stays within RLS via company_id + employee_id filter.
const { error: deleteError } = await supabase
@@ -48,30 +93,98 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
return NextResponse.json({ error: getUserErrorMessage(deleteError) }, { status: 500 })
}
// Reinsert a captured pre-existing row verbatim. The bulk delete above has
// already destroyed the stored row for every date in the batch, so when a
// date's replacement insert fails, this is what turns "row destroyed" back
// into "nothing changed". The original row passed the 24h-cap trigger when
// it was first written, so restoring the identical values is expected to
// pass it again.
const restoreDay = async (existing: ExistingWorkedDay) => {
const { error } = await supabase.from('salary_worked_days').insert({
company_id: companyId,
employee_id: employeeId,
work_date: existing.work_date,
hours: existing.hours,
notes: existing.notes,
salary_run_employee_id: existing.salary_run_employee_id,
start_time: existing.start_time,
end_time: existing.end_time,
})
return error
}
// Best-effort restore of every not-yet-processed date's pre-existing row,
// used when an unexpected error aborts the loop: without it, every date
// after the failure point would have been deleted and never reinserted.
const restoreRemaining = async (fromIndex: number) => {
for (let j = fromIndex; j < uniqueDates.length; j++) {
const existing = existingByDate.get(uniqueDates[j])
if (!existing) continue
const restoreError = await restoreDay(existing)
if (restoreError) {
log.error('worked-hours batch: failed to restore a deleted row after an aborted batch', {
employeeId,
workDate: existing.work_date,
error: restoreError.message,
})
}
}
}
// Per-row insert so we can isolate trigger failures (24h cap on a date with
// existing absence) without aborting the whole batch. A single multi-row
// insert would fail-fast and surface only the first conflict.
const conflicts: BatchConflict[] = []
let inserted = 0
for (const date of uniqueDates) {
for (let i = 0; i < uniqueDates.length; i++) {
const date = uniqueDates[i]
const existing = existingByDate.get(date)
const shiftWindow = bodyShiftWindow ?? {
start_time: existing?.start_time ?? null,
end_time: existing?.end_time ?? null,
}
const { error } = await supabase
.from('salary_worked_days')
.insert({
company_id: companyId,
employee_id: employeeId,
work_date: date,
// hours is the point of the batch: it always overwrites.
hours: body.hours,
notes: body.notes ?? null,
salary_run_employee_id: body.salary_run_employee_id ?? null,
// Everything else: the body wins when it carries a value, otherwise
// the day keeps what it already had.
notes: body.notes ?? existing?.notes ?? null,
salary_run_employee_id:
body.salary_run_employee_id ?? existing?.salary_run_employee_id ?? null,
start_time: shiftWindow.start_time,
end_time: shiftWindow.end_time,
})
if (error) {
// 24h cap trigger uses ERRCODE check_violation (23514) and a Swedish
// message starting with "Total tid". Other failures are unexpected.
if (error.message?.includes('Total tid') || error.code === '23514') {
// The conflict report says "nothing changed for this date": make
// that true by reinserting the pre-existing row the bulk delete
// destroyed. A date with no prior row has nothing to restore.
if (existing) {
const restoreError = await restoreDay(existing)
if (restoreError) {
// The restore itself failed: the date's data IS lost unless the
// remaining dates are put back and the caller is told loudly.
await restoreRemaining(i + 1)
return NextResponse.json(
{ error: getUserErrorMessage(restoreError), inserted, conflicts },
{ status: 500 },
)
}
}
conflicts.push({ date, reason: getUserErrorMessage(error) })
continue
}
// Unexpected error: put back the captured rows for this date and every
// date the loop never reached, then surface the failure.
await restoreRemaining(i)
return NextResponse.json(
{ error: getUserErrorMessage(error), inserted, conflicts },
{ status: 500 },

Some files were not shown because too many files have changed in this diff Show More