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
+271
View File
@@ -0,0 +1,271 @@
/**
* Cron failure reporting (lib/auth/cron.ts).
*
* Lives here rather than next to lib/auth/__tests__/cron.test.ts, which covers
* the constant-time secret comparison and is left untouched.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
CRON_REPORT_THROTTLE_MS,
reportCronFailure,
reportCronSuccess,
resetCronReportingState,
shouldReportCronFailure,
verifyCronSecret,
} from '../auth/cron'
import {
registerObservabilitySink,
resetObservabilitySink,
type ObservabilityContext,
type ObservabilityLevel,
} from '../observability/sink'
interface Captured {
kind: 'exception' | 'message'
error?: unknown
message?: string
level?: ObservabilityLevel
context: ObservabilityContext
}
let events: Captured[] = []
beforeEach(() => {
events = []
resetCronReportingState()
vi.unstubAllEnvs()
registerObservabilitySink({
name: 'test',
captureException(error, context) {
events.push({ kind: 'exception', error, context })
},
captureMessage(message, level, context) {
events.push({ kind: 'message', message, level, context })
},
async flush() {
return true
},
})
})
afterEach(() => {
resetObservabilitySink()
resetCronReportingState()
vi.unstubAllEnvs()
})
describe('shouldReportCronFailure', () => {
it('reports the first failure by default', () => {
expect(shouldReportCronFailure({ consecutiveFailures: 1, lastReportAt: null })).toBe(true)
})
it('waits for the threshold when one is given', () => {
expect(
shouldReportCronFailure({ consecutiveFailures: 2, lastReportAt: null, threshold: 3 }),
).toBe(false)
expect(
shouldReportCronFailure({ consecutiveFailures: 3, lastReportAt: null, threshold: 3 }),
).toBe(true)
})
it('throttles repeats inside the window and allows them after it', () => {
const now = 1_000_000_000
expect(
shouldReportCronFailure({ consecutiveFailures: 5, lastReportAt: now - 1000, now }),
).toBe(false)
expect(
shouldReportCronFailure({
consecutiveFailures: 5,
lastReportAt: now - CRON_REPORT_THROTTLE_MS - 1,
now,
}),
).toBe(true)
})
})
describe('reportCronFailure', () => {
it('sends a message event with cron context when there is no error object', () => {
reportCronFailure({ operation: 'cron.invoice-reminders', status: 500, reason: 'timeout' })
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('message')
expect(events[0].level).toBe('error')
expect(events[0].context).toMatchObject({
cron: true,
alert: true,
operation: 'cron.invoice-reminders',
cronKind: 'run',
status: 500,
reason: 'timeout',
consecutiveFailures: 1,
})
})
it('sends an exception event when an error is supplied', () => {
reportCronFailure({
operation: 'cron.documents-verify',
error: new Error('storage unreachable'),
itemsTotal: 10,
itemsFailed: 4,
})
expect(events[0].kind).toBe('exception')
expect(events[0].error).toMatchObject({ name: 'Error', message: 'storage unreachable' })
expect(events[0].context).toMatchObject({ itemsTotal: 10, itemsFailed: 4 })
})
it('throttles a storm of failures from one operation into a single event', () => {
for (let i = 0; i < 500; i++) {
reportCronFailure({ operation: 'cron.accruals', kind: 'item', reason: 'boom' })
}
expect(events).toHaveLength(1)
})
it('keeps different operations and kinds on separate throttles', () => {
reportCronFailure({ operation: 'cron.a', kind: 'run' })
reportCronFailure({ operation: 'cron.a', kind: 'auth' })
reportCronFailure({ operation: 'cron.b', kind: 'run' })
expect(events).toHaveLength(3)
})
it('reports again once the throttle window has passed, with a growing streak', () => {
const t0 = 1_000_000_000
reportCronFailure({ operation: 'cron.a', now: t0 })
reportCronFailure({ operation: 'cron.a', now: t0 + 1000 })
reportCronFailure({ operation: 'cron.a', now: t0 + CRON_REPORT_THROTTLE_MS + 1 })
expect(events).toHaveLength(2)
expect(events[0].context.consecutiveFailures).toBe(1)
expect(events[1].context.consecutiveFailures).toBe(3)
})
it('reportCronSuccess clears the streak so the next outage reports fresh', () => {
const t0 = 1_000_000_000
reportCronFailure({ operation: 'cron.a', now: t0 })
reportCronSuccess('cron.a')
reportCronFailure({ operation: 'cron.a', now: t0 + 1000 })
expect(events).toHaveLength(2)
expect(events[1].context.consecutiveFailures).toBe(1)
})
it('evicts only the oldest tracked key at capacity, keeping live throttles intact', () => {
const t0 = 1_000_000_000
// Fill the map to MAX_TRACKED_CRON_KEYS (200 in lib/auth/cron.ts).
for (let i = 0; i < 200; i++) {
reportCronFailure({ operation: `cron.op-${i}`, now: t0 })
}
events.length = 0
// A new key at capacity used to clear() the whole map; now it evicts the
// single oldest entry (cron.op-0).
reportCronFailure({ operation: 'cron.new', now: t0 + 1 })
expect(events).toHaveLength(1)
// cron.op-199's throttle survived: an in-progress storm stays damped
// instead of re-reporting immediately after the map was cleared.
reportCronFailure({ operation: 'cron.op-199', now: t0 + 2 })
expect(events).toHaveLength(1)
// cron.op-0 was the one evicted: its next failure reports from a fresh
// streak rather than inheriting the old one.
reportCronFailure({ operation: 'cron.op-0', now: t0 + 3 })
expect(events).toHaveLength(2)
expect(events[1].context).toMatchObject({
operation: 'cron.op-0',
consecutiveFailures: 1,
})
})
it('redacts PII in the supplied context before it reaches the sink', () => {
reportCronFailure({
operation: 'cron.salary',
context: { note: 'employee 800101-1234 skipped', token: 'abc123' },
})
const ctx = events[0].context as { note: string; token: string }
expect(ctx.note).toBe('[REDACTED]')
expect(ctx.token).toBe('[REDACTED]')
})
it('never throws, even when the sink is broken', () => {
registerObservabilitySink({
name: 'broken',
captureException() {
throw new Error('provider down')
},
captureMessage() {
throw new Error('provider down')
},
async flush() {
return false
},
})
expect(() => reportCronFailure({ operation: 'cron.a' })).not.toThrow()
})
})
describe('verifyCronSecret reporting', () => {
function cronRequest(auth?: string): Request {
return new Request('http://localhost/api/invoices/recurring/cron', {
headers: auth ? { authorization: auth } : {},
})
}
it('reports a missing CRON_SECRET, the config error that silently kills every job', () => {
vi.stubEnv('CRON_SECRET', '')
const result = verifyCronSecret(cronRequest('Bearer whatever'))
expect(result?.status).toBe(401)
expect(events).toHaveLength(1)
expect(events[0].context).toMatchObject({
cron: true,
alert: true,
cronKind: 'auth',
status: 401,
reason: 'secret_not_configured',
operation: '/api/invoices/recurring/cron',
})
})
it('reports a token mismatch, i.e. a rotated secret the scheduler still has', () => {
vi.stubEnv('CRON_SECRET', 'correct-secret')
const result = verifyCronSecret(cronRequest('Bearer stale-secret'))
expect(result?.status).toBe(401)
expect(events[0].context.reason).toBe('token_mismatch')
})
it('reports a missing authorization header', () => {
vi.stubEnv('CRON_SECRET', 'correct-secret')
verifyCronSecret(cronRequest())
expect(events[0].context.reason).toBe('missing_authorization')
})
it('uses an explicit operation name when one is given', () => {
vi.stubEnv('CRON_SECRET', 'correct-secret')
verifyCronSecret(cronRequest('Bearer nope'), { operation: 'cron.invoice-reminders' })
expect(events[0].context.operation).toBe('cron.invoice-reminders')
})
it('reports nothing on a successful auth, and clears the streak', () => {
vi.stubEnv('CRON_SECRET', 'correct-secret')
verifyCronSecret(cronRequest('Bearer wrong'))
expect(events).toHaveLength(1)
expect(verifyCronSecret(cronRequest('Bearer correct-secret'))).toBeNull()
expect(events).toHaveLength(1)
// Streak was cleared, so the next failure reports immediately rather than
// being swallowed by the previous report's throttle window.
verifyCronSecret(cronRequest('Bearer wrong'))
expect(events).toHaveLength(2)
expect(events[1].context.consecutiveFailures).toBe(1)
})
it('can be told not to report', () => {
vi.stubEnv('CRON_SECRET', 'correct-secret')
verifyCronSecret(cronRequest('Bearer wrong'), { report: false })
expect(events).toHaveLength(0)
})
})
+254
View File
@@ -0,0 +1,254 @@
/**
* The logger -> observability sink bridge.
*
* The redaction assertions here are the load-bearing ones: these log records
* carry personnummer and financial data, and the sink ships them to a third
* party. If any of these fail, redaction is being bypassed on the way out.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { createLogger } from '../logger'
import {
registerObservabilitySink,
resetObservabilitySink,
type ObservabilityContext,
type ObservabilityLevel,
} from '../observability/sink'
interface Captured {
kind: 'exception' | 'message'
error?: unknown
message?: string
level?: ObservabilityLevel
context: ObservabilityContext
}
let events: Captured[] = []
beforeEach(() => {
events = []
// The logger still writes to the console; keep the suite quiet.
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
registerObservabilitySink({
name: 'test',
captureException(error, context) {
events.push({ kind: 'exception', error, context })
},
captureMessage(message, level, context) {
events.push({ kind: 'message', message, level, context })
},
async flush() {
return true
},
})
})
afterEach(() => {
resetObservabilitySink()
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
describe('logger -> observability sink', () => {
it('forwards every error record with its bound context', () => {
const log = createLogger('test/module', { requestId: 'req_1' }).child({ companyId: 'co_1' })
const err = new Error('insert failed')
;(err as Error & { code?: string }).code = '23505'
log.error('could not save entry', err, { operation: 'journal.commit' })
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('exception')
expect(events[0].error).toMatchObject({ name: 'Error', message: 'insert failed', code: '23505' })
expect(events[0].context).toMatchObject({
module: 'test/module',
logLevel: 'error',
logMessage: 'could not save entry',
requestId: 'req_1',
companyId: 'co_1',
operation: 'journal.commit',
})
})
it('forwards an error record with no Error object as a message', () => {
const log = createLogger('m')
log.error('something went wrong', { status: 500 })
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('message')
expect(events[0].message).toBe('something went wrong')
expect(events[0].level).toBe('error')
expect(events[0].context.status).toBe(500)
})
it('forwards alert-flagged info/warn at their own severity, not as error', () => {
const log = createLogger('m')
log.warn('tax table stale', { alert: true })
log.info('backup finished late', { alert: true })
expect(events).toHaveLength(2)
expect(events[0]).toMatchObject({ kind: 'message', level: 'warning' })
expect(events[1]).toMatchObject({ kind: 'message', level: 'info' })
})
it('does not forward plain info/warn records', () => {
const log = createLogger('m')
log.info('routine')
log.warn('mildly odd')
expect(events).toHaveLength(0)
})
it('forwards info/warn records flagged alert: true', () => {
const log = createLogger('m')
log.warn('year-end omföring skipped', { alert: true, companyId: 'co_1' })
expect(events).toHaveLength(1)
expect(events[0].context).toMatchObject({ alert: true, logLevel: 'warn', companyId: 'co_1' })
})
it('honours alert: true bound on a child logger', () => {
const log = createLogger('m').child({ alert: true })
log.info('this must page someone')
expect(events).toHaveLength(1)
expect(events[0].context.alert).toBe(true)
})
// --- redaction: the sink must never receive PII ---------------------------
it('strips a personnummer from the message before the sink sees it', () => {
const log = createLogger('m')
log.error('could not match payment for 800101-1234')
expect(events).toHaveLength(1)
expect(events[0].message).toBe('[REDACTED]')
expect(events[0].context.logMessage).toBe('[REDACTED]')
expect(JSON.stringify(events[0])).not.toContain('800101-1234')
})
it('strips a personnummer from context values and from the error message', () => {
const log = createLogger('m')
log.error('rot/rut lookup failed', new Error('no customer with 19800101-1234'), {
note: 'belongs to 800101-1234',
companyId: '57484518-3409-4b29-9d23-5d22f08bda63',
})
expect(events[0].context.note).toBe('[REDACTED]')
expect((events[0].error as { message: string }).message).toBe('[REDACTED]')
// UUIDs survive: they are identifiers, not personnummer.
expect(events[0].context.companyId).toBe('57484518-3409-4b29-9d23-5d22f08bda63')
expect(JSON.stringify(events[0])).not.toContain('800101-1234')
})
it('strips denylisted keys, however deeply nested, before the sink sees them', () => {
const log = createLogger('m')
log.error('provider call failed', {
user: 'alice',
personnummer: '800101-1234',
iban: 'SE4550000000058398257466',
headers: { authorization: 'Bearer super-secret', cookie: 'sess=abc' },
body: { credentials: { password: 'hunter2', api_key: 'gnubok_sk_live_xyz' } },
})
const ctx = events[0].context as {
user: string
personnummer: string
iban: string
headers: { authorization: string; cookie: string }
body: { credentials: unknown }
}
expect(ctx.personnummer).toBe('[REDACTED]')
expect(ctx.iban).toBe('[REDACTED]')
expect(ctx.headers.authorization).toBe('[REDACTED]')
expect(ctx.headers.cookie).toBe('[REDACTED]')
expect(ctx.body.credentials).toBe('[REDACTED]')
expect(ctx.user).toBe('alice')
const serialized = JSON.stringify(events[0])
expect(serialized).not.toContain('super-secret')
expect(serialized).not.toContain('hunter2')
expect(serialized).not.toContain('gnubok_sk_live_xyz')
expect(serialized).not.toContain('SE4550000000058398257466')
})
it('redacts PII carried in trailing details args', () => {
const log = createLogger('m')
log.error('legacy call', 'customer 800101-1234 not found', 42)
expect(events[0].context.details).toEqual(['[REDACTED]', 42])
})
it('scrubs API keys, emails and IBANs from free-text messages, not just denylisted keys', () => {
const log = createLogger('m')
log.error('key gnubok_sk_live_secret1 for anna@example.com failed on SE4550000000058398257466')
expect(events[0].message).toBe(
'key [REDACTED_API_KEY] for [REDACTED_EMAIL] failed on [REDACTED_IBAN]',
)
const serialized = JSON.stringify(events[0])
expect(serialized).not.toContain('gnubok_sk_live_secret1')
expect(serialized).not.toContain('anna@example.com')
expect(serialized).not.toContain('SE4550000000058398257466')
})
// --- stacks: the sink keeps them, production stdout drops them -------------
it('in production the sink receives a stack while the stdout JSON line drops it', () => {
vi.stubEnv('NODE_ENV', 'production')
const log = createLogger('m')
log.error('insert failed', new Error('clean failure'))
// Sink path: the error tracker only runs in production, so this is the
// one environment where a missing stack would make every event ungroupable.
const sinkErr = events[0].error as { message: string; stack?: string }
expect(typeof sinkErr.stack).toBe('string')
expect(sinkErr.stack).toContain('clean failure')
// Stdout path: unchanged contract, production log lines stay stackless.
const line = (console.error as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string
const parsed = JSON.parse(line) as { err: { message: string; stack?: unknown } }
expect(parsed.err.message).toBe('clean failure')
expect(parsed.err.stack).toBeUndefined()
})
it('the stack reaching the sink in production is redacted', () => {
vi.stubEnv('NODE_ENV', 'production')
const log = createLogger('m')
log.error('lookup failed', new Error('no customer with 800101-1234'))
const sinkErr = events[0].error as { message: string; stack?: string }
// The first stack line carries the message, so the whole-string
// personnummer rule nukes the stack rather than leaking it.
expect(sinkErr.message).toBe('[REDACTED]')
expect(sinkErr.stack).toBe('[REDACTED]')
expect(JSON.stringify(events[0])).not.toContain('800101-1234')
})
// --- resilience ----------------------------------------------------------
it('keeps logging when the sink throws', () => {
registerObservabilitySink({
name: 'broken',
captureException() {
throw new Error('provider down')
},
captureMessage() {
throw new Error('provider down')
},
async flush() {
return false
},
})
const log = createLogger('m')
expect(() => log.error('still works', new Error('boom'))).not.toThrow()
expect(console.error).toHaveBeenCalled()
})
it('is inert when no provider is registered', () => {
resetObservabilitySink()
const log = createLogger('m')
expect(() => log.error('no sink installed', new Error('boom'))).not.toThrow()
expect(events).toHaveLength(0)
})
})
@@ -0,0 +1,401 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { loadBankingSummary } from '../inputs'
import { selectAtoms, formatMagnitude, buildCurrencyUncertaintyNotes } from '../atom-selection'
import type { ComposerInputs } from '../inputs'
// The composer prompt is the INPUT to the model that writes a company's
// standing agent instructions. A magnitude that is wrong here does not
// mislead one answer, it gets baked into durable instructions. So the rule
// these tests pin is: a number is only ever printed as "kr" when it really is
// kronor, and a magnitude we cannot express in kronor is printed as unknown,
// never as a confident figure and never silently dropped.
const { createMock } = vi.hoisted(() => ({ createMock: vi.fn() }))
vi.mock('../client', () => ({
getAnthropic: () => ({ messages: { create: createMock } }),
OPUS_MODEL: 'test-opus',
SONNET_MODEL: 'test-sonnet',
THINKING_BUDGET_STANDARD: 1,
THINKING_BUDGET_DEEP: 1,
}))
// sv-SE thousands separators are non-breaking spaces; build expectations the
// same way the renderer does so the assertions are not whitespace-fragile.
const sv = (n: number) => n.toLocaleString('sv-SE')
interface TxRow {
description: string | null
amount: number
currency?: string | null
amount_sek?: number | null
exchange_rate?: number | null
date?: string
journal_entry_id?: string | null
}
function buildSupabase(rows: TxRow[]) {
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'gte', 'order', 'limit']) {
chain[m] = () => chain
}
chain.then = (resolve: (v: unknown) => void) => resolve({ data: rows, error: null })
return { from: vi.fn().mockReturnValue(chain) } as never
}
function tx(overrides: Partial<TxRow> & { description: string; amount: number }): TxRow {
return {
currency: 'SEK',
amount_sek: null,
exchange_rate: null,
date: '2026-03-01',
journal_entry_id: 'je-1',
...overrides,
}
}
const ATOM_INDEX = [
{
id: 'horizontal/swedish-vat',
tier: 'horizontal' as const,
title: 'Moms',
description: 'Svensk moms.',
sni_prefixes: [],
trigger_signals: {},
estimated_tokens: 100,
version: 1,
},
]
function makeInputs(overrides: Partial<ComposerInputs> = {}): ComposerInputs {
return {
companyId: 'company-1',
companyName: 'Testbolaget AB',
entityType: 'aktiebolag',
ticSnapshot: null,
ticFetchedAt: null,
companySettings: null,
activeEmployees: null,
sieSummary: null,
bankingSummary: null,
atomIndex: ATOM_INDEX,
userIsConfirmedDirector: false,
...overrides,
}
}
function mockSelectionResponse() {
createMock.mockResolvedValue({
content: [
{
type: 'tool_use',
name: 'compose_agent_profile',
input: {
horizontal_atoms: ['horizontal/swedish-vat'],
vertical_atoms: [],
modifier_atoms: [],
is_multi_vertical: false,
verification_questions: ['Vad är er vanligaste kundtyp?'],
uncertainty_notes: [],
},
},
],
})
}
// Runs the real prompt builder and hands back the text the model would see.
async function renderPrompt(inputs: ComposerInputs): Promise<string> {
mockSelectionResponse()
await selectAtoms(inputs)
const call = createMock.mock.calls[0][0] as { messages: { content: string }[] }
return call.messages[0].content
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('loadBankingSummary currency handling', () => {
it('keeps a SEK-only company on a single kr magnitude', async () => {
const supabase = buildSupabase([
tx({ description: 'ACME AB faktura', amount: -300000 }),
tx({ description: 'ACME AB faktura', amount: -112000 }),
])
const summary = await loadBankingSummary(supabase, 'company-1')
expect(summary).not.toBeNull()
expect(summary!.currencies).toEqual(['SEK'])
expect(summary!.volume_rows_without_sek).toBe(0)
expect(summary!.unconvertible_counterparties).toEqual([])
expect(summary!.top_counterparties[0]).toMatchObject({
name: 'ACME AB faktura',
abs_amount: 412000,
rows_without_sek: 0,
by_currency: [{ currency: 'SEK', abs_amount: 412000 }],
})
})
it('never folds a foreign amount into the SEK total when no rate is stored', async () => {
const supabase = buildSupabase([
tx({ description: 'ACME AB faktura', amount: -412000 }),
tx({ description: 'Hetzner Online', amount: -30000, currency: 'EUR' }),
])
const summary = await loadBankingSummary(supabase, 'company-1')
const hetzner = summary!.top_counterparties.find((c) => c.name === 'Hetzner Online')!
// The 30 000 is EUR. It must not appear anywhere as a SEK amount.
expect(hetzner.abs_amount).toBe(0)
expect(hetzner.rows_without_sek).toBe(1)
expect(hetzner.by_currency).toEqual([{ currency: 'EUR', abs_amount: 30000 }])
// Monthly volume counts the SEK row only, and says how much it left out.
expect(summary!.monthly_volume).toBe(Math.round(412000 / 12))
expect(summary!.volume_rows_with_sek).toBe(1)
expect(summary!.volume_rows_without_sek).toBe(1)
expect(summary!.currencies).toEqual(['EUR', 'SEK'])
})
it('uses the stored SEK equivalent for a foreign row when there is one', async () => {
const supabase = buildSupabase([
tx({ description: 'Hetzner Online', amount: -30000, currency: 'EUR', amount_sek: -345000 }),
])
const summary = await loadBankingSummary(supabase, 'company-1')
expect(summary!.top_counterparties[0]).toMatchObject({
abs_amount: 345000,
rows_without_sek: 0,
by_currency: [{ currency: 'EUR', abs_amount: 30000 }],
})
expect(summary!.monthly_volume).toBe(Math.round(345000 / 12))
})
it('derives the SEK equivalent from exchange_rate when amount_sek is absent', async () => {
const supabase = buildSupabase([
tx({ description: 'Hetzner Online', amount: -1000, currency: 'EUR', exchange_rate: 11.5 }),
])
const summary = await loadBankingSummary(supabase, 'company-1')
expect(summary!.top_counterparties[0]).toMatchObject({
abs_amount: 11500,
rows_without_sek: 0,
})
})
it('reports an unknown monthly volume rather than a zero when nothing converts', async () => {
const supabase = buildSupabase([
tx({ description: 'Stripe Inc', amount: -12000, currency: 'USD' }),
tx({ description: 'Hetzner Online', amount: -30000, currency: 'EUR' }),
])
const summary = await loadBankingSummary(supabase, 'company-1')
expect(summary!.monthly_volume).toBeNull()
expect(summary!.volume_rows_with_sek).toBe(0)
expect(summary!.volume_rows_without_sek).toBe(2)
})
it('surfaces a rate-less counterparty that the SEK ranking would have dropped', async () => {
// 20 SEK counterparties outrank the foreign one on the kr scale, so the
// top-20 slice would silently swallow it.
const rows: TxRow[] = []
for (let i = 0; i < 20; i++) {
rows.push(tx({ description: `Kund nummer ${String.fromCharCode(65 + i)}`, amount: 100000 + i }))
}
rows.push(tx({ description: 'Stripe Inc', amount: -12000, currency: 'USD' }))
const summary = await loadBankingSummary(buildSupabase(rows), 'company-1')
expect(summary!.top_counterparties.map((c) => c.name)).not.toContain('Stripe Inc')
expect(summary!.unconvertible_counterparties.map((c) => c.name)).toEqual(['Stripe Inc'])
})
})
describe('formatMagnitude', () => {
it('renders plain SEK exactly as a bare kr figure', () => {
expect(
formatMagnitude({
abs_amount: 412000,
by_currency: [{ currency: 'SEK', abs_amount: 412000 }],
rows_without_sek: 0,
}),
).toBe(`${sv(412000)} kr`)
})
it('keeps each currency in its own unit and adds the SEK equivalent', () => {
const out = formatMagnitude({
abs_amount: 757000,
by_currency: [
{ currency: 'SEK', abs_amount: 412000 },
{ currency: 'EUR', abs_amount: 30000 },
],
rows_without_sek: 0,
})
expect(out).toBe(`${sv(412000)} SEK + ${sv(30000)} EUR (motsvarar ${sv(757000)} kr)`)
})
it('says the SEK value is unknown instead of printing 0 kr', () => {
const out = formatMagnitude({
abs_amount: 0,
by_currency: [{ currency: 'EUR', abs_amount: 30000 }],
rows_without_sek: 2,
})
expect(out).toContain(`${sv(30000)} EUR`)
expect(out).toContain('SEK-motsvarighet OKÄND')
// No number is ever labelled kronor here.
expect(out).not.toMatch(/\d[\s ]*kr/)
})
it('marks a partially convertible magnitude as partial', () => {
const out = formatMagnitude({
abs_amount: 412000,
by_currency: [
{ currency: 'SEK', abs_amount: 412000 },
{ currency: 'USD', abs_amount: 12000 },
],
rows_without_sek: 1,
})
expect(out).toContain(`varav ${sv(412000)} kr har känd SEK-motsvarighet`)
expect(out).toContain('saknar växelkurs')
})
})
describe('composer prompt rendering', () => {
it('renders a SEK-only company exactly as before', async () => {
const summary = await loadBankingSummary(
buildSupabase([
tx({ description: 'ACME AB faktura', amount: -300000 }),
tx({ description: 'ACME AB faktura', amount: -112000 }),
]),
'company-1',
)
const prompt = await renderPrompt(makeInputs({ bankingSummary: summary }))
expect(prompt).toContain(`Snittvolym per månad: ${sv(Math.round(412000 / 12))} kr`)
expect(prompt).toContain(`ACME AB faktura: ${sv(412000)} kr (ut, bokförd)`)
// No caveats invented for a company that has nothing to caveat.
expect(prompt).not.toContain('GOLV')
expect(prompt).not.toContain('Valutor i underlaget')
expect(prompt).not.toContain('Motparter utan känd SEK-motsvarighet')
})
// KEY TEST: this is the finding. A 30 000 EUR counterparty was rendered as
// "30 000 kr" and fed straight into durable instruction generation.
it('does not give a multi-currency company a single misleading kr figure', async () => {
const summary = await loadBankingSummary(
buildSupabase([
tx({ description: 'ACME AB faktura', amount: -412000 }),
tx({ description: 'Hetzner Online', amount: -30000, currency: 'EUR' }),
]),
'company-1',
)
const prompt = await renderPrompt(makeInputs({ bankingSummary: summary }))
// The EUR magnitude must never be labelled kronor.
expect(prompt).not.toContain(`${sv(30000)} kr`)
expect(prompt).toContain(`${sv(30000)} EUR`)
expect(prompt).toContain('SEK-motsvarighet OKÄND')
// And the volume is honestly reported as a floor.
expect(prompt).toContain(`Snittvolym per månad: ${sv(Math.round(412000 / 12))} kr (GOLV:`)
expect(prompt).toContain('Valutor i underlaget: EUR, SEK')
})
it('keeps rate-less counterparties visible instead of dropping them at the slice', async () => {
const rows: TxRow[] = []
for (let i = 0; i < 20; i++) {
rows.push(tx({ description: `Kund nummer ${String.fromCharCode(65 + i)}`, amount: 100000 + i }))
}
rows.push(tx({ description: 'Stripe Inc', amount: -12000, currency: 'USD' }))
const summary = await loadBankingSummary(buildSupabase(rows), 'company-1')
const prompt = await renderPrompt(makeInputs({ bankingSummary: summary }))
expect(prompt).toContain('Motparter utan känd SEK-motsvarighet')
expect(prompt).toContain(`Stripe Inc: ${sv(12000)} USD`)
expect(prompt).not.toContain(`Stripe Inc: ${sv(12000)} kr`)
})
it('states that the monthly volume is unknown when no row converts', async () => {
const summary = await loadBankingSummary(
buildSupabase([
tx({ description: 'Stripe Inc', amount: -12000, currency: 'USD' }),
tx({ description: 'Hetzner Online', amount: -30000, currency: 'EUR' }),
]),
'company-1',
)
const prompt = await renderPrompt(makeInputs({ bankingSummary: summary }))
expect(prompt).toContain('Snittvolym per månad: OKÄND')
expect(prompt).not.toMatch(/Snittvolym per månad: [\d\s ]+ kr/)
})
it('applies the same unit rule to the SIE counterparty rollup', async () => {
const prompt = await renderPrompt(
makeInputs({
sieSummary: {
year_count: 2,
top_accounts: [{ account: '3011', abs_amount: 900000 }],
top_counterparties: [
{
name: 'Hetzner Online',
abs_amount: 0,
by_currency: [{ currency: 'EUR', abs_amount: 30000 }],
rows_without_sek: 3,
},
],
unconvertible_counterparties: [],
},
}),
)
expect(prompt).toContain(`Hetzner Online: ${sv(30000)} EUR`)
expect(prompt).not.toContain(`Hetzner Online: ${sv(30000)} kr`)
// Ledger balances are genuinely SEK and keep their bare kr rendering.
expect(prompt).toContain(`${sv(900000)} kr`)
})
})
describe('buildCurrencyUncertaintyNotes', () => {
it('returns nothing for a SEK-only company', async () => {
const summary = await loadBankingSummary(
buildSupabase([tx({ description: 'ACME AB faktura', amount: -412000 })]),
'company-1',
)
expect(buildCurrencyUncertaintyNotes(makeInputs({ bankingSummary: summary }))).toEqual([])
})
it('flags multi-currency data and the rows left out of the volume', async () => {
const summary = await loadBankingSummary(
buildSupabase([
tx({ description: 'ACME AB faktura', amount: -412000 }),
tx({ description: 'Hetzner Online', amount: -30000, currency: 'EUR' }),
]),
'company-1',
)
const notes = buildCurrencyUncertaintyNotes(makeInputs({ bankingSummary: summary }))
expect(notes.join(' ')).toContain('flera valutor')
expect(notes.join(' ')).toContain('golv')
})
it('appends the caveats to the selection so they reach the durable profile', async () => {
const summary = await loadBankingSummary(
buildSupabase([
tx({ description: 'ACME AB faktura', amount: -412000 }),
tx({ description: 'Hetzner Online', amount: -30000, currency: 'EUR' }),
]),
'company-1',
)
mockSelectionResponse()
const selection = await selectAtoms(makeInputs({ bankingSummary: summary }))
expect(selection.uncertainty_notes.join(' ')).toContain('flera valutor')
})
})
@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
employeeFieldValue,
employeeKnownFact,
knowsEmployeeFact,
loadActiveEmployeeCount,
resolveEmployeeFacts,
type EmployeeFacts,
} from '../employee-facts'
import { buildKnownFacts, filterRedundantQuestions } from '../atom-selection'
import type { ComposerInputs } from '../inputs'
/**
* Both call sites used to read `company_settings.employee_count` and
* `company_settings.has_employees`. Neither column exists: `employee_count` is
* on `agi_declarations`, `has_employees` is in no migration at all. PostgREST
* rejects a select whole on 42703, so the agent-onboarding page and the
* composer got `null` for the ENTIRE settings row, and "Anställda" plus every
* other known fact silently vanished.
*
* tests/schema/no-phantom-columns.test.ts proves the column names are now real.
* This file proves the replacement reads the RIGHT ones: that a live headcount
* wins, that an attested employer registration settles the question in both
* directions, and above all that `pays_salaries === false` (a NOT NULL DEFAULT
* false column whose only writer is the tax settings form) is never laundered
* into "this company has no employees".
*/
const NOTHING_KNOWN: EmployeeFacts = {
activeEmployees: null,
ticEmployeeRange: null,
employerRegistered: null,
paysSalaries: null,
}
function makeInputs(overrides: Partial<ComposerInputs> = {}): ComposerInputs {
return {
companyId: 'company-1',
companyName: 'Testbolaget AB',
entityType: 'aktiebolag',
ticSnapshot: null,
ticFetchedAt: null,
companySettings: null,
activeEmployees: null,
sieSummary: null,
bankingSummary: null,
atomIndex: [],
userIsConfirmedDirector: false,
...overrides,
}
}
describe('resolveEmployeeFacts', () => {
it('reports unknown when nothing is actually known', () => {
expect(resolveEmployeeFacts(NOTHING_KNOWN)).toEqual({ kind: 'unknown' })
expect(employeeFieldValue(NOTHING_KNOWN)).toBeNull()
expect(knowsEmployeeFact(NOTHING_KNOWN)).toBe(false)
})
it('prefers a live headcount over every weaker signal', () => {
expect(
resolveEmployeeFacts({
activeEmployees: 3,
ticEmployeeRange: '5-9',
employerRegistered: true,
paysSalaries: true,
})
).toEqual({ kind: 'count', count: 3 })
})
it('falls back to the Bolagsverket interval when no employee rows exist yet', () => {
// Every company arriving at agent onboarding has zero employee rows, so
// zero must fall through rather than answer the question.
expect(
resolveEmployeeFacts({ ...NOTHING_KNOWN, activeEmployees: 0, ticEmployeeRange: '5-9' })
).toEqual({ kind: 'range', range: '5-9' })
expect(
employeeFieldValue({ ...NOTHING_KNOWN, activeEmployees: 0, ticEmployeeRange: '5-9' })
).toBe('5-9')
})
it('treats a zero headcount as no evidence, not as "nej"', () => {
expect(resolveEmployeeFacts({ ...NOTHING_KNOWN, activeEmployees: 0 })).toEqual({
kind: 'unknown',
})
})
it('accepts an attested employer registration in both directions', () => {
expect(employeeFieldValue({ ...NOTHING_KNOWN, employerRegistered: true })).toBe('Ja')
expect(employeeFieldValue({ ...NOTHING_KNOWN, employerRegistered: false })).toBe('Nej')
})
it('reads pays_salaries=true as evidence but pays_salaries=false as none', () => {
// The column is NOT NULL DEFAULT false and no onboarding step sets it, so a
// false is indistinguishable from "never answered". Answering "Nej" from it
// would state a fact nobody entered.
expect(employeeFieldValue({ ...NOTHING_KNOWN, paysSalaries: true })).toBe('Ja')
expect(employeeFieldValue({ ...NOTHING_KNOWN, paysSalaries: false })).toBeNull()
expect(knowsEmployeeFact({ ...NOTHING_KNOWN, paysSalaries: false })).toBe(false)
})
it('lets an attested registration override the pays_salaries default', () => {
expect(
employeeFieldValue({ ...NOTHING_KNOWN, employerRegistered: true, paysSalaries: false })
).toBe('Ja')
})
})
describe('employeeKnownFact (composer KÄNDA FAKTA line)', () => {
it('states a headcount when employee rows back it', () => {
expect(employeeKnownFact({ ...NOTHING_KNOWN, activeEmployees: 2 })).toBe('Anställda: 2')
})
it('does not repeat the TIC interval, which has its own line', () => {
expect(employeeKnownFact({ ...NOTHING_KNOWN, ticEmployeeRange: '5-9' })).toBeNull()
})
it('says nothing when nothing is known', () => {
expect(employeeKnownFact(NOTHING_KNOWN)).toBeNull()
expect(employeeKnownFact({ ...NOTHING_KNOWN, paysSalaries: false })).toBeNull()
})
})
describe('buildKnownFacts', () => {
it('surfaces the real settings columns that the phantom select was discarding', () => {
const facts = buildKnownFacts(
makeInputs({
activeEmployees: 2,
companySettings: {
city: 'Göteborg',
moms_period: 'quarterly',
fiscal_year_start_month: 1,
f_skatt: true,
vat_registered: true,
employer_registered: true,
pays_salaries: true,
accounting_method: 'accrual',
},
})
)
expect(facts).toContain('Anställda: 2')
expect(facts).toContain('Arbetsgivarregistrerad: ja')
expect(facts).toContain('Betalar ut lön: ja')
expect(facts).toContain('Momsperiod: kvartalsvis')
expect(facts).toContain('Säte: Göteborg')
})
it('states a headcount even when the company has no company_settings row', () => {
expect(buildKnownFacts(makeInputs({ activeEmployees: 4 }))).toContain('Anställda: 4')
})
it('claims no employee fact when there is none to claim', () => {
const facts = buildKnownFacts(makeInputs())
expect(facts.some((f) => f.startsWith('Anställda'))).toBe(false)
})
})
describe('filterRedundantQuestions: the employee question', () => {
// fallbackAtomSelection generates exactly this question for an aktiebolag.
const QUESTION = 'Har bolaget anställda förutom dig?'
it('keeps the question when the employee fact is genuinely unknown', () => {
expect(filterRedundantQuestions([QUESTION], makeInputs())).toEqual([QUESTION])
})
it('keeps the question when pays_salaries is merely at its column default', () => {
const inputs = makeInputs({
activeEmployees: 0,
companySettings: {
city: null,
moms_period: null,
fiscal_year_start_month: null,
f_skatt: null,
vat_registered: null,
employer_registered: null,
pays_salaries: false,
accounting_method: null,
},
})
expect(filterRedundantQuestions([QUESTION], inputs)).toEqual([QUESTION])
})
it('drops the question once employee rows exist', () => {
expect(filterRedundantQuestions([QUESTION], makeInputs({ activeEmployees: 1 }))).toEqual([])
})
it('drops the question when the TIC snapshot carries an employee interval', () => {
const inputs = makeInputs({ ticSnapshot: { employeeRange: '1-4' } })
expect(filterRedundantQuestions([QUESTION], inputs)).toEqual([])
})
it('drops the question when employer registration was attested either way', () => {
for (const employer_registered of [true, false]) {
const inputs = makeInputs({
companySettings: {
city: null,
moms_period: null,
fiscal_year_start_month: null,
f_skatt: null,
vat_registered: null,
employer_registered,
pays_salaries: false,
accounting_method: null,
},
})
expect(filterRedundantQuestions([QUESTION], inputs)).toEqual([])
}
})
})
describe('loadActiveEmployeeCount', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function mockClient(result: { count: number | null; error: unknown }) {
const eq = vi.fn()
const chain = {
select: vi.fn(() => chain),
eq,
}
eq.mockReturnValueOnce(chain).mockReturnValueOnce(Promise.resolve(result))
const from = vi.fn(() => chain)
return { client: { from } as unknown as SupabaseClient, from, chain }
}
it('counts only active employees for the company', async () => {
const { client, from, chain } = mockClient({ count: 3, error: null })
await expect(loadActiveEmployeeCount(client, 'company-1')).resolves.toBe(3)
expect(from).toHaveBeenCalledWith('employees')
expect(chain.select).toHaveBeenCalledWith('id', { count: 'exact', head: true })
expect(chain.eq).toHaveBeenNthCalledWith(1, 'company_id', 'company-1')
expect(chain.eq).toHaveBeenNthCalledWith(2, 'is_active', true)
})
it('returns null (not 0) when the count cannot be read', async () => {
const { client } = mockClient({ count: null, error: { message: 'boom' } })
await expect(loadActiveEmployeeCount(client, 'company-1')).resolves.toBeNull()
})
})
@@ -22,6 +22,7 @@ function makeInputs(overrides: Partial<ComposerInputs>): ComposerInputs {
ticSnapshot: null,
ticFetchedAt: null,
companySettings: null,
activeEmployees: null,
sieSummary: null,
bankingSummary: null,
atomIndex: [],
+169 -28
View File
@@ -1,6 +1,7 @@
import { getAnthropic, OPUS_MODEL } from './client'
import { AtomSelectionSchema, ATOM_SELECTION_TOOL_SCHEMA, type AtomSelection } from './schemas'
import type { ComposerInputs } from './inputs'
import { isSekOnlyMagnitude, type ComposerInputs, type CurrencyMagnitude } from './inputs'
import { employeeKnownFact, knowsEmployeeFact } from './employee-facts'
const SYSTEM_PROMPT = `Du komponerar en specialiserad svensk bokföringsassistent åt ett företag.
@@ -16,10 +17,18 @@ Du får:
- KÄNDA FAKTA från företagets inställningar: saker användaren redan har angett (momsperiod, räkenskapsår, F-skatt-status, anställda, bokföringsmetod)
- Eventuell sammanfattning från importerad SIE-fil (topp-konton, topp-motparter, antal år)
- Eventuell sammanfattning från bankhistorik. Varje topp-motpart har:
- belopp i kr (abs)
- belopp med sin valutaenhet utskriven (abs)
- riktning: 'in' (intäkt/inbetalning), 'ut' (kostnad/utbetalning), eller 'in+ut'
- bokföringsstatus: 'OBOKFÖRD' (minst en transaktion ej bokförd) eller 'bokförd'
KRITISKT: ställ INTE en verifieringsfråga om en motpart där riktningen är entydig OCH alla transaktioner är bokförda. T.ex. en motpart märkt "(ut, bokförd)" är redan klassad som kostnad och redan kategoriserad. Att fråga "är detta en intäkt eller kostnad?" är fel. Fokusera frågorna på OBOKFÖRD-motparter där det finns en bokningsbeslutning kvar att fatta.
BELOPP OCH VALUTA (hård regel):
- Ett belopp gäller ALLTID den enhet som står skriven. "412 000 kr" är svenska kronor. "30 000 EUR" är euro, inte kronor.
- Addera ALDRIG belopp i olika valutor till en summa, och räkna aldrig om dem själv: du har ingen växelkurs.
- Står det "motsvarar N kr" är N en lagrad SEK-motsvarighet och får användas som storleksordning.
- Står det "SEK-motsvarighet OKÄND" eller "saknar växelkurs" finns INGEN känd kronsumma för de raderna. Behandla storleken som okänd, gissa aldrig fram ett kr-belopp, och nämn luckan i uncertainty_notes.
- Ett block märkt "Motparter utan känd SEK-motsvarighet" listar motparter vars storlek vi inte kan uttrycka i kronor. De är inte små: de är omätta.
- Om "Snittvolym per månad" saknas eller är märkt som ett golv: dra inga slutsatser om omsättningsstorlek av den.
- Ett register över tillgängliga atomer (horizontal/vertical/modifier) med beskrivning, SNI-prefix och utlösare
Din uppgift:
@@ -43,7 +52,7 @@ Din uppgift:
Fokusera istället på frågor vars svar du inte kan se: specifika balansposter (t.ex. "Vad gäller ALMI-beloppet, lån eller bidrag?"), arbetssätt (faktureringscadens, kund-geografi), planerade förändringar (kommande löneutbetalning, expansion, fastighetsförvärv).
6. Skriv 1-3 svenska uncertainty_notes till utvecklaren som granskar valet senare. Inkludera explicit notering om statuses[] visar isCeased eller red-status.
6. Skriv 1-3 svenska uncertainty_notes till utvecklaren som granskar valet senare. Inkludera explicit notering om statuses[] visar isCeased eller red-status, och om något belopp saknar känd SEK-motsvarighet.
Stil i all text du skriver (verifieringsfrågor och notes): använd ALDRIG tankstreck (— eller –). Använd kommatecken, punkt, eller "till" för intervall ("2,5 till 5 miljoner"). Hård regel.
@@ -110,9 +119,101 @@ export async function selectAtoms(inputs: ComposerInputs): Promise<AtomSelection
parsed.data.modifier_atoms,
)
// Currency caveats are appended deterministically, not left to the model.
// This selection becomes the company's standing agent profile, so a
// magnitude the underlying data cannot support has to be flagged even when
// the model failed to notice it: an explicit "unknown" is always better
// than a confident wrong number baked into durable instructions.
parsed.data.uncertainty_notes = [
...parsed.data.uncertainty_notes,
...buildCurrencyUncertaintyNotes(inputs),
]
return parsed.data
}
// Deterministic notes about what the amount signals cannot tell us. Exported
// so the stream endpoint can apply the same caveats to fallback selections,
// which never see the model at all.
export function buildCurrencyUncertaintyNotes(inputs: ComposerInputs): string[] {
const notes: string[] = []
const bank = inputs.bankingSummary
if (bank) {
const foreign = bank.currencies.filter((c) => c !== 'SEK')
if (foreign.length > 0) {
notes.push(
`Banksammanfattningen spänner över flera valutor (${bank.currencies.join(', ')}). Beloppen är inte en enda kr-summa: kontrollera valutan innan en storleksordning används.`,
)
}
if (bank.volume_rows_without_sek > 0) {
const total = bank.volume_rows_with_sek + bank.volume_rows_without_sek
notes.push(
bank.monthly_volume == null
? `Ingen månadsvolym i kr kan anges: samtliga ${total} banktransaktioner saknar lagrad SEK-motsvarighet.`
: `${bank.volume_rows_without_sek} av ${total} banktransaktioner saknar lagrad SEK-motsvarighet och ingår inte i snittvolymen. Volymen i kr är ett golv, inte ett facit.`,
)
}
if (bank.unconvertible_counterparties.length > 0) {
notes.push(
`${bank.unconvertible_counterparties.length} motpart(er) kunde inte rankas i kr eftersom växelkurs saknas: ${bank.unconvertible_counterparties.map((c) => c.name).join(', ')}.`,
)
}
}
const sie = inputs.sieSummary
if (sie) {
const unknownRows = [...sie.top_counterparties, ...sie.unconvertible_counterparties].reduce(
(n, c) => n + c.rows_without_sek,
0,
)
if (unknownRows > 0) {
notes.push(
`SIE-motpartsbeloppen innehåller ${unknownRows} rad(er) i utländsk valuta utan känd SEK-motsvarighet.`,
)
}
}
return notes
}
// Renders a magnitude so the number always carries its real unit.
//
// Plain SEK data renders exactly as before ("412 000 kr"). Anything else keeps
// each currency in its own unit and states, in words, how much of it has a
// known SEK equivalent. There is deliberately no path that turns a mixed or
// rate-less magnitude into a single "kr" figure.
export function formatMagnitude(m: CurrencyMagnitude): string {
if (isSekOnlyMagnitude(m)) {
return `${formatAmount(m.abs_amount)} kr`
}
const native = m.by_currency
.map((c) => `${formatAmount(c.abs_amount)} ${c.currency}`)
.join(' + ')
if (m.rows_without_sek === 0) {
return `${native} (motsvarar ${formatAmount(m.abs_amount)} kr)`
}
const missing = `${m.rows_without_sek} ${m.rows_without_sek === 1 ? 'transaktion' : 'transaktioner'} saknar växelkurs`
if (m.abs_amount === 0) {
return `${native} (SEK-motsvarighet OKÄND: ${missing}, ingen kr-summa kan anges)`
}
return `${native} (varav ${formatAmount(m.abs_amount)} kr har känd SEK-motsvarighet, ${missing} och ingår inte)`
}
function formatAmount(value: number): string {
return Math.round(value).toLocaleString('sv-SE')
}
function counterpartyLabels(c: {
direction: 'in' | 'out' | 'mixed'
has_unbooked: boolean
}): string {
const dirLabel = c.direction === 'in' ? 'in' : c.direction === 'out' ? 'ut' : 'in+ut'
return `${dirLabel}, ${c.has_unbooked ? 'OBOKFÖRD' : 'bokförd'}`
}
// Drops questions whose answer is already settled in company_settings or
// TIC snapshot. Each question is matched against keyword patterns:
// keep this conservative so we never accidentally drop a legitimate
@@ -139,7 +240,12 @@ export function filterRedundantQuestions(
| null
const knowsMomsPeriod = !!s?.moms_period
const knowsEmployees = s?.has_employees != null || s?.employee_count != null || !!tic?.employeeRange
const knowsEmployees = knowsEmployeeFact({
activeEmployees: inputs.activeEmployees,
ticEmployeeRange: tic?.employeeRange ?? null,
employerRegistered: s?.employer_registered ?? null,
paysSalaries: s?.pays_salaries ?? null,
})
const knowsFiscalYear = s?.fiscal_year_start_month != null
const knowsFSkatt = s?.f_skatt != null || tic?.registration?.fTax != null
const knowsVatRegistered = s?.vat_registered != null || tic?.registration?.vat != null
@@ -241,7 +347,9 @@ function buildUserPrompt(inputs: ComposerInputs): string {
lines.push(`# SIE-sammanfattning`)
lines.push(`Antal år: ${inputs.sieSummary.year_count}`)
if (inputs.sieSummary.top_accounts.length > 0) {
lines.push('Topp-konton (abs-belopp):')
// Ledger balances: journal_entry_lines are booked in SEK by definition,
// so a bare kr figure is correct here.
lines.push('Topp-konton (abs-belopp, bokförda i SEK):')
for (const a of inputs.sieSummary.top_accounts.slice(0, 20)) {
lines.push(` ${a.account.padEnd(8)} ${Math.round(a.abs_amount).toLocaleString('sv-SE')} kr`)
}
@@ -249,35 +357,64 @@ function buildUserPrompt(inputs: ComposerInputs): string {
if (inputs.sieSummary.top_counterparties.length > 0) {
lines.push('Topp-motparter (från transaktionsbeskrivningar):')
for (const c of inputs.sieSummary.top_counterparties.slice(0, 10)) {
lines.push(` ${c.name}: ${Math.round(c.abs_amount).toLocaleString('sv-SE')} kr`)
lines.push(` ${c.name}: ${formatMagnitude(c)}`)
}
}
if (inputs.sieSummary.unconvertible_counterparties.length > 0) {
lines.push('Motparter utan känd SEK-motsvarighet (växelkurs saknas, därför inte rankade ovan):')
for (const c of inputs.sieSummary.unconvertible_counterparties) {
lines.push(` ${c.name}: ${formatMagnitude(c)}`)
}
}
lines.push('')
}
if (inputs.bankingSummary) {
const bank = inputs.bankingSummary
lines.push(`# Banktransaktioner (12 mån)`)
if (inputs.bankingSummary.monthly_volume != null) {
const volumeTotal = bank.volume_rows_with_sek + bank.volume_rows_without_sek
if (bank.monthly_volume != null) {
// Only the rows with a known SEK value are in this figure. When some
// rows are missing a rate the number is a floor, and saying so beats
// printing a confident total the data cannot back.
const caveat =
bank.volume_rows_without_sek > 0
? ` (GOLV: ${bank.volume_rows_without_sek} av ${volumeTotal} transaktioner saknar växelkurs och ingår inte, den verkliga volymen är högre)`
: ''
lines.push(
`Snittvolym per månad: ${Math.round(inputs.bankingSummary.monthly_volume).toLocaleString('sv-SE')} kr`,
`Snittvolym per månad: ${Math.round(bank.monthly_volume).toLocaleString('sv-SE')} kr${caveat}`,
)
} else if (bank.volume_rows_without_sek > 0) {
lines.push(
`Snittvolym per månad: OKÄND. Samtliga ${volumeTotal} transaktioner är i utländsk valuta utan lagrad växelkurs, så ingen kr-volym kan beräknas.`,
)
}
lines.push(
`Antal obokförda transaktioner: ${inputs.bankingSummary.unbooked_count}`,
)
if (inputs.bankingSummary.top_counterparties.length > 0) {
lines.push(`Antal obokförda transaktioner: ${bank.unbooked_count}`)
if (bank.currencies.length > 1) {
lines.push(
`Valutor i underlaget: ${bank.currencies.join(', ')}. Beloppen nedan står i sin egen valuta, addera dem inte.`,
)
}
if (bank.top_counterparties.length > 0) {
lines.push('Topp-motparter (riktning + bokföringsstatus):')
for (const c of inputs.bankingSummary.top_counterparties.slice(0, 20)) {
for (const c of bank.top_counterparties.slice(0, 20)) {
// direction tells Opus whether this counterparty is a source of
// income, a cost, or both. has_unbooked says whether there's still
// a transaction waiting for the user to book: only those are
// legitimate verification-question fodder.
const dirLabel =
c.direction === 'in' ? 'in' : c.direction === 'out' ? 'ut' : 'in+ut'
const bookedLabel = c.has_unbooked ? 'OBOKFÖRD' : 'bokförd'
lines.push(
` ${c.name}: ${Math.round(c.abs_amount).toLocaleString('sv-SE')} kr (${dirLabel}, ${bookedLabel})`,
)
lines.push(` ${c.name}: ${formatMagnitude(c)} (${counterpartyLabels(c)})`)
}
}
if (bank.unconvertible_counterparties.length > 0) {
// These rank as 0 kr and would otherwise disappear at the slice above.
// They are unmeasured, not small: listing them separately keeps that
// distinction visible to the model.
lines.push(
'Motparter utan känd SEK-motsvarighet (växelkurs saknas, därför inte rankade ovan, storleken är omätt och inte liten):',
)
for (const c of bank.unconvertible_counterparties) {
lines.push(` ${c.name}: ${formatMagnitude(c)} (${counterpartyLabels(c)})`)
}
}
lines.push('')
@@ -319,7 +456,7 @@ function buildUserPrompt(inputs: ComposerInputs): string {
// Surfaces user-settled facts in a tight bullet list the composer can scan
// before generating questions. Anything in here is OFF-LIMITS for the
// verification_questions list: the user already said it.
function buildKnownFacts(inputs: ComposerInputs): string[] {
export function buildKnownFacts(inputs: ComposerInputs): string[] {
const out: string[] = []
const s = inputs.companySettings
if (s) {
@@ -343,17 +480,12 @@ function buildKnownFacts(inputs: ComposerInputs): string[] {
if (s.vat_registered != null) {
out.push(`Momsregistrerad: ${s.vat_registered ? 'ja' : 'nej'}`)
}
if (s.has_employees != null || s.employee_count != null) {
const ec = s.employee_count
if (typeof ec === 'number') {
out.push(`Anställda: ${ec}`)
} else if (s.has_employees != null) {
out.push(`Anställda: ${s.has_employees ? 'ja' : 'nej'}`)
}
}
if (s.pays_salaries != null) {
out.push(`Betalar ut lön: ${s.pays_salaries ? 'ja' : 'nej'}`)
}
if (s.employer_registered != null) {
out.push(`Arbetsgivarregistrerad: ${s.employer_registered ? 'ja' : 'nej'}`)
}
if (s.accounting_method) {
out.push(`Bokföringsmetod: ${s.accounting_method}`)
}
@@ -361,6 +493,15 @@ function buildKnownFacts(inputs: ComposerInputs): string[] {
out.push(`Säte: ${s.city}`)
}
}
// Outside the settings block on purpose: a live employee count is a fact even
// for a company with no company_settings row yet.
const employeeFact = employeeKnownFact({
activeEmployees: inputs.activeEmployees,
ticEmployeeRange: null,
employerRegistered: s?.employer_registered ?? null,
paysSalaries: s?.pays_salaries ?? null,
})
if (employeeFact) out.push(employeeFact)
const tic = inputs.ticSnapshot as
| {
registration?: { fTax?: boolean; vat?: boolean; payroll?: boolean }
+144
View File
@@ -0,0 +1,144 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* "Har bolaget anställda?" resolved from columns that actually exist.
*
* WHY THIS MODULE EXISTS
* ----------------------
* The agent-onboarding review card and the composer both need this fact, and
* neither could get it: both selected `company_settings.employee_count` and
* `company_settings.has_employees`, and neither column has ever existed.
* `employee_count` lives on `agi_declarations` (20260414120000_salary_module);
* `has_employees` appears in no migration at all. PostgREST answers 42703 and
* rejects the select WHOLE, so those two names were also throwing away the real
* columns selected beside them (city, moms_period, fiscal_year_start_month,
* f_skatt, vat_registered, pays_salaries, accounting_method, is_sandbox).
*
* WHAT IS ACTUALLY KNOWABLE, strongest evidence first:
*
* 1. `employees` rows that are active. A row exists because a person with a
* personnummer was entered, so a POSITIVE count is a hard fact. A zero
* count is not the opposite fact: a company that has not opened the payroll
* module yet also has zero rows, which is every company arriving at agent
* onboarding. Zero is therefore never read as "nej".
* 2. `companies.tic_snapshot.employeeRange`: Bolagsverket's registered employee
* interval ("5-9"). Authoritative but coarse, and only as fresh as the last
* TIC fetch.
* 3. `company_settings.employer_registered` (nullable, 20260717151000): whether
* the company is registered as an employer with Skatteverket. The column is
* nullable precisely so that "nobody ever attested this" is representable
* (DECISIONS.md 2026-07-17), so a non-null value settles the question in
* BOTH directions.
* 4. `company_settings.pays_salaries` (NOT NULL DEFAULT false, 20260401000000):
* the "Betalar löner" toggle on /settings/tax. `true` is evidence. `false`
* is NOT: the column defaults to false, no onboarding step asks, and its
* only writer is that settings form. Reading the default as "nej" would
* state a fact nobody entered, and would drop the composer's verification
* question for every company that never opened tax settings.
*
* Being a registered employer and having employees are not the same predicate:
* a registered employer still files nil AGI months with nobody on payroll. This
* module therefore only ever reports a NUMBER when actual employee rows back it,
* and otherwise reports the employer fact as a plain yes/no.
*/
export interface EmployeeFacts {
/** Employees currently flagged active. null when the count could not be read. */
activeEmployees: number | null
/** Bolagsverket's registered employee interval, from the TIC snapshot. */
ticEmployeeRange: string | null
/** Attested Skatteverket employer registration. null = never attested. */
employerRegistered: boolean | null
/** The "Betalar löner" toggle. `false` is a column default, not an answer. */
paysSalaries: boolean | null
}
export type EmployeeVerdict =
| { kind: 'count'; count: number }
| { kind: 'range'; range: string }
| { kind: 'employer'; isEmployer: boolean }
| { kind: 'unknown' }
/**
* Count employees flagged active for a company. Mirrors how the rest of the app
* defines "active employee" (lib/salary/create-run.ts, lib/reports/vacation-
* liability.ts, the MCP server's headcount tools): `is_active`, with no separate
* `employment_end` test, because deactivation sets `is_active = false`. Served
* by the partial index `idx_employees_active (company_id, is_active)`.
*
* Returns null (not 0) on error, so "we could not read this" never collapses
* into "this company has nobody".
*/
export async function loadActiveEmployeeCount(
supabase: SupabaseClient,
companyId: string,
): Promise<number | null> {
const { count, error } = await supabase
.from('employees')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('is_active', true)
if (error) return null
return count ?? null
}
/** Pick the strongest fact available, or report that there is none. */
export function resolveEmployeeFacts(facts: EmployeeFacts): EmployeeVerdict {
// A live headcount beats Bolagsverket's interval: it is the figure this app
// can act on (it is what a salary run enumerates) and it is current by
// construction, where the snapshot is only as fresh as the last TIC fetch.
if (facts.activeEmployees != null && facts.activeEmployees > 0) {
return { kind: 'count', count: facts.activeEmployees }
}
const range = facts.ticEmployeeRange?.trim()
if (range) return { kind: 'range', range }
if (facts.employerRegistered === true) return { kind: 'employer', isEmployer: true }
if (facts.paysSalaries === true) return { kind: 'employer', isEmployer: true }
// Only an ATTESTED negative is a negative. pays_salaries === false is the
// column default and says nothing.
if (facts.employerRegistered === false) return { kind: 'employer', isEmployer: false }
return { kind: 'unknown' }
}
/**
* True when we hold a fact that settles "har bolaget anställda", i.e. when the
* composer must not generate a verification question about it.
*/
export function knowsEmployeeFact(facts: EmployeeFacts): boolean {
return resolveEmployeeFacts(facts).kind !== 'unknown'
}
/**
* Value for the onboarding review card's "Anställda" row. null renders as the
* field's placeholder rather than inventing a figure.
*/
export function employeeFieldValue(facts: EmployeeFacts): string | null {
const verdict = resolveEmployeeFacts(facts)
switch (verdict.kind) {
case 'count':
return String(verdict.count)
case 'range':
return verdict.range
case 'employer':
return verdict.isEmployer ? 'Ja' : 'Nej'
default:
return null
}
}
/**
* Composer KÄNDA FAKTA line, or null when nothing is known. Resolved WITHOUT
* the TIC range: that already gets its own labelled line in the same block, and
* stating the same interval twice invites the model to treat it as two facts.
*/
export function employeeKnownFact(facts: EmployeeFacts): string | null {
const verdict = resolveEmployeeFacts({ ...facts, ticEmployeeRange: null })
switch (verdict.kind) {
case 'count':
return `Anställda: ${verdict.count}`
case 'employer':
return `Anställda: ${verdict.isEmployer ? 'ja' : 'nej'}`
default:
return null
}
}
+259 -48
View File
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { SourceSignals } from './schemas'
import { loadActiveEmployeeCount } from './employee-facts'
// Atom registry index row: the metadata-only shape the composer sees when
// picking a loadout. We never send full atom bodies to the Opus call;
@@ -46,18 +47,25 @@ export async function loadCompanyTicSnapshot(
}
}
// Known facts from `company_settings`: the onboarding form persists these
// (moms_period, fiscal_year_start_month, f_skatt, employees, city). Composer
// uses them as KNOWN inputs so it stops generating verification questions
// about already-settled values.
// Known facts from `company_settings`: the settings forms persist these
// (moms_period, fiscal_year_start_month, f_skatt, employer facts, city).
// Composer uses them as KNOWN inputs so it stops generating verification
// questions about already-settled values.
//
// Employees are NOT in here as a headcount: this select used to name
// `employee_count` and `has_employees`, neither of which exists on
// company_settings, which made PostgREST reject the whole select (42703) and
// left every field below null too. The employer facts that do exist are
// `employer_registered` (nullable: null = never attested) and `pays_salaries`;
// lib/agent/composer/employee-facts.ts turns them plus the live `employees`
// count into the derived answer.
export interface CompanySettingsForComposer {
city: string | null
moms_period: string | null
fiscal_year_start_month: number | null
f_skatt: boolean | null
vat_registered: boolean | null
employee_count: number | null
has_employees: boolean | null
employer_registered: boolean | null
pays_salaries: boolean | null
accounting_method: string | null
}
@@ -69,7 +77,7 @@ export async function loadCompanySettings(
const { data } = await supabase
.from('company_settings')
.select(
'city, moms_period, fiscal_year_start_month, f_skatt, vat_registered, employee_count, has_employees, pays_salaries, accounting_method',
'city, moms_period, fiscal_year_start_month, f_skatt, vat_registered, employer_registered, pays_salaries, accounting_method',
)
.eq('company_id', companyId)
.maybeSingle()
@@ -144,9 +152,162 @@ export async function loadUserDirectorship(
return { confirmedDirector: isDirector }
}
// ---------------------------------------------------------------------------
// Currency-aware magnitudes
// ---------------------------------------------------------------------------
//
// `transactions.amount` is denominated in `transactions.currency`, not in SEK.
// The SEK value of a foreign row lives in `amount_sek` (written at ingest) or
// is derivable from `exchange_rate`; a foreign row carrying neither has no
// known SEK value at all. Adding raw `amount` across rows and labelling the
// total "kr" therefore invents a magnitude out of thin air.
//
// That matters more here than in an ordinary report: these summaries are the
// input to the model that writes the company's standing agent instructions, so
// a wrong magnitude is baked into durable instructions rather than misleading
// one answer. Every rollup below therefore carries three things instead of one
// number: the SEK total of the rows whose SEK value is actually known, the
// native total per currency (covering every row), and the count of rows
// deliberately left out of the SEK total.
export interface CurrencyMagnitude {
// SEK total of the rows with a known SEK value. 0 when no row has one, so
// always read `rows_without_sek` before treating this as the magnitude.
//
// Named `abs_amount` (not `abs_amount_sek`) because `SourceSignals` persists
// this shape in `agent_profiles.source_signals`; renaming would silently
// change what older snapshots mean.
abs_amount: number
// Native totals per currency, covering EVERY row including the ones with no
// SEK equivalent. SEK first, then descending. Amounts in different
// currencies are never added together.
by_currency: { currency: string; abs_amount: number }[]
// Rows with a foreign amount and neither `amount_sek` nor a usable
// `exchange_rate`. These are NOT part of `abs_amount`.
rows_without_sek: number
}
interface AmountRow {
amount: number | string | null
currency?: string | null
amount_sek?: number | string | null
exchange_rate?: number | string | null
}
function toFiniteNumber(value: unknown): number | null {
if (value == null || value === '') return null
const n = Number(value)
return Number.isFinite(n) ? n : null
}
function rowCurrency(row: AmountRow): string {
const raw = typeof row.currency === 'string' ? row.currency.trim().toUpperCase() : ''
return raw.length > 0 ? raw : 'SEK'
}
// SEK value of one row, or null when there genuinely is none. Mirrors the
// resolution order used when booking (lib/bookkeeping/currency-utils.ts) but
// deliberately drops its final fallback: that one returns the raw foreign
// amount when no rate is stored, which for a summary reads as "500 EUR is
// 500 kr". For this input a missing rate must read as unknown.
function rowSekAmount(row: AmountRow): number | null {
const amount = toFiniteNumber(row.amount)
if (amount == null) return null
if (rowCurrency(row) === 'SEK') return Math.abs(amount)
const stored = toFiniteNumber(row.amount_sek)
if (stored != null) return Math.abs(stored)
const rate = toFiniteNumber(row.exchange_rate)
if (rate == null || rate <= 0) return null
return Math.abs(amount * rate)
}
interface MagnitudeBucket {
sek: number
native: Map<string, number>
rowsWithoutSek: number
}
function newMagnitudeBucket(): MagnitudeBucket {
return { sek: 0, native: new Map(), rowsWithoutSek: 0 }
}
// Folds one row into a bucket and returns its SEK value (null when unknown).
// Rows with no usable `amount` at all are skipped rather than counted as a
// currency gap: they are missing data, not a missing exchange rate.
function addRowToMagnitude(bucket: MagnitudeBucket, row: AmountRow): number | null {
const amount = toFiniteNumber(row.amount)
if (amount == null) return null
const currency = rowCurrency(row)
bucket.native.set(currency, (bucket.native.get(currency) ?? 0) + Math.abs(amount))
const sek = rowSekAmount(row)
if (sek == null) {
bucket.rowsWithoutSek++
} else {
bucket.sek += sek
}
return sek
}
function finalizeMagnitude(bucket: MagnitudeBucket): CurrencyMagnitude {
const by_currency = Array.from(bucket.native.entries())
.map(([currency, abs_amount]) => ({
currency,
abs_amount: Math.round(abs_amount * 100) / 100,
}))
.sort((a, b) => {
if (a.currency === b.currency) return 0
if (a.currency === 'SEK') return -1
if (b.currency === 'SEK') return 1
return b.abs_amount - a.abs_amount
})
return {
abs_amount: Math.round(bucket.sek * 100) / 100,
by_currency,
rows_without_sek: bucket.rowsWithoutSek,
}
}
// True when the whole magnitude is plain SEK with nothing unresolved, i.e. the
// only case where it may be rendered as a single "kr" figure.
export function isSekOnlyMagnitude(m: CurrencyMagnitude): boolean {
return m.rows_without_sek === 0 && m.by_currency.every((c) => c.currency === 'SEK')
}
// Ranking key for magnitudes with no usable SEK total: the largest single
// currency total. Ordering only, never rendered, so it never implies that
// amounts in different currencies are comparable money.
function largestNativeTotal(m: CurrencyMagnitude): number {
return m.by_currency.reduce((max, c) => (c.abs_amount > max ? c.abs_amount : max), 0)
}
// Splits a counterparty rollup into a SEK-ranked top list plus the ones whose
// magnitude cannot be expressed in SEK and would otherwise vanish at the
// slice (they rank as 0 kr). The second list is what keeps "no stored exchange
// rate" visible instead of silently excluded.
function rankCounterparties<T extends CurrencyMagnitude & { name: string }>(
rows: T[],
topLimit: number,
unconvertibleLimit: number,
): { top: T[]; unconvertible: T[] } {
const ranked = [...rows].sort((a, b) => b.abs_amount - a.abs_amount)
const top = ranked.slice(0, topLimit)
const inTop = new Set(top.map((r) => r.name))
const unconvertible = ranked
.filter((r) => r.rows_without_sek > 0 && !inTop.has(r.name))
.sort((a, b) => largestNativeTotal(b) - largestNativeTotal(a))
.slice(0, unconvertibleLimit)
return { top, unconvertible }
}
interface CounterpartyMagnitude extends CurrencyMagnitude {
name: string
}
interface SieSummary {
top_accounts: { account: string; abs_amount: number }[]
top_counterparties: { name: string; abs_amount: number }[]
top_counterparties: CounterpartyMagnitude[]
// Counterparties left out of the SEK ranking because no row carries a SEK
// equivalent. Rendered in their own labelled block so they stay visible.
unconvertible_counterparties: CounterpartyMagnitude[]
year_count: number
}
@@ -181,7 +342,9 @@ export async function loadSieSummary(
// Top-20 account magnitudes across journal_entry_lines for the company.
// Cheaper than aggregating SIE line-items directly because the lines have
// already landed in journal_entry_lines after import.
// already landed in journal_entry_lines after import. These are genuinely
// SEK: journal_entry_lines.debit/credit are the booked SEK amounts, with any
// foreign original kept separately in currency/amount_in_currency.
const { data: lines, error: linesErr } = await supabase.rpc('agent_top_accounts_for_company', {
p_company_id: companyId,
p_limit: 20,
@@ -201,36 +364,42 @@ export async function loadSieSummary(
// `transactions.description` is the raw text from the bank (not
// normalized) so this is a noisy signal. The composer treats it as a hint
// alongside TIC sniCodes, which carry the strong industry signal.
//
// currency/amount_sek/exchange_rate come along because `amount` alone does
// not say what unit it is in: see the CurrencyMagnitude note above.
const { data: tx } = await supabase
.from('transactions')
.select('description, amount')
.select('description, amount, currency, amount_sek, exchange_rate')
.eq('company_id', companyId)
.limit(2000)
const cpAgg = new Map<string, number>()
const cpAgg = new Map<string, MagnitudeBucket>()
if (Array.isArray(tx)) {
for (const t of tx as { description: string | null; amount: number | string | null }[]) {
for (const t of tx as (AmountRow & { description: string | null })[]) {
const name = normalizeCounterparty(t.description)
if (!name) continue
const amt = Math.abs(Number(t.amount) || 0)
cpAgg.set(name, (cpAgg.get(name) ?? 0) + amt)
let bucket = cpAgg.get(name)
if (!bucket) {
bucket = newMagnitudeBucket()
cpAgg.set(name, bucket)
}
addRowToMagnitude(bucket, t)
}
}
const topCounterparties = Array.from(cpAgg.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name, abs_amount]) => ({ name, abs_amount }))
const allCounterparties: CounterpartyMagnitude[] = Array.from(cpAgg.entries()).map(
([name, bucket]) => ({ name, ...finalizeMagnitude(bucket) }),
)
const { top, unconvertible } = rankCounterparties(allCounterparties, 10, 5)
return {
top_accounts: topAccounts,
top_counterparties: topCounterparties,
top_counterparties: top,
unconvertible_counterparties: unconvertible,
year_count: years.size,
}
}
interface BankingCounterparty {
name: string
abs_amount: number
interface BankingCounterparty extends CounterpartyMagnitude {
// 'in' → money coming in (income / refund / loan disbursement)
// 'out' → money going out (cost / supplier payment / repayment)
// 'mixed' → both directions present (rare: typically transfers or
@@ -246,7 +415,20 @@ interface BankingCounterparty {
interface BankingSummary {
top_counterparties: BankingCounterparty[]
// Counterparties whose amounts include rows with no stored SEK equivalent
// and that did not survive the SEK ranking. Kept so "we do not know what
// this is worth in kronor" stays visible instead of silently excluded.
unconvertible_counterparties: BankingCounterparty[]
// Average monthly SEK volume over the window, counting ONLY rows whose SEK
// value is known. null when no row has one: there is then no honest kr
// figure to state at all.
monthly_volume: number | null
// Coverage behind `monthly_volume`. When rows_without_sek > 0 the figure is
// a floor, not the real volume, and the renderer has to say so.
volume_rows_with_sek: number
volume_rows_without_sek: number
// Every currency seen in the window, e.g. ['EUR', 'SEK'].
currencies: string[]
unbooked_count: number
}
@@ -263,9 +445,12 @@ export async function loadBankingSummary(
supabase: SupabaseClient,
companyId: string,
): Promise<BankingSummary | null> {
// currency/amount_sek/exchange_rate are part of the magnitude, not optional
// decoration: without them `amount` is a bare number with no unit, and the
// rollup below would sum EUR into a total labelled "kr".
const { data, error } = await supabase
.from('transactions')
.select('description, amount, date, journal_entry_id')
.select('description, amount, currency, amount_sek, exchange_rate, date, journal_entry_id')
.eq('company_id', companyId)
.gte('date', oneYearAgo())
.order('date', { ascending: false })
@@ -273,51 +458,70 @@ export async function loadBankingSummary(
if (error || !Array.isArray(data) || data.length === 0) return null
interface Bucket {
absAmount: number
magnitude: MagnitudeBucket
hasInflow: boolean
hasOutflow: boolean
hasUnbooked: boolean
}
const cpAgg = new Map<string, Bucket>()
let totalVolume = 0
const currencies = new Set<string>()
let sekVolume = 0
let volumeRowsWithSek = 0
let volumeRowsWithoutSek = 0
let unbookedCount = 0
for (const t of data as {
for (const t of data as (AmountRow & {
description: string | null
amount: number | string | null
journal_entry_id: string | null
}[]) {
const signedAmt = Number(t.amount) || 0
const absAmt = Math.abs(signedAmt)
totalVolume += absAmt
})[]) {
if (!t.journal_entry_id) unbookedCount++
const signedAmt = toFiniteNumber(t.amount)
if (signedAmt != null) {
currencies.add(rowCurrency(t))
const sek = rowSekAmount(t)
if (sek == null) {
volumeRowsWithoutSek++
} else {
sekVolume += sek
volumeRowsWithSek++
}
}
const name = normalizeCounterparty(t.description)
if (!name) continue
const prev = cpAgg.get(name) ?? {
absAmount: 0,
hasInflow: false,
hasOutflow: false,
hasUnbooked: false,
let bucket = cpAgg.get(name)
if (!bucket) {
bucket = {
magnitude: newMagnitudeBucket(),
hasInflow: false,
hasOutflow: false,
hasUnbooked: false,
}
cpAgg.set(name, bucket)
}
prev.absAmount += absAmt
if (signedAmt > 0) prev.hasInflow = true
if (signedAmt < 0) prev.hasOutflow = true
if (!t.journal_entry_id) prev.hasUnbooked = true
cpAgg.set(name, prev)
addRowToMagnitude(bucket.magnitude, t)
// Direction reads off the sign, which is currency-independent.
if (signedAmt != null && signedAmt > 0) bucket.hasInflow = true
if (signedAmt != null && signedAmt < 0) bucket.hasOutflow = true
if (!t.journal_entry_id) bucket.hasUnbooked = true
}
const top: BankingCounterparty[] = Array.from(cpAgg.entries())
.sort((a, b) => b[1].absAmount - a[1].absAmount)
.slice(0, 20)
.map(([name, b]) => ({
const allCounterparties: BankingCounterparty[] = Array.from(cpAgg.entries()).map(
([name, b]) => ({
name,
abs_amount: b.absAmount,
...finalizeMagnitude(b.magnitude),
direction: b.hasInflow && b.hasOutflow ? 'mixed' : b.hasInflow ? 'in' : 'out',
has_unbooked: b.hasUnbooked,
}))
}),
)
const { top, unconvertible } = rankCounterparties(allCounterparties, 20, 10)
return {
top_counterparties: top,
monthly_volume: totalVolume > 0 ? Math.round(totalVolume / 12) : null,
unconvertible_counterparties: unconvertible,
monthly_volume: sekVolume > 0 ? Math.round(sekVolume / 12) : null,
volume_rows_with_sek: volumeRowsWithSek,
volume_rows_without_sek: volumeRowsWithoutSek,
currencies: Array.from(currencies).sort(),
unbooked_count: unbookedCount,
}
}
@@ -350,6 +554,10 @@ export interface ComposerInputs {
ticSnapshot: Record<string, unknown> | null
ticFetchedAt: string | null
companySettings: CompanySettingsForComposer | null
// Employees currently flagged active in the payroll module. null when the
// count could not be read, which must not be read as "nobody": see
// lib/agent/composer/employee-facts.ts.
activeEmployees: number | null
sieSummary: SieSummary | null
bankingSummary: BankingSummary | null
atomIndex: AtomRegistryIndexRow[]
@@ -371,6 +579,7 @@ export async function gatherComposerInputs(
sieSummary,
bankingSummary,
companySettings,
activeEmployees,
directorship,
] = await Promise.all([
loadCompanyTicSnapshot(supabase, companyId),
@@ -378,6 +587,7 @@ export async function gatherComposerInputs(
loadSieSummary(supabase, companyId).catch(() => null),
loadBankingSummary(supabase, companyId).catch(() => null),
loadCompanySettings(supabase, companyId).catch(() => null),
loadActiveEmployeeCount(supabase, companyId).catch(() => null),
loadUserDirectorship(supabase, companyId).catch(() => ({ confirmedDirector: false })),
])
@@ -388,6 +598,7 @@ export async function gatherComposerInputs(
ticSnapshot: snapshot,
ticFetchedAt: fetchedAt,
companySettings,
activeEmployees,
sieSummary,
bankingSummary,
atomIndex,
@@ -0,0 +1,199 @@
import { describe, it, expect } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { bokslutStep } from '../bokslut-step'
// fiscal_periods has no `status` column: state is is_closed + locked_at
// (see lib/core/bookkeeping/period-service.ts and the period-lock triggers).
// Selecting `status` made PostgREST reject the whole query, so the bokslut
// intent never resolved a period and silently dropped the räkenskapsår from
// the prompt. The mocked Supabase harness happily returns whatever rows the
// test enqueues regardless of the column list, so these tests assert the
// SELECT string itself, plus the open/locked/closed/unknown prompt branches.
interface QueryResult {
data: unknown
error: unknown
}
interface RecordedSelect {
table: string
columns: string
}
function createRecordingSupabase(results: Record<string, QueryResult>) {
const selects: RecordedSelect[] = []
const buildChain = (table: string) => {
const chain = {
select: (columns: string) => {
selects.push({ table, columns })
return chain
},
eq: () => chain,
order: () => chain,
limit: () => chain,
maybeSingle: async (): Promise<QueryResult> =>
results[table] ?? { data: null, error: null },
}
return chain
}
const supabase = {
from: (table: string) => buildChain(table),
} as unknown as SupabaseClient
return { supabase, selects }
}
const COMPANY_ID = '22222222-2222-2222-2222-222222222222'
const PERIOD_ID = '33333333-3333-3333-3333-333333333333'
function periodRow(overrides: Record<string, unknown> = {}) {
return {
id: PERIOD_ID,
period_start: '2025-01-01',
period_end: '2025-12-31',
is_closed: false,
locked_at: null,
...overrides,
}
}
async function capture(results: Record<string, QueryResult>) {
const { supabase, selects } = createRecordingSupabase(results)
const captured = await bokslutStep.capture(
{ step_id: 'accruals', fiscal_year_end: null },
{ supabase, userId: 'user-1', companyId: COMPANY_ID },
)
return { captured, selects }
}
function render(captured: Awaited<ReturnType<typeof bokslutStep.capture>>) {
return bokslutStep.promptTemplate({ captured, profileSummary: null, activeMemory: [] })
}
describe('bokslut.step capture', () => {
it('selects the real fiscal_periods state columns, never a `status` column', async () => {
const { selects } = await capture({
fiscal_periods: { data: periodRow(), error: null },
companies: { data: { entity_type: 'ab' }, error: null },
})
const periodSelect = selects.find((s) => s.table === 'fiscal_periods')
expect(periodSelect).toBeDefined()
expect(periodSelect?.columns).toBe('id, period_start, period_end, is_closed, locked_at')
// The column does not exist: requesting it makes PostgREST 400 the query.
expect(periodSelect?.columns).not.toMatch(/\bstatus\b/)
})
it('maps an unlocked, unclosed period to status open', async () => {
const { captured } = await capture({
fiscal_periods: { data: periodRow(), error: null },
companies: { data: { entity_type: 'ab' }, error: null },
})
expect(captured.period_lookup_failed).toBe(false)
expect(captured.fiscal_period).toMatchObject({
id: PERIOD_ID,
period_start: '2025-01-01',
period_end: '2025-12-31',
status: 'open',
lock_date: null,
})
})
it('maps locked_at to status locked and is_closed to status closed', async () => {
const { captured: locked } = await capture({
fiscal_periods: { data: periodRow({ locked_at: '2026-02-01T10:00:00Z' }), error: null },
companies: { data: null, error: null },
})
expect(locked.fiscal_period?.status).toBe('locked')
expect(locked.fiscal_period?.lock_date).toBe('2026-02-01T10:00:00Z')
// is_closed wins over locked_at, same precedence as period-service.
const { captured: closed } = await capture({
fiscal_periods: {
data: periodRow({ is_closed: true, locked_at: '2026-02-01T10:00:00Z' }),
error: null,
},
companies: { data: null, error: null },
})
expect(closed.fiscal_period?.status).toBe('closed')
})
it('fails closed when the period lookup errors', async () => {
const { captured } = await capture({
fiscal_periods: { data: null, error: { message: 'column does not exist' } },
companies: { data: { entity_type: 'ab' }, error: null },
})
expect(captured.fiscal_period).toBeNull()
expect(captured.period_lookup_failed).toBe(true)
})
})
describe('bokslut.step prompt template', () => {
it('states the period is open without any lock warning', async () => {
const { captured } = await capture({
fiscal_periods: { data: periodRow(), error: null },
companies: { data: { entity_type: 'ab' }, error: null },
})
const out = render(captured)
expect(out).toContain('Räkenskapsår: 2025-01-01 → 2025-12-31 (status: öppen)')
expect(out).not.toContain('VARNING')
expect(out).not.toContain('Perioden är LÅST')
expect(out).not.toContain('Perioden är STÄNGD')
})
it('tells the agent not to propose bookings in a closed period', async () => {
const { captured } = await capture({
fiscal_periods: { data: periodRow({ is_closed: true }), error: null },
companies: { data: { entity_type: 'ab' }, error: null },
})
const out = render(captured)
expect(out).toContain('(status: stängd)')
expect(out).toContain('Perioden är STÄNGD')
expect(out).toContain('BFL 5 kap 5 §')
expect(out).toContain('Föreslå inga bokningar')
})
it('tells the agent a locked period must be unlocked first', async () => {
const { captured } = await capture({
fiscal_periods: { data: periodRow({ locked_at: '2026-02-01T10:00:00Z' }), error: null },
companies: { data: { entity_type: 'ab' }, error: null },
})
const out = render(captured)
expect(out).toContain('(status: låst)')
expect(out).toContain('Perioden är LÅST')
expect(out).toContain('2026-02-01T10:00:00Z')
})
it('never presents an unresolvable year as open', async () => {
const { captured: errored } = await capture({
fiscal_periods: { data: null, error: { message: 'boom' } },
companies: { data: { entity_type: 'ab' }, error: null },
})
const erroredOut = render(errored)
expect(erroredOut).toContain('uppslaget av räkenskapsåret misslyckades')
expect(erroredOut).toContain('OKÄND')
expect(erroredOut).toContain('Anta INTE att året är öppet')
expect(erroredOut).not.toContain('status: öppen')
const { captured: missing } = await capture({
fiscal_periods: { data: null, error: null },
companies: { data: { entity_type: 'ab' }, error: null },
})
const missingOut = render(missing)
expect(missingOut).toContain('inget räkenskapsår hittades')
expect(missingOut).toContain('Anta INTE att året är öppet')
expect(missingOut).not.toContain('status: öppen')
})
it('exposes gnubok_list_fiscal_periods so the fail-closed instruction is actionable', () => {
expect(bokslutStep.tools).toContain('gnubok_list_fiscal_periods')
expect(bokslutStep.tools).toContain('gnubok_year_end_readiness')
})
})
@@ -0,0 +1,178 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { inboxBulkBook } from '../inbox-bulk-book'
// The bank leg of this sheet is what an LLM agent reads before it decides how
// to book. A foreign magnitude printed with "SEK" is worse than a missing
// value: the agent proceeds confidently on a number that is off by the
// exchange rate. These tests lock the unit to the transaction's real currency.
const ITEM_ID = '11111111-1111-1111-1111-111111111111'
const TX_ID = '22222222-2222-2222-2222-222222222222'
interface TxFixture {
amount: number
currency: string | null
amount_sek: number | null
exchange_rate: number | null
}
async function renderPromptForTx(tx: TxFixture): Promise<string> {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: [
{
id: ITEM_ID,
matched_transaction_id: TX_ID,
created_journal_entry_id: null,
created_supplier_invoice_id: null,
extracted_data: {
supplier: { name: 'Nordvind Molntjänst' },
invoice: { invoiceDate: '2026-05-12', currency: tx.currency ?? 'SEK' },
totals: { total: tx.amount, vatAmount: null },
},
},
],
},
{
data: [
{
id: TX_ID,
date: '2026-05-12',
amount: -tx.amount,
currency: tx.currency,
amount_sek: tx.amount_sek == null ? null : -tx.amount_sek,
exchange_rate: tx.exchange_rate,
description: 'NORDVIND MOLN',
},
],
},
])
const captured = await inboxBulkBook.capture(
{ item_ids: [ITEM_ID] },
{
supabase: supabase as unknown as SupabaseClient,
userId: 'user-1',
companyId: 'company-1',
},
)
return inboxBulkBook.promptTemplate({ captured, profileSummary: null, activeMemory: [] })
}
describe('inbox.bulk-book bank amount rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders a 500 EUR bank row in EUR and never calls it SEK', async () => {
// amount_sek is stored, so BOTH legs are shown: the agent can reason about
// the foreign amount and the SEK magnitude it will actually book.
const out = await renderPromptForTx({
amount: 500,
currency: 'EUR',
amount_sek: 5750,
exchange_rate: 11.5,
})
expect(out).toContain(`bank=${(500).toLocaleString('sv-SE')} EUR`)
expect(out).toContain(`(${(5750).toLocaleString('sv-SE')} SEK)`)
// The old rendering printed "bank=500 SEK" for exactly this row.
expect(out).not.toContain(`bank=${(500).toLocaleString('sv-SE')} SEK`)
})
it('says the SEK amount is missing instead of implying SEK when no rate is stored', async () => {
// Neither amount_sek nor exchange_rate: there is NO known SEK value. The
// old code multiplied by an implicit rate of 1 and labelled the result SEK.
const out = await renderPromptForTx({
amount: 500,
currency: 'EUR',
amount_sek: null,
exchange_rate: null,
})
expect(out).toContain(`bank=${(500).toLocaleString('sv-SE')} EUR`)
expect(out).toContain('SEK-belopp saknas')
expect(out).not.toContain(`bank=${(500).toLocaleString('sv-SE')} SEK`)
// And the agent is told not to book that row on a number it cannot trust.
expect(out).toContain('VÄXELKURS SAKNAS')
})
it('derives the SEK amount from exchange_rate when amount_sek is absent', async () => {
const out = await renderPromptForTx({
amount: 500,
currency: 'EUR',
amount_sek: null,
exchange_rate: 11.5,
})
expect(out).toContain(`bank=${(500).toLocaleString('sv-SE')} EUR`)
expect(out).toContain(`(${(5750).toLocaleString('sv-SE')} SEK)`)
expect(out).not.toContain('SEK-belopp saknas')
expect(out).not.toContain('VÄXELKURS SAKNAS')
})
it('renders a plain SEK bank row unchanged', async () => {
const out = await renderPromptForTx({
amount: 500,
currency: 'SEK',
amount_sek: null,
exchange_rate: null,
})
expect(out).toContain(`bank=${(500).toLocaleString('sv-SE')} SEK`)
// No parenthesised second leg and no foreign code on the bank part. (The
// static MOMS instruction mentions "USD/EUR", so scan the item line only.)
const itemLine = out.split('\n').find((l) => l.includes('item_id=')) ?? ''
expect(itemLine).not.toContain('EUR')
expect(itemLine).toContain('bank=500 SEK,')
expect(out).not.toContain('SEK-belopp saknas')
expect(out).not.toContain('VÄXELKURS SAKNAS')
})
it('carries the transaction currency through capture alongside the SEK leg', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: [
{
id: ITEM_ID,
matched_transaction_id: TX_ID,
created_journal_entry_id: null,
created_supplier_invoice_id: null,
extracted_data: {},
},
],
},
{
data: [
{
id: TX_ID,
date: '2026-05-12',
amount: -500,
currency: 'eur',
amount_sek: null,
exchange_rate: null,
description: 'NORDVIND MOLN',
},
],
},
])
const captured = await inboxBulkBook.capture(
{ item_ids: [ITEM_ID] },
{
supabase: supabase as unknown as SupabaseClient,
userId: 'user-1',
companyId: 'company-1',
},
)
expect(captured.items[0].tx_amount).toBe(500)
expect(captured.items[0].tx_currency).toBe('EUR')
expect(captured.items[0].tx_amount_sek).toBeNull()
})
})
@@ -0,0 +1,342 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { supplierInvoiceReview } from '../supplier-invoice-review'
// This sheet is read by an LLM agent that decides whether to attest a supplier
// invoice, and the "KÄNDA FAKTA" block is explicitly labelled "fråga INTE om
// dessa". A magnitude printed there without its unit is worse than a missing
// value: it reads as confirmation of the registered amount, so a 1 200 EUR
// invoice gets attested on the number 1 200 and booked at SEK face value.
// These tests lock every amount to its real currency, and keep any amount that
// cannot be confirmed out of the "do not ask" block entirely.
const INVOICE_ID = '11111111-1111-1111-1111-111111111111'
const DOC_ID = '22222222-2222-2222-2222-222222222222'
type Captured = Parameters<typeof supplierInvoiceReview.promptTemplate>[0]['captured']
const sv = (n: number) => n.toLocaleString('sv-SE')
interface Fixture {
currency: string | null
total_sek?: number | null
vat_amount_sek?: number | null
exchange_rate?: number | null
// Currency the AI extraction reports for the PDF. `undefined` = the
// extraction carries no currency at all (legacy / partial extraction).
extractionCurrency?: string | null
withItems?: boolean
withRecent?: boolean
}
function makeCaptured(f: Fixture): Captured {
return {
invoice: {
id: INVOICE_ID,
arrival_number: 7,
supplier_invoice_number: 'F-2026-0042',
invoice_date: '2026-05-12',
due_date: '2026-06-11',
status: 'registered',
currency: f.currency,
subtotal: 960,
vat_amount: 240,
total: 1200,
vat_amount_sek: f.vat_amount_sek ?? null,
total_sek: f.total_sek ?? null,
exchange_rate: f.exchange_rate ?? null,
vat_treatment: 'standard_25',
reverse_charge: false,
payment_reference: null,
is_credit_note: false,
document_id: DOC_ID,
},
supplier: {
id: 'supplier-1',
name: 'Nordvind Molntjänst BV',
org_number: null,
vat_number: 'NL123456789B01',
country: 'NL',
},
items: f.withItems
? [
{
description: 'Molnlagring maj',
quantity: 1,
unit_price: 960,
line_total: 960,
vat_rate: 25,
account_number: null,
},
]
: [],
recent_invoices_from_supplier: f.withRecent
? [
{
invoice_number: 'F-2026-0031',
invoice_date: '2026-04-12',
total: 1100,
total_sek: f.total_sek == null ? null : 12650,
currency: f.currency,
exchange_rate: f.exchange_rate ?? null,
status: 'paid',
},
]
: [],
inbox_extraction: {
supplier: { name: 'Nordvind Molntjänst BV', orgNumber: null, vatNumber: 'NL123456789B01' },
// `extractionCurrency: undefined` leaves the currency key out entirely.
invoice:
'extractionCurrency' in f ? { currency: f.extractionCurrency } : { currency: f.currency },
totals: { subtotal: 960, vatAmount: 240, total: 1200 },
vatBreakdown: [{ rate: 25, base: 960, amount: 240 }],
},
document_extraction: null,
}
}
function render(f: Fixture): string {
return supplierInvoiceReview.promptTemplate({
captured: makeCaptured(f),
profileSummary: null,
activeMemory: [],
})
}
// The single line that states the invoice's own amount.
function beloppLine(out: string): string {
return out.split('\n').find((l) => l.startsWith('- Belopp:')) ?? ''
}
// Everything under the "fråga INTE om dessa" header, up to the blank line that
// closes the block. This is the text the agent is told not to question.
function knownFactsBlock(out: string): string {
const all = out.split('\n')
const start = all.findIndex((l) => l.startsWith('KÄNDA FAKTA'))
if (start === -1) return ''
const rest = all.slice(start + 1)
const end = rest.findIndex((l) => l === '')
return rest.slice(0, end === -1 ? rest.length : end).join('\n')
}
describe('supplier_invoice.review currency rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders a 1200 EUR invoice in EUR with the SEK equivalent beside it', () => {
const out = render({
currency: 'EUR',
total_sek: 13800,
vat_amount_sek: 2760,
exchange_rate: 11.5,
})
expect(beloppLine(out)).toBe(`- Belopp: ${sv(1200)} EUR (moms ${sv(240)} EUR)`)
expect(beloppLine(out)).not.toContain('SEK')
expect(out).toContain(`I SEK (bokföringen sker i SEK): ${sv(13800)} SEK`)
expect(out).toContain(`moms ${sv(2760)} SEK`)
expect(out).toContain('växelkurs 11.5')
})
it('derives the SEK leg from exchange_rate when total_sek is absent', () => {
const out = render({ currency: 'EUR', total_sek: null, exchange_rate: 11.5 })
expect(out).toContain(`I SEK (bokföringen sker i SEK): ${sv(13800)} SEK`)
expect(out).not.toContain('SEK-BELOPP SAKNAS')
})
it('says the SEK amount is missing instead of implying SEK when no rate is stored', () => {
// Neither total_sek nor exchange_rate: there is NO known SEK value. Printing
// the face value as kronor here is precisely the bug.
const out = render({ currency: 'EUR', total_sek: null, exchange_rate: null })
expect(beloppLine(out)).toBe(`- Belopp: ${sv(1200)} EUR (moms ${sv(240)} EUR)`)
expect(out).toContain('SEK-BELOPP SAKNAS')
expect(out).toContain(`${sv(1200)} EUR är INTE ${sv(1200)} SEK`)
expect(out).toContain('Attestera inte på det nominella beloppet')
})
it('carries the currency into the KÄNDA FAKTA block for a 1200 EUR invoice', () => {
// The finding: the PDF total printed as a bare "1 200" three lines under a
// labelled "Belopp: 1200 EUR", inside a block the agent is told not to
// question. It must carry EUR.
const out = render({
currency: 'EUR',
total_sek: 13800,
vat_amount_sek: 2760,
exchange_rate: 11.5,
extractionCurrency: 'EUR',
})
const known = knownFactsBlock(out)
expect(known).toContain(`- Total (PDF): ${sv(1200)} EUR`)
expect(known).toContain(`- Moms (PDF): ${sv(240)} EUR`)
expect(known).toContain(`- Momsuppdelning: 25%: ${sv(240)} EUR`)
// No bare magnitude anywhere in the "do not ask" block.
expect(known).not.toMatch(new RegExp(`${sv(1200)}(?! EUR)`))
expect(known).not.toContain(`${sv(1200)} SEK`)
})
it('keeps a currency-less extracted amount OUT of the known-facts block', () => {
// A number with no unit cannot confirm a EUR registration. Under a "fråga
// INTE om dessa" header it reads as confirmation, so it does not belong
// there at all: it moves to a block that tells the agent to settle it first.
const out = render({
currency: 'EUR',
total_sek: 13800,
exchange_rate: 11.5,
extractionCurrency: null,
})
const known = knownFactsBlock(out)
// The supplier identity is still a known fact; the amounts are not.
expect(known).toContain('- Leverantör (PDF): Nordvind Molntjänst BV')
expect(known).not.toContain('Total (PDF)')
expect(known).not.toContain('Moms (PDF)')
expect(known).not.toContain('Momsuppdelning')
expect(out).toContain('EJ BEKRÄFTADE BELOPP')
expect(out).toContain(`- Total (PDF): ${sv(1200)} (valuta okänd)`)
expect(out).toContain('Extraktionen anger ingen valuta')
expect(out).toContain('Behandla det ALDRIG som kronor')
expect(out).toContain(`gnubok_get_document_content(document_id=${DOC_ID})`)
// And it is never dressed up as kronor.
expect(out).not.toContain(`Total (PDF): ${sv(1200)} SEK`)
})
it('flags a genuine currency conflict between registration and extraction', () => {
const out = render({
currency: 'EUR',
total_sek: 13800,
exchange_rate: 11.5,
extractionCurrency: 'USD',
})
expect(knownFactsBlock(out)).not.toContain('Total (PDF)')
expect(out).toContain('EJ BEKRÄFTADE BELOPP')
expect(out).toContain('VALUTAKONFLIKT')
expect(out).toContain('registrerad i EUR men underlaget anger USD')
expect(out).toContain(`- Total (PDF): ${sv(1200)} USD`)
})
it('renders a plain SEK invoice unchanged', () => {
const out = render({
currency: 'SEK',
extractionCurrency: 'SEK',
withItems: true,
withRecent: true,
})
expect(beloppLine(out)).toBe(`- Belopp: ${sv(1200)} SEK (moms ${sv(240)} SEK)`)
expect(out).not.toContain('I SEK (bokföringen sker i SEK)')
expect(out).not.toContain('SEK-BELOPP SAKNAS')
expect(out).not.toContain('EJ BEKRÄFTADE BELOPP')
expect(out).not.toContain('VALUTAKONFLIKT')
expect(out).not.toContain('valuta okänd')
// Amounts still carry their unit, and the extraction stays a known fact.
expect(knownFactsBlock(out)).toContain(`- Total (PDF): ${sv(1200)} SEK`)
expect(out).toContain(` • Molnlagring maj: ${sv(960)} SEK (25%)`)
expect(out).toContain(`F-2026-0031 (2026-04-12, paid): ${sv(1100)} SEK`)
})
it('labels line items and supplier history in the invoice currency, never bare', () => {
const out = render({
currency: 'EUR',
total_sek: 13800,
exchange_rate: 11.5,
withItems: true,
withRecent: true,
})
expect(out).toContain(` • Molnlagring maj: ${sv(960)} EUR (25%)`)
// Supplier history is what the anomaly check compares against, so the SEK
// leg travels with it: comparing 1 100 EUR to a SEK figure says nothing.
expect(out).toContain(`F-2026-0031 (2026-04-12, paid): ${sv(1100)} EUR (${sv(12650)} SEK)`)
expect(out).not.toContain(`${sv(960)} SEK`)
})
it('tells the agent that a foreign invoice is booked on the SEK amount', () => {
const out = render({ currency: 'EUR', total_sek: 13800, exchange_rate: 11.5 })
expect(out).toContain('bokföringen sker i SEK')
expect(out).toContain('Ett belopp utan angiven valuta är inte ett SEK-belopp')
expect(out).toContain('Jämför i SEK när valutorna skiljer sig åt')
})
})
describe('supplier_invoice.review capture', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('threads the SEK columns and the exchange rate through capture', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
// 1. the invoice itself
{
data: {
id: INVOICE_ID,
supplier_id: 'supplier-1',
arrival_number: 7,
supplier_invoice_number: 'F-2026-0042',
invoice_date: '2026-05-12',
due_date: '2026-06-11',
status: 'registered',
currency: 'EUR',
exchange_rate: 11.5,
subtotal: 960,
vat_amount: 240,
vat_amount_sek: 2760,
total: 1200,
total_sek: 13800,
vat_treatment: 'standard_25',
reverse_charge: false,
payment_reference: null,
is_credit_note: false,
document_id: DOC_ID,
},
},
// 2. supplier
{ data: { id: 'supplier-1', name: 'Nordvind Molntjänst BV', org_number: null, vat_number: null, country: 'NL' } },
// 3. items
{ data: [] },
// 4. recent invoices from the same supplier
{
data: [
{
supplier_invoice_number: 'F-2026-0031',
invoice_date: '2026-04-12',
total: 1100,
total_sek: 12650,
currency: 'EUR',
exchange_rate: 11.5,
status: 'paid',
},
],
},
// 5. inbox extraction
{ data: null },
// 6. document extraction
{ data: null },
])
const captured = await supplierInvoiceReview.capture(
{ supplier_invoice_id: INVOICE_ID },
{
supabase: supabase as unknown as SupabaseClient,
userId: 'user-1',
companyId: 'company-1',
},
)
expect(captured.invoice?.currency).toBe('EUR')
expect(captured.invoice?.total).toBe(1200)
expect(captured.invoice?.total_sek).toBe(13800)
expect(captured.invoice?.vat_amount_sek).toBe(2760)
expect(captured.invoice?.exchange_rate).toBe(11.5)
expect(captured.recent_invoices_from_supplier[0].total_sek).toBe(12650)
expect(captured.recent_invoices_from_supplier[0].exchange_rate).toBe(11.5)
})
})
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { verifikationDraft } from '../verifikation-draft'
// verifikation.draft is the assistant entry point on the manual bookkeeping
@@ -122,6 +122,217 @@ describe('verifikation.draft prompt template', () => {
})
})
// The shared mock Supabase harnesses proxy every chained call and never look at
// the argument, so a select() naming a column that does not exist looks
// identical to a correct one. These tests therefore record the select strings
// and assert against the real schema:
// journal_entry_lines: line_description (NOT description)
// fiscal_periods: is_closed + locked_at (NOT status / locked_through)
type StubResult = { data?: unknown; error?: unknown; throws?: Error }
function createRecordingSupabase(byTable: Record<string, StubResult[]>) {
const selects: { table: string; columns: string }[] = []
const chainFor = (table: string, result: StubResult): unknown => {
const proxy: unknown = new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void, reject: (e: unknown) => void) => {
if (result.throws) {
reject(result.throws)
return
}
resolve({ data: result.data ?? null, error: result.error ?? null })
}
}
return (...args: unknown[]) => {
if (prop === 'select') selects.push({ table, columns: String(args[0] ?? '') })
return proxy
}
},
},
)
return proxy
}
const supabase = {
from: (table: string) => chainFor(table, byTable[table]?.shift() ?? {}),
}
return { supabase: supabase as never, selects }
}
const DRAFT_ENTRY = { id: 'e1', entry_date: '2026-05-01', description: 'Inköp', status: 'draft' }
function stubs(overrides: Partial<Record<string, StubResult[]>> = {}): Record<string, StubResult[]> {
return {
journal_entries: [{ data: DRAFT_ENTRY }],
journal_entry_lines: [
{
data: [
{
account_number: '5410',
debit_amount: 500,
credit_amount: null,
line_description: 'Förbrukningsinventarier',
},
],
},
],
company_settings: [{ data: null }],
fiscal_periods: [{ data: { id: 'p1', is_closed: false, locked_at: null } }],
document_attachments: [{ data: [] }],
...overrides,
}
}
async function captureWith(byTable: Record<string, StubResult[]>) {
const { supabase, selects } = createRecordingSupabase(byTable)
const captured = await verifikationDraft.capture(
{ journal_entry_id: 'e1' },
{ supabase, userId: 'u1', companyId: 'c1' },
)
const prompt = verifikationDraft.promptTemplate({ captured, profileSummary: null, activeMemory: [] })
return { captured, prompt, selects }
}
describe('verifikation.draft capture selects columns that exist', () => {
it('reads journal_entry_lines.line_description, not a non-existent description column', async () => {
const { captured, selects } = await captureWith(stubs())
const lineSelect = selects.find((s) => s.table === 'journal_entry_lines')
expect(lineSelect).toBeDefined()
expect(lineSelect!.columns).toContain('line_description')
// A bare `description` would make PostgREST reject the whole select, which
// is what made the agent see a verifikation with zero rader.
expect(lineSelect!.columns).not.toMatch(/(^|[\s,])description/)
// ...and the per-line text still reaches the prompt shape.
expect(captured.current_lines).toEqual([
{
account_number: '5410',
debit_amount: 500,
credit_amount: null,
description: 'Förbrukningsinventarier',
},
])
})
it('resolves the lock state from the real two-layer source, not fiscal_periods.status/locked_through', async () => {
const { selects } = await captureWith(stubs())
// Layer 1: company-wide lock date.
const settingsSelect = selects.find((s) => s.table === 'company_settings')
expect(settingsSelect?.columns).toContain('bookkeeping_locked_through')
// Layer 2: the covering fiscal period.
const periodSelect = selects.find((s) => s.table === 'fiscal_periods')
expect(periodSelect).toBeDefined()
expect(periodSelect!.columns).toContain('is_closed')
expect(periodSelect!.columns).toContain('locked_at')
expect(periodSelect!.columns).not.toContain('locked_through')
expect(periodSelect!.columns).not.toContain('status')
})
})
describe('verifikation.draft period lock gate', () => {
it('stays silent about locks for an open period', async () => {
const { captured, prompt } = await captureWith(stubs())
expect(captured.period_status).toEqual({ period_id: 'p1', status: 'open', lock_date: null })
expect(prompt).not.toContain('PERIODEN ÄR LÅST')
expect(prompt).not.toContain('PERIODEN ÄR STÄNGD')
expect(prompt).not.toContain('PERIODLÅSET KUNDE INTE LÄSAS')
})
it('refuses a locked period (fiscal_periods.locked_at)', async () => {
const { captured, prompt } = await captureWith(
stubs({ fiscal_periods: [{ data: { id: 'p1', is_closed: false, locked_at: '2026-06-01T09:00:00Z' } }] }),
)
expect(captured.period_status?.status).toBe('locked')
expect(prompt).toContain('PERIODEN ÄR LÅST')
})
it('refuses a date behind the company-wide lock date', async () => {
const { captured, prompt } = await captureWith(
stubs({ company_settings: [{ data: { bookkeeping_locked_through: '2026-06-30' } }] }),
)
expect(captured.period_status?.status).toBe('locked')
expect(captured.period_status?.lock_date).toBe('2026-06-30')
expect(prompt).toContain('PERIODEN ÄR LÅST')
})
it('refuses a closed period and does not offer to unlock it', async () => {
const { captured, prompt } = await captureWith(
stubs({ fiscal_periods: [{ data: { id: 'p1', is_closed: true, locked_at: '2026-06-01T09:00:00Z' } }] }),
)
expect(captured.period_status?.status).toBe('closed')
expect(prompt).toContain('PERIODEN ÄR STÄNGD')
// A closed period cannot be unlocked (period-service rejects it), so the
// agent must not send the user to Räkenskapsår to try.
expect(prompt).not.toContain('låsa upp perioden')
})
it('fails closed when the lock lookup errors', async () => {
const { captured, prompt } = await captureWith(
stubs({ company_settings: [{ throws: new Error('PostgREST 500') }] }),
)
// Never 'open': an unreadable lock state must not read as postable.
expect(captured.period_status?.status).toBe('unknown')
expect(prompt).toContain('PERIODLÅSET KUNDE INTE LÄSAS')
expect(prompt).toContain('utgå INTE från att perioden är öppen')
})
it('renders a SOFT lookup failure as unknown, never as a real lock', async () => {
// resolvePeriodStatusForDate does not throw on a PostgREST error result:
// it returns { status: 'locked', lookup_failed: true } (fail-closed for
// write gates). This surface must branch on lookup_failed: a transient DB
// blip must not tell the user "PERIODEN ÄR LÅST ... låsa upp perioden"
// about a lock that may not exist.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const { captured, prompt } = await captureWith(
stubs({ company_settings: [{ data: null, error: { message: 'connection reset' } }] }),
)
expect(captured.period_status?.status).toBe('unknown')
expect(prompt).toContain('PERIODLÅSET KUNDE INTE LÄSAS')
expect(prompt).not.toContain('PERIODEN ÄR LÅST')
expect(prompt).not.toContain('låsa upp perioden')
} finally {
errorSpy.mockRestore()
}
})
it('points a posted entry in a locked period at storno, never at an inline rewrite', async () => {
// BFL 5 kap 5 §: inline rättelse is only legal while the period is open and
// unlocked. Past a lock/close, storno is the only sanctioned track.
const { prompt } = await captureWith(
stubs({
journal_entries: [{ data: { ...DRAFT_ENTRY, status: 'posted' } }],
fiscal_periods: [{ data: { id: 'p1', is_closed: false, locked_at: '2026-06-01T09:00:00Z' } }],
}),
)
expect(prompt).toContain('storno')
expect(prompt).toContain('BFL 5 kap 5 §')
})
it('does not push storno at a posted entry while the period is open', async () => {
const { prompt } = await captureWith(
stubs({ journal_entries: [{ data: { ...DRAFT_ENTRY, status: 'posted' } }] }),
)
expect(prompt).not.toContain('BFL 5 kap 5 §')
})
})
describe('verifikation.draft capture', () => {
it('returns an empty draft (with an underlag array) when no entry id is given', async () => {
// The fresh-start path (Bokföring → "Skapa med assistent") passes no
+67 -11
View File
@@ -1,6 +1,7 @@
import { defineAgentIntent } from './types'
import { OPUS_MODEL } from '@/lib/agent/composer/client'
import { renderAgentGroundRules } from './shared-rules'
import type { PeriodStatusValue } from '@/lib/core/bookkeeping/period-service'
// bokslut.step: "Fråga [namn]" inside the year-end (bokslut) wizard.
//
@@ -28,11 +29,24 @@ interface CapturedBokslutStep {
id: string | null
period_start: string | null
period_end: string | null
status: string | null
// Canonical three-value period state, derived from is_closed + locked_at
// the same way lib/core/bookkeeping/period-service.ts derives it.
status: PeriodStatusValue
lock_date: string | null
} | null
// True when the fiscal_periods lookup itself failed. Distinct from
// fiscal_period === null (no period row), which is the "no räkenskapsår
// exists yet" case. Both mean the year state is UNKNOWN, never "open".
period_lookup_failed: boolean
entity_type: string | null
}
const PERIOD_STATUS_SV: Record<PeriodStatusValue, string> = {
open: 'öppen',
locked: 'låst',
closed: 'stängd',
}
export const bokslutStep = defineAgentIntent<BokslutStepArgs, CapturedBokslutStep>({
id: 'bokslut.step',
buttonLabel: 'Fråga om detta steg',
@@ -53,6 +67,7 @@ export const bokslutStep = defineAgentIntent<BokslutStepArgs, CapturedBokslutSte
tools: [
'gnubok_year_end_readiness',
'gnubok_list_fiscal_periods',
'gnubok_propose_accruals',
'gnubok_propose_annual_depreciation',
'gnubok_propose_dispositioner',
@@ -70,15 +85,21 @@ export const bokslutStep = defineAgentIntent<BokslutStepArgs, CapturedBokslutSte
model: OPUS_MODEL,
capture: async ({ step_id, fiscal_year_end }, { supabase, companyId }) => {
// Find the latest non-locked fiscal period (the one being closed): or
// the one matching fiscal_year_end if supplied.
// Find the latest fiscal period (the one being closed): or the one
// matching fiscal_year_end if supplied.
//
// fiscal_periods has NO `status` column. Period state lives in
// `is_closed` + `locked_at`, which is what period-service.ts
// (resolvePeriodStatusForDate) and the DB period-lock triggers read.
// Selecting a non-existent column makes PostgREST reject the whole
// query, so the period would never resolve at all.
let query = supabase
.from('fiscal_periods')
.select('id, period_start, period_end, status')
.select('id, period_start, period_end, is_closed, locked_at')
.eq('company_id', companyId)
if (fiscal_year_end) query = query.eq('period_end', fiscal_year_end)
query = query.order('period_end', { ascending: false }).limit(1)
const { data: period } = await query.maybeSingle()
const { data: period, error: periodError } = await query.maybeSingle()
const { data: company } = await supabase
.from('companies')
@@ -86,14 +107,28 @@ export const bokslutStep = defineAgentIntent<BokslutStepArgs, CapturedBokslutSte
.eq('id', companyId)
.maybeSingle()
const row = periodError
? null
: (period as {
id: string
period_start?: string | null
period_end?: string | null
is_closed?: boolean | null
locked_at?: string | null
} | null)
return {
step_id: step_id ?? null,
fiscal_period: period
period_lookup_failed: !!periodError,
fiscal_period: row
? {
id: (period as { id: string }).id,
period_start: ((period as { period_start?: string | null }).period_start) ?? null,
period_end: ((period as { period_end?: string | null }).period_end) ?? null,
status: ((period as { status?: string | null }).status) ?? null,
id: row.id,
period_start: row.period_start ?? null,
period_end: row.period_end ?? null,
// Same precedence as resolvePeriodStatusForDate: closed wins
// over locked, locked wins over open.
status: row.is_closed ? 'closed' : row.locked_at ? 'locked' : 'open',
lock_date: row.locked_at ?? null,
}
: null,
entity_type: ((company as { entity_type?: string | null } | null)?.entity_type) ?? null,
@@ -107,8 +142,29 @@ export const bokslutStep = defineAgentIntent<BokslutStepArgs, CapturedBokslutSte
lines.push('Användaren är i bokslutsguiden och behöver hjälp.')
if (captured.step_id) lines.push(`Aktivt steg: ${captured.step_id}`)
if (captured.fiscal_period) {
const period = captured.fiscal_period
lines.push(
`Räkenskapsår: ${captured.fiscal_period.period_start ?? '?'} → ${captured.fiscal_period.period_end ?? '?'} (status: ${captured.fiscal_period.status ?? '?'})`,
`Räkenskapsår: ${period.period_start ?? '?'} → ${period.period_end ?? '?'} (status: ${PERIOD_STATUS_SV[period.status]})`,
)
if (period.status === 'closed') {
lines.push(
'Perioden är STÄNGD. Databasens periodlås avvisar nya bokföringsposter i perioden. Föreslå inga bokningar i räkenskapsåret: hänvisa till rättelse enligt BFL 5 kap 5 § (storno) om något behöver korrigeras.',
)
} else if (period.status === 'locked') {
lines.push(
`Perioden är LÅST${period.lock_date ? ` (låst ${period.lock_date})` : ''}. Databasens periodlås avvisar nya bokföringsposter tills en behörig användare låser upp perioden. Föreslå inga bokningar innan dess: säg att perioden måste låsas upp först.`,
)
}
} else {
// Fail closed: an errored lookup or a missing period row is NOT an open
// year. Never let the agent reason as if bokslutsposter can be booked.
lines.push(
captured.period_lookup_failed
? 'VARNING: uppslaget av räkenskapsåret misslyckades (databasfel). Räkenskapsårets status är OKÄND.'
: 'VARNING: inget räkenskapsår hittades för företaget. Räkenskapsårets status är OKÄND.',
)
lines.push(
'Anta INTE att året är öppet. Kör gnubok_year_end_readiness och gnubok_list_fiscal_periods, och bekräfta räkenskapsåret med användaren INNAN du föreslår eller stagear någon bokslutspost.',
)
}
if (captured.entity_type) lines.push(`Företagsform: ${captured.entity_type}`)
+47 -6
View File
@@ -28,6 +28,12 @@ interface CapturedInboxItem {
vat_amount: number | null
currency: string | null
tx_date: string | null
// The bank transaction's own magnitude travels WITH its own currency, and the
// SEK equivalent is a separate field that is null when the row is foreign and
// no amount_sek/exchange_rate is stored. Never collapse the two: a foreign
// amount labelled "SEK" reads to the agent as a SEK amount and it books on it.
tx_amount: number | null
tx_currency: string | null
tx_amount_sek: number | null
tx_description: string | null
}
@@ -37,8 +43,11 @@ interface CapturedInboxBulk {
bookable_count: number
}
// SEK magnitude of a (usually-SEK) bank transaction. Foreign rows are
// normalised via their stored amount_sek/exchange_rate.
// SEK magnitude of a bank transaction, or null when there genuinely is none.
// Foreign rows are normalised via their stored amount_sek/exchange_rate; a
// foreign row that carries NEITHER has no known SEK value, so we return null
// instead of falling back to rate 1 (that returned e.g. 500 for a 500 EUR row,
// which the sheet then printed as "500 SEK").
function txSek(tx: {
amount: number | null
currency: string | null
@@ -48,8 +57,34 @@ function txSek(tx: {
if (tx.amount == null) return null
const cur = String(tx.currency ?? 'SEK').toUpperCase()
if (cur === 'SEK') return Math.abs(Number(tx.amount))
const sek = tx.amount_sek ?? Number(tx.amount) * Number(tx.exchange_rate ?? 1)
return Number.isFinite(sek) ? Math.abs(Number(sek)) : null
if (tx.amount_sek != null) {
const stored = Number(tx.amount_sek)
return Number.isFinite(stored) ? Math.abs(stored) : null
}
const rate = tx.exchange_rate == null ? null : Number(tx.exchange_rate)
if (rate == null || !Number.isFinite(rate) || rate <= 0) return null
const sek = Number(tx.amount) * rate
return Number.isFinite(sek) ? Math.abs(sek) : null
}
// Amount of the bank transaction in ITS OWN currency (absolute value, matching
// how txSek reports the SEK leg).
function txOwnAmount(tx: { amount: number | null }): number | null {
if (tx.amount == null) return null
const n = Number(tx.amount)
return Number.isFinite(n) ? Math.abs(n) : null
}
// Renders the bank leg so the magnitude always carries its real unit: SEK rows
// print as before, foreign rows print their own amount plus the SEK equivalent
// when one exists, and say so explicitly when it does not.
function formatBankAmount(it: CapturedInboxItem): string | null {
if (it.tx_amount == null) return null
const cur = it.tx_currency ?? 'SEK'
const own = `${it.tx_amount.toLocaleString('sv-SE')} ${cur}`
if (cur === 'SEK') return own
if (it.tx_amount_sek != null) return `${own} (${it.tx_amount_sek.toLocaleString('sv-SE')} SEK)`
return `${own} (SEK-belopp saknas: ingen växelkurs lagrad på transaktionen)`
}
export const inboxBulkBook = defineAgentIntent<InboxBulkBookArgs, CapturedInboxBulk>({
@@ -137,6 +172,8 @@ export const inboxBulkBook = defineAgentIntent<InboxBulkBookArgs, CapturedInboxB
vat_amount: ex.totals?.vatAmount ?? null,
currency: ex.invoice?.currency ?? null,
tx_date: tx?.date ?? null,
tx_amount: tx ? txOwnAmount(tx) : null,
tx_currency: tx ? String(tx.currency ?? 'SEK').toUpperCase() : null,
tx_amount_sek: tx ? txSek(tx) : null,
tx_description: tx?.description ?? null,
}
@@ -170,7 +207,8 @@ export const inboxBulkBook = defineAgentIntent<InboxBulkBookArgs, CapturedInboxB
if (it.merchant_name) parts.push(`leverantör=${it.merchant_name}`)
if (it.total != null) parts.push(`belopp=${it.total.toLocaleString('sv-SE')} ${it.currency ?? 'SEK'}`)
if (it.vat_amount != null) parts.push(`moms=${it.vat_amount.toLocaleString('sv-SE')} ${it.currency ?? 'SEK'}`)
if (it.tx_amount_sek != null) parts.push(`bank=${it.tx_amount_sek.toLocaleString('sv-SE')} SEK`)
const bank = formatBankAmount(it)
if (bank) parts.push(`bank=${bank}`)
if (it.tx_date) parts.push(`datum=${it.tx_date}`)
lines.push(` • ${parts.join(', ')}`)
}
@@ -184,7 +222,10 @@ export const inboxBulkBook = defineAgentIntent<InboxBulkBookArgs, CapturedInboxB
}
lines.push('')
lines.push('Arbetssätt:')
lines.push('- Boka via banktransaktionen (Modell B): verktyget bokför varje underlag mot dess matchade banktransaktion, som redan bär SEK-beloppet. Du behöver inte räkna om valuta.')
lines.push('- Boka via banktransaktionen (Modell B): verktyget bokför varje underlag mot dess matchade banktransaktion. Bankraden ovan visar sitt eget belopp med sin egen valuta, och SEK-beloppet inom parentes när ett sådant finns lagrat. Står raden i SEK, eller har ett SEK-belopp inom parentes, behöver du inte räkna om valuta.')
if (bookable.some((i) => i.tx_amount != null && (i.tx_currency ?? 'SEK') !== 'SEK' && i.tx_amount_sek == null)) {
lines.push('- VÄXELKURS SAKNAS på minst en bankrad ovan (markerad "SEK-belopp saknas"). Det beloppet är i utländsk valuta, INTE i SEK: behandla det aldrig som ett SEK-belopp. Bulkbokför inte den raden på egen hand: säg till användaren att transaktionens växelkurs/SEK-belopp behöver fyllas i först, och bokför de övriga raderna som vanligt.')
}
lines.push('- GRUPPERA de bokförbara underlagen efter leverantör/typ. Samma slags kostnad → samma kategori + momsbehandling. För varje homogen grupp anropar du gnubok_bulk_book_inbox_items med gruppens item_ids, en kategori (enum) och vat_treatment.')
lines.push('- MOMS: en utländsk tjänst (t.ex. USD/EUR-prenumeration som Cursor/Anysphere där säljaren INTE debiterat svensk moms) är omvänd skattskyldighet → vat_treatment="reverse_charge". En svensk faktura med debiterad moms → standard_25 (eller den sats kvittot visar). Gissa aldrig: utgå från valuta + om underlaget visar moms.')
lines.push('- KOLLA HUR MOTPARTEN BOKFÖRTS FÖRUT med gnubok_query_journal({ text: "<leverantör>", limit: 5 }) innan du väljer kategori. Följ ett tydligt tidigare mönster om inte underlaget motsäger det.')
+155 -19
View File
@@ -39,6 +39,13 @@ interface CapturedSupplierInvoiceReview {
subtotal: number | null
vat_amount: number | null
total: number | null
// The invoice's magnitudes travel WITH their own currency, and the SEK
// legs are separate fields that stay null when no rate was ever resolved.
// Never collapse the two: a EUR amount printed without a unit reads to the
// agent as kronor, and it attests the booking on that number.
vat_amount_sek: number | null
total_sek: number | null
exchange_rate: number | null
vat_treatment: string | null
reverse_charge: boolean | null
payment_reference: string | null
@@ -65,6 +72,10 @@ interface CapturedSupplierInvoiceReview {
invoice_date: string | null
total: number | null
currency: string | null
// Anomaly detection compares these against each other. Without a common
// unit that comparison is meaningless, so the SEK leg travels along.
total_sek: number | null
exchange_rate: number | null
status: string | null
}[]
// Linked inbox / document extraction so the agent doesn't re-ask for what
@@ -73,6 +84,58 @@ interface CapturedSupplierInvoiceReview {
document_extraction: Record<string, unknown> | null
}
// ISO code as stored, uppercased, or null when nothing usable is there. A blank
// or absent code is NOT silently promoted to SEK: an amount whose unit we do
// not know must be rendered as unknown, never as kronor.
function normalizeCurrency(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null
const code = value.trim().toUpperCase()
return code.length === 0 ? null : code
}
// "1 200 EUR", or "1 200 (valuta okänd)" when no currency code is attached.
function formatMoney(amount: number | null | undefined, currency: string | null | undefined): string {
if (amount == null || !Number.isFinite(Number(amount))) return '?'
const code = normalizeCurrency(currency)
const value = Number(amount).toLocaleString('sv-SE')
return code ? `${value} ${code}` : `${value} (valuta okänd)`
}
// SEK magnitude of an amount, or null when there genuinely is none. Mirrors
// resolveSekAmount()'s priority (stored *_sek column, then exchange_rate) but
// deliberately drops its legacy "assume SEK" fallback: for a reader the honest
// answer is "no known SEK value", not the face value relabelled as kronor.
function sekEquivalent(
amount: number | null | undefined,
amountSek: number | null | undefined,
currency: string | null | undefined,
exchangeRate: number | null | undefined,
): number | null {
if (amount == null || !Number.isFinite(Number(amount))) return null
if (normalizeCurrency(currency) === 'SEK') return Number(amount)
if (amountSek != null && Number.isFinite(Number(amountSek))) return Number(amountSek)
const rate = exchangeRate == null ? null : Number(exchangeRate)
if (rate != null && Number.isFinite(rate) && rate > 0) {
return Math.round(Number(amount) * rate * 100) / 100
}
return null
}
// "1 200 EUR (13 800 SEK)" when a rate exists, plain "1 200 SEK" for a domestic
// amount, and the bare foreign amount when no SEK value is known.
function formatMoneyWithSek(
amount: number | null | undefined,
amountSek: number | null | undefined,
currency: string | null | undefined,
exchangeRate: number | null | undefined,
): string {
const own = formatMoney(amount, currency)
if (amount == null) return own
if (normalizeCurrency(currency) === 'SEK') return own
const sek = sekEquivalent(amount, amountSek, currency, exchangeRate)
return sek == null ? own : `${own} (${sek.toLocaleString('sv-SE')} SEK)`
}
export const supplierInvoiceReview = defineAgentIntent<
SupplierInvoiceReviewArgs,
CapturedSupplierInvoiceReview
@@ -114,7 +177,7 @@ export const supplierInvoiceReview = defineAgentIntent<
const { data: invoice } = await supabase
.from('supplier_invoices')
.select(
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, subtotal, vat_amount, total, vat_treatment, reverse_charge, payment_reference, is_credit_note, document_id',
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, exchange_rate, subtotal, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, is_credit_note, document_id',
)
.eq('id', supplier_invoice_id)
.eq('company_id', companyId)
@@ -156,7 +219,7 @@ export const supplierInvoiceReview = defineAgentIntent<
supplierId
? supabase
.from('supplier_invoices')
.select('supplier_invoice_number, invoice_date, total, currency, status')
.select('supplier_invoice_number, invoice_date, total, total_sek, currency, exchange_rate, status')
.eq('supplier_id', supplierId)
.eq('company_id', companyId)
.neq('id', supplier_invoice_id)
@@ -194,6 +257,9 @@ export const supplierInvoiceReview = defineAgentIntent<
subtotal: ((invoice as { subtotal?: number | null }).subtotal) ?? null,
vat_amount: ((invoice as { vat_amount?: number | null }).vat_amount) ?? null,
total: ((invoice as { total?: number | null }).total) ?? null,
vat_amount_sek: ((invoice as { vat_amount_sek?: number | null }).vat_amount_sek) ?? null,
total_sek: ((invoice as { total_sek?: number | null }).total_sek) ?? null,
exchange_rate: ((invoice as { exchange_rate?: number | null }).exchange_rate) ?? null,
vat_treatment: ((invoice as { vat_treatment?: string | null }).vat_treatment) ?? null,
reverse_charge: ((invoice as { reverse_charge?: boolean | null }).reverse_charge) ?? null,
payment_reference:
@@ -229,13 +295,17 @@ export const supplierInvoiceReview = defineAgentIntent<
supplier_invoice_number: string | null
invoice_date: string | null
total: number | null
total_sek: number | null
currency: string | null
exchange_rate: number | null
status: string | null
}[]).map((r) => ({
invoice_number: r.supplier_invoice_number,
invoice_date: r.invoice_date,
total: r.total,
total_sek: r.total_sek ?? null,
currency: r.currency,
exchange_rate: r.exchange_rate ?? null,
status: r.status,
})),
inbox_extraction: (inboxRow as { extracted_data?: Record<string, unknown> | null } | null)?.extracted_data ?? null,
@@ -269,9 +339,27 @@ export const supplierInvoiceReview = defineAgentIntent<
lines.push(`- ${supplierLine.join(' ')}`)
}
lines.push(`- Status: ${inv.status ?? '?'} | Datum: ${inv.invoice_date ?? '?'} | Förfaller: ${inv.due_date ?? '?'}`)
const invCurrency = normalizeCurrency(inv.currency)
lines.push(
`- Belopp: ${inv.total != null ? `${inv.total.toLocaleString('sv-SE')} ${inv.currency ?? 'SEK'}` : '?'} (moms ${inv.vat_amount != null ? `${inv.vat_amount.toLocaleString('sv-SE')} ${inv.currency ?? 'SEK'}` : '?'})`,
`- Belopp: ${formatMoney(inv.total, inv.currency)} (moms ${formatMoney(inv.vat_amount, inv.currency)})`,
)
// A foreign invoice is booked in SEK, so the SEK leg is the number that
// actually lands in the verifikat. Print it next to the face value when it
// exists, and say plainly that it is missing when it does not: the one
// thing this block must never do is let a EUR figure pass for kronor.
if (invCurrency !== 'SEK' && inv.total != null) {
const totalSek = sekEquivalent(inv.total, inv.total_sek, inv.currency, inv.exchange_rate)
const vatSek = sekEquivalent(inv.vat_amount, inv.vat_amount_sek, inv.currency, inv.exchange_rate)
if (totalSek != null) {
lines.push(
`- I SEK (bokföringen sker i SEK): ${totalSek.toLocaleString('sv-SE')} SEK (moms ${vatSek != null ? `${vatSek.toLocaleString('sv-SE')} SEK` : '?'})${inv.exchange_rate != null ? `, växelkurs ${inv.exchange_rate}` : ''}`,
)
} else {
lines.push(
`- SEK-BELOPP SAKNAS: ingen växelkurs är lagrad på fakturan. ${formatMoney(inv.total, inv.currency)} är INTE ${inv.total.toLocaleString('sv-SE')} SEK. Attestera inte på det nominella beloppet: växelkursen måste fyllas i först.`,
)
}
}
lines.push(
`- Momskod: ${inv.vat_treatment ?? '?'}${inv.reverse_charge ? ' (omvänd skattskyldighet flaggad)' : ''}`,
)
@@ -282,10 +370,12 @@ export const supplierInvoiceReview = defineAgentIntent<
if (captured.items.length > 0) {
lines.push('Rader:')
for (const it of captured.items.slice(0, 20)) {
const total = it.line_total != null ? `${it.line_total.toLocaleString('sv-SE')}` : '?'
// Line amounts are stored in the invoice's currency, so they carry the
// invoice's currency code: never a bare number that reads as kronor.
const total = formatMoney(it.line_total, inv.currency)
const vat = it.vat_rate != null ? `${it.vat_rate}%` : '?'
const acc = it.account_number ? ` → ${it.account_number}` : ' → (ingen kontering)'
lines.push(` • ${it.description ?? '(beskrivning saknas)'}: ${total} ${inv.currency ?? 'SEK'} (${vat})${acc}`)
lines.push(` • ${it.description ?? '(beskrivning saknas)'}: ${total} (${vat})${acc}`)
}
lines.push('')
}
@@ -293,7 +383,7 @@ export const supplierInvoiceReview = defineAgentIntent<
if (captured.recent_invoices_from_supplier.length > 0) {
lines.push('Tidigare fakturor från samma leverantör (för anomalikontroll):')
for (const r of captured.recent_invoices_from_supplier) {
const amt = r.total != null ? `${r.total.toLocaleString('sv-SE')} ${r.currency ?? 'SEK'}` : '?'
const amt = formatMoneyWithSek(r.total, r.total_sek, r.currency, r.exchange_rate)
lines.push(` • ${r.invoice_number ?? '?'} (${r.invoice_date ?? '?'}, ${r.status ?? '?'}): ${amt}`)
}
lines.push('')
@@ -301,19 +391,64 @@ export const supplierInvoiceReview = defineAgentIntent<
const ex = captured.inbox_extraction ?? captured.document_extraction
if (ex) {
lines.push('KÄNDA FAKTA från AI-extraktion av underlaget: fråga INTE om dessa:')
const supplier = (ex.supplier as { name?: string | null; orgNumber?: string | null; vatNumber?: string | null } | undefined) ?? null
const exInvoice = (ex.invoice as { currency?: string | null } | undefined) ?? null
const totals = (ex.totals as { total?: number | null; vatAmount?: number | null } | undefined) ?? null
const breakdown = (ex.vatBreakdown as { rate: number; base: number; amount: number }[] | undefined) ?? []
if (supplier?.name) lines.push(`- Leverantör (PDF): ${supplier.name}`)
if (supplier?.orgNumber) lines.push(`- Org.nr (PDF): ${supplier.orgNumber}`)
if (supplier?.vatNumber) lines.push(`- VAT-nr (PDF): ${supplier.vatNumber}`)
if (totals?.total != null) lines.push(`- Total (PDF): ${totals.total.toLocaleString('sv-SE')}`)
if (totals?.vatAmount != null) lines.push(`- Moms (PDF): ${totals.vatAmount.toLocaleString('sv-SE')}`)
if (breakdown.length > 0) {
lines.push(`- Momsuppdelning: ${breakdown.map((b) => `${b.rate}%: ${b.amount.toLocaleString('sv-SE')}`).join('; ')}`)
const exCurrency = normalizeCurrency(exInvoice?.currency)
const hasExtractedAmounts =
totals?.total != null || totals?.vatAmount != null || breakdown.length > 0
// An extracted amount is a KNOWN FACT only when we know its unit AND that
// unit is the one the invoice is registered in. Anything else is half a
// fact, and half a fact under a "fråga INTE om dessa" header reads as
// confirmation of the registered amount: that is exactly how a 1 200 EUR
// invoice gets attested as 1 200 SEK. Those values move to their own
// block below, where the agent is told to settle them first.
const amountsConfirmRegistered =
hasExtractedAmounts && exCurrency != null && invCurrency != null && exCurrency === invCurrency
const renderAmountLines = (): string[] => {
const out: string[] = []
if (totals?.total != null) out.push(`- Total (PDF): ${formatMoney(totals.total, exCurrency)}`)
if (totals?.vatAmount != null) out.push(`- Moms (PDF): ${formatMoney(totals.vatAmount, exCurrency)}`)
if (breakdown.length > 0) {
out.push(
`- Momsuppdelning: ${breakdown.map((b) => `${b.rate}%: ${formatMoney(b.amount, exCurrency)}`).join('; ')}`,
)
}
return out
}
const known: string[] = []
if (supplier?.name) known.push(`- Leverantör (PDF): ${supplier.name}`)
if (supplier?.orgNumber) known.push(`- Org.nr (PDF): ${supplier.orgNumber}`)
if (supplier?.vatNumber) known.push(`- VAT-nr (PDF): ${supplier.vatNumber}`)
if (amountsConfirmRegistered) known.push(...renderAmountLines())
if (known.length > 0) {
lines.push('KÄNDA FAKTA från AI-extraktion av underlaget: fråga INTE om dessa:')
lines.push(...known)
lines.push('')
}
if (hasExtractedAmounts && !amountsConfirmRegistered) {
lines.push(
'EJ BEKRÄFTADE BELOPP från AI-extraktionen: dessa är INTE kända fakta och bekräftar INTE det registrerade beloppet ovan:',
)
lines.push(...renderAmountLines())
if (exCurrency == null) {
lines.push(
`- Extraktionen anger ingen valuta. Fakturan är registrerad i ${invCurrency ?? 'okänd valuta'}: ett belopp utan valuta kan varken bekräfta eller motsäga det registrerade beloppet. Behandla det ALDRIG som kronor.`,
)
} else {
lines.push(
`- ⚠ VALUTAKONFLIKT: fakturan är registrerad i ${invCurrency ?? 'okänd valuta'} men underlaget anger ${exCurrency}. Attestera inte förrän det är utrett vilken valuta som gäller: ett av beloppen är fel registrerat.`,
)
}
lines.push(
`- Fastställ valuta och belopp INNAN du attesterar: läs underlaget med gnubok_get_document_content(document_id=${inv.document_id ?? '…'}), eller fråga användaren.`,
)
lines.push('')
}
lines.push('')
} else if (inv.document_id) {
lines.push('Underlag är bifogat men inte AI-extraherat. Använd gnubok_get_document_content för att läsa PDF/bilden om du behöver fler signaler.')
lines.push('')
@@ -328,10 +463,11 @@ export const supplierInvoiceReview = defineAgentIntent<
lines.push(' - EU näringsidkare: omvänd skattskyldighet (2614/2645).')
lines.push(' - Bygg i Sverige: omvänd skattskyldighet enligt ML 1 kap 2 § 1 st 4b.')
lines.push(' - Tredje land: import-moms via Tullverket eller deklareras via momsdeklaration ruta 60-62.')
lines.push('2. Avvikelse mot tidigare fakturor från samma leverantör? Beloppen i ungefär samma härad?')
lines.push('3. Saknas obligatoriska fält (ML 17 kap 24§): fakturanummer, datum, org.nr, moms-belopp, VAT-id vid reverse charge?')
lines.push('4. Föreslå rätt BAS-konto för varje rad (eller för hela fakturan om bara en summarad finns). Följ leverantörens historik: gnubok_query_journal({ text: "<leverantörens namn>", source_type: "supplier_invoice", limit: 5 }): när du väljer konto.')
lines.push('5. Om du är säker, staga attestering via gnubok_approve_supplier_invoice. Annars: peka på vad som ska klargöras innan attestering.')
lines.push('2. Valuta: bokföringen sker i SEK. Är fakturan i utländsk valuta är det SEK-beloppet ovan som bokförs, aldrig det nominella beloppet i främmande valuta. Saknas SEK-beloppet (ingen växelkurs lagrad): attestera inte, be användaren fylla i växelkursen först. Ett belopp utan angiven valuta är inte ett SEK-belopp: reda ut valutan innan du räknar på det.')
lines.push('3. Avvikelse mot tidigare fakturor från samma leverantör? Beloppen i ungefär samma härad? Jämför i SEK när valutorna skiljer sig åt: en jämförelse mellan olika valutor säger ingenting.')
lines.push('4. Saknas obligatoriska fält (ML 17 kap 24§): fakturanummer, datum, org.nr, moms-belopp, VAT-id vid reverse charge?')
lines.push('5. Föreslå rätt BAS-konto för varje rad (eller för hela fakturan om bara en summarad finns). Följ leverantörens historik: gnubok_query_journal({ text: "<leverantörens namn>", source_type: "supplier_invoice", limit: 5 }): när du väljer konto.')
lines.push('6. Om du är säker, staga attestering via gnubok_approve_supplier_invoice. Annars: peka på vad som ska klargöras innan attestering.')
lines.push('')
lines.push('Svara på svenska, var direkt och konkret. Ditt första svar är det första användaren ser.')
return lines.join('\n')
+67 -19
View File
@@ -1,6 +1,7 @@
import { defineAgentIntent } from './types'
import { SONNET_MODEL, THINKING_BUDGET_STANDARD } from '@/lib/agent/composer/client'
import { renderAgentGroundRules } from './shared-rules'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
// verifikation.draft: "Fråga om denna verifikation" on the journal entry
// creation/draft surfaces (Bokföring → "Skapa med assistent", the Ny
@@ -34,9 +35,14 @@ interface CapturedVerifikationDraft {
credit_amount: number | null
description: string | null
}[]
// Lock state for the entry's date, resolved through the canonical
// resolvePeriodStatusForDate (company-wide bookkeeping_locked_through +
// the covering fiscal_period's is_closed/locked_at): the same two layers the
// DB triggers enforce. `'unknown'` means the lookup failed and the agent must
// fail closed rather than treat the period as open.
period_status: {
period_id: string | null
status: string | null
status: 'open' | 'locked' | 'closed' | 'unknown' | null
lock_date: string | null
} | null
description_hint: string | null
@@ -118,27 +124,51 @@ export const verifikationDraft = defineAgentIntent<
description: ((e as { description?: string | null }).description) ?? null,
status: ((e as { status?: string | null }).status) ?? null,
}
// The per-line free text lives in `line_description`; there is no
// `description` column on journal_entry_lines. Asking for one made
// PostgREST reject the whole select, so every entry looked like it had
// zero rader and the agent reasoned about the verifikation from nothing.
const { data: rows } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, description')
.select('account_number, debit_amount, credit_amount, line_description')
.eq('journal_entry_id', journal_entry_id)
.order('id', { ascending: true })
lines = (rows ?? []) as CapturedVerifikationDraft['current_lines']
lines = ((rows ?? []) as {
account_number: string | null
debit_amount: number | null
credit_amount: number | null
line_description: string | null
}[]).map((r) => ({
account_number: r.account_number ?? null,
debit_amount: r.debit_amount ?? null,
credit_amount: r.credit_amount ?? null,
description: r.line_description ?? null,
}))
const entryDate = entry?.entry_date ?? null
if (entryDate) {
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, status, locked_through')
.eq('company_id', companyId)
.lte('period_start', entryDate)
.gte('period_end', entryDate)
.maybeSingle()
if (period) {
periodStatus = {
period_id: (period as { id: string }).id,
status: ((period as { status?: string | null }).status) ?? null,
lock_date: ((period as { locked_through?: string | null }).locked_through) ?? null,
}
// fiscal_periods has no `status`/`locked_through` columns: lock state
// is company_settings.bookkeeping_locked_through plus the period's
// is_closed/locked_at. Go through resolvePeriodStatusForDate so this
// surface reads the exact same source of truth as the DB triggers,
// the MCP server and the v1 REST period gate.
try {
const resolved = await resolvePeriodStatusForDate(supabase, companyId, entryDate)
// A soft lookup failure surfaces as { status: 'locked',
// lookup_failed: true }: the right fail-closed verdict for a write
// gate, but the wrong words for a human. Rendering it as a real
// lock produces "lås upp perioden under Räkenskapsår" advice for a
// lock that may not exist, so map it to the 'unknown' wording
// instead (bokslut-step handles its equivalent the same way).
periodStatus = resolved.lookup_failed
? { period_id: null, status: 'unknown', lock_date: null }
: {
period_id: resolved.period_id,
status: resolved.status,
lock_date: resolved.lock_date,
}
} catch {
// Fail closed: an unreadable period must never be presented as open.
periodStatus = { period_id: null, status: 'unknown', lock_date: null }
}
}
@@ -246,14 +276,32 @@ export const verifikationDraft = defineAgentIntent<
}
if (captured.period_status) {
const periodState = captured.period_status.status
lines.push('')
lines.push(
`Period: ${captured.period_status.period_id ?? '?'} (status ${captured.period_status.status ?? '?'}${
captured.period_status.lock_date ? `, låst t.o.m. ${captured.period_status.lock_date}` : ''
`Period: ${captured.period_status.period_id ?? '?'} (status ${periodState ?? '?'}${
captured.period_status.lock_date ? `, lås: ${captured.period_status.lock_date}` : ''
})`,
)
if (captured.period_status.status === 'locked' || captured.period_status.status === 'closed') {
if (periodState === 'closed') {
lines.push('PERIODEN ÄR STÄNGD: ingenting får bokföras på detta datum, och en stängd period kan inte låsas upp. Vägled användaren att flytta verifikationsdatumet till en öppen period.')
} else if (periodState === 'locked') {
lines.push('PERIODEN ÄR LÅST: ett utkast kan inte bokföras här. Vägled användaren att ändra verifikationsdatumet till en öppen period (utkast redigeras fritt), eller att låsa upp perioden under Bokföring → Räkenskapsår om datumet måste stå kvar.')
} else if (periodState === 'unknown') {
// Fail closed: the lock lookup failed, so we cannot claim the period is
// open. Posting into a locked period is blocked by DB triggers anyway;
// the agent must not promise the user that it will go through.
lines.push('PERIODLÅSET KUNDE INTE LÄSAS: utgå INTE från att perioden är öppen. Be användaren kontrollera periodlåset under Bokföring → Räkenskapsår innan något bokförs på detta datum.')
} else if (periodState === 'open' && captured.period_status.period_id === null) {
lines.push('INGET RÄKENSKAPSÅR TÄCKER DATUMET: periodlåset gick inte att avgöra. Kontrollera att verifikationsdatumet ligger inom ett upplagt räkenskapsår innan du föreslår bokföring.')
}
if (
captured.entry?.status === 'posted' &&
(periodState === 'locked' || periodState === 'closed' || periodState === 'unknown')
) {
// BFL 5 kap 5 §: inline rättelse is only legal in an open, unlocked
// period. Past a lock/close, storno is the only sanctioned path.
lines.push('Verifikationen är dessutom redan BOKFÖRD och perioden är inte bevisligen öppen: enligt BFL 5 kap 5 § är storno (omföring i en öppen period) då enda tillåtna rättelsevägen. Föreslå aldrig att rader, belopp eller datum skrivs om i det bokförda verifikatet.')
}
}
lines.push('')
+58
View File
@@ -794,6 +794,34 @@ describe('CreateSupplierInvoiceSchema', () => {
expect(result.success).toBe(false)
})
// The DB CHECK supplier_invoices_exchange_rate_check reads
// `exchange_rate IS NULL OR (exchange_rate > 0 AND exchange_rate < 100000)`.
// The schema had no ceiling at all, so a fat-fingered rate reached Postgres
// and came back as a 23514 the route surfaced as a 500. These three pin the
// mirror to the constraint, exclusivity included.
it('rejects an exchange rate at or above the DB ceiling of 100000', () => {
const result = CreateSupplierInvoiceSchema.safeParse(
validSupplierInvoice({ exchange_rate: 250000 })
)
expect(result.success).toBe(false)
const issue = result.error?.issues.find((i) => i.path.join('.') === 'exchange_rate')
expect(issue?.message).toContain('100 000')
})
it('rejects exactly 100000: the CHECK bound is exclusive', () => {
const result = CreateSupplierInvoiceSchema.safeParse(
validSupplierInvoice({ exchange_rate: 100000 })
)
expect(result.success).toBe(false)
})
it('accepts 99999.99, just inside the exclusive ceiling', () => {
const result = CreateSupplierInvoiceSchema.safeParse(
validSupplierInvoice({ exchange_rate: 99999.99 })
)
expect(result.success).toBe(true)
})
it('accepts item with legacy quantity/unit_price fields', () => {
const result = CreateSupplierInvoiceSchema.safeParse(
validSupplierInvoice({
@@ -1199,6 +1227,36 @@ describe('MatchInvoiceSchema', () => {
const result = MatchInvoiceSchema.safeParse({ invoice_id: 'INV-001' })
expect(result.success).toBe(false)
})
// manual_exchange_rate lands in invoice_payments.payment_exchange_rate,
// whose CHECK is `> 0 AND < 100000`. The old `.max(100000)` was inclusive:
// exactly 100000 passed Zod and then violated the constraint.
it('rejects exactly 100000 for manual_exchange_rate (exclusive CHECK)', () => {
const result = MatchInvoiceSchema.safeParse({
invoice_id: validUuid,
manual_exchange_rate: 100000,
})
expect(result.success).toBe(false)
})
it('accepts 99999.99 for manual_exchange_rate', () => {
const result = MatchInvoiceSchema.safeParse({
invoice_id: validUuid,
manual_exchange_rate: 99999.99,
})
expect(result.success).toBe(true)
})
it('rejects a zero or negative manual_exchange_rate', () => {
expect(MatchInvoiceSchema.safeParse({
invoice_id: validUuid,
manual_exchange_rate: 0,
}).success).toBe(false)
expect(MatchInvoiceSchema.safeParse({
invoice_id: validUuid,
manual_exchange_rate: -11.5,
}).success).toBe(false)
})
})
describe('MatchSupplierInvoiceSchema', () => {
+274
View File
@@ -0,0 +1,274 @@
import { describe, it, expect } from 'vitest'
import { z } from 'zod'
import { sparsePatch, sparsePatchBody, isPatchDocument } from '@/lib/api/sparse-patch'
import { validateBody } from '@/lib/api/validate'
import { createMockRequest } from '@/tests/helpers'
// Mirrors the real hazard shape: a create schema carrying .default() flags,
// turned into a patch schema with .partial().
const CreateLine = z.object({
description: z.string().min(1),
amount: z.number(),
quantity: z.number().optional(),
is_taxable: z.boolean().default(true),
is_net_deduction: z.boolean().default(false),
sort_order: z.number().int().default(0),
account_number: z.string().nullable().optional(),
tags: z.array(z.string()).default([]),
meta: z.object({ source: z.string().default('manual') }).default({ source: 'manual' }),
})
const UpdateLine = CreateLine.partial()
describe('the bug this helper exists for', () => {
it('.partial() does NOT strip .default(): a one-field parse resurrects every default', () => {
// This is the finding, pinned as an executable fact. If a future zod
// upgrade changes it, this test tells you the helper can be retired.
expect(UpdateLine.parse({ amount: 5500 })).toEqual({
amount: 5500,
is_taxable: true,
is_net_deduction: false,
sort_order: 0,
tags: [],
meta: { source: 'manual' },
})
})
})
describe('sparsePatch', () => {
it('a one-field patch does not resurrect defaults', () => {
const result = sparsePatch(UpdateLine, { amount: 5500 })
expect(result.success).toBe(true)
if (!result.success) return
expect(result.data).toEqual({ amount: 5500 })
expect(Object.keys(result.data)).toEqual(['amount'])
})
it('keeps a default-carrying field when the caller DID send it', () => {
const result = sparsePatch(UpdateLine, { is_taxable: false })
expect(result.success).toBe(true)
if (!result.success) return
expect(result.data).toEqual({ is_taxable: false })
})
it('an explicit null survives as a deliberate clear', () => {
const result = sparsePatch(UpdateLine, { account_number: null })
expect(result.success).toBe(true)
if (!result.success) return
expect(result.data).toEqual({ account_number: null })
expect('account_number' in result.data).toBe(true)
})
it('an absent key is untouched (never appears in the output)', () => {
const result = sparsePatch(UpdateLine, { description: 'Bonus' })
expect(result.success).toBe(true)
if (!result.success) return
expect('account_number' in result.data).toBe(false)
expect('is_taxable' in result.data).toBe(false)
expect('sort_order' in result.data).toBe(false)
})
it('null and absent are distinguishable, which is the whole point', () => {
const cleared = sparsePatch(UpdateLine, { account_number: null })
const untouched = sparsePatch(UpdateLine, {})
expect(cleared.success && cleared.data).toEqual({ account_number: null })
expect(untouched.success && untouched.data).toEqual({})
})
it('drops an in-process undefined (not expressible in JSON, never intended)', () => {
const result = sparsePatch(UpdateLine, { amount: 1, quantity: undefined })
expect(result.success).toBe(true)
if (!result.success) return
expect(result.data).toEqual({ amount: 1 })
expect('quantity' in result.data).toBe(false)
})
it('drops unknown keys rather than passing them to the caller', () => {
const result = sparsePatch(UpdateLine, { amount: 1, is_system: true, company_id: 'other' })
expect(result.success).toBe(true)
if (!result.success) return
expect(result.data).toEqual({ amount: 1 })
})
it('drops prototype-polluting own properties from a JSON body', () => {
const rawBody = JSON.parse('{"amount": 1, "__proto__": {"polluted": true}}')
const result = sparsePatch(UpdateLine, rawBody)
expect(result.success).toBe(true)
if (!result.success) return
expect(result.data).toEqual({ amount: 1 })
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
})
describe('nested objects', () => {
it('an absent nested key stays absent (its inner defaults do not materialise)', () => {
const result = sparsePatch(UpdateLine, { amount: 1 })
expect(result.success && 'meta' in result.data).toBe(false)
})
it('a supplied nested key is replaced wholesale, inner defaults included', () => {
// Shallow by design: the sink is `SET col = $1`, which replaces the whole
// jsonb value, so a half-merged object would write something the caller
// never described.
const result = sparsePatch(UpdateLine, { meta: {} })
expect(result.success).toBe(true)
if (!result.success) return
expect(result.data).toEqual({ meta: { source: 'manual' } })
})
it('reports validation errors with their full nested path', () => {
const result = sparsePatch(UpdateLine, { meta: { source: 42 } })
expect(result.success).toBe(false)
if (result.success) return
expect(result.error.issues[0].path).toEqual(['meta', 'source'])
})
})
describe('arrays', () => {
it('an array VALUE is taken wholesale', () => {
const result = sparsePatch(UpdateLine, { tags: ['a', 'b'] })
expect(result.success && result.data).toEqual({ tags: ['a', 'b'] })
})
it('an explicitly empty array is a real value, not an absence', () => {
const result = sparsePatch(UpdateLine, { tags: [] })
expect(result.success).toBe(true)
if (!result.success) return
expect(result.data).toEqual({ tags: [] })
expect('tags' in result.data).toBe(true)
})
it('an array BODY is rejected: a patch document must be a JSON object', () => {
const result = sparsePatch(UpdateLine, [{ amount: 1 }])
expect(result.success).toBe(false)
if (result.success) return
expect(result.error.issues[0].message).toContain('JSON object')
})
})
it.each([
['null', null],
['a scalar', 42],
['a string', 'amount=1'],
])('rejects %s as a body', (_label, body) => {
expect(sparsePatch(UpdateLine, body).success).toBe(false)
})
it('propagates schema validation failures unchanged', () => {
const result = sparsePatch(UpdateLine, { amount: 'not a number' })
expect(result.success).toBe(false)
if (result.success) return
expect(result.error.issues[0].path).toEqual(['amount'])
})
it('throws when the schema output is not an object (there is nothing to narrow)', () => {
const Reshaped = z.object({ a: z.number().optional() }).transform(() => null)
expect(() => sparsePatch(Reshaped, { a: 1 })).toThrow(/output is an object/)
})
it('works on a .superRefine()-wrapped schema (where .shape is unreachable)', () => {
const Refined = CreateLine.partial().superRefine((data, ctx) => {
if (data.amount === 13) {
ctx.addIssue({ code: 'custom', message: 'Olyckstal', path: ['amount'] })
}
})
expect(sparsePatch(Refined, { amount: 1 }).success && sparsePatch(Refined, { amount: 1 })).toMatchObject({
data: { amount: 1 },
})
const bad = sparsePatch(Refined, { amount: 13 })
expect(bad.success).toBe(false)
if (bad.success) return
expect(bad.error.issues[0].path).toEqual(['amount'])
})
})
describe('isPatchDocument', () => {
it.each([
[{}, true],
[{ a: 1 }, true],
[[], false],
[null, false],
[1, false],
['x', false],
[undefined, false],
])('%s -> %s', (input, expected) => {
expect(isPatchDocument(input)).toBe(expected)
})
})
describe('sparsePatchBody + validateBody', () => {
async function patch(body: unknown) {
const request = createMockRequest('/api/thing/1', { method: 'PATCH', body })
return validateBody(request, sparsePatchBody(UpdateLine))
}
it('yields only the sent keys through the validateBody pipeline', async () => {
const result = await patch({ amount: 5500 })
expect(result.success).toBe(true)
if (!result.success) return
expect(result.data).toEqual({ amount: 5500 })
})
it('keeps an explicit null through the validateBody pipeline', async () => {
const result = await patch({ account_number: null })
expect(result.success && result.data).toEqual({ account_number: null })
})
it('returns a 400 with the original field path on a schema failure', async () => {
const result = await patch({ amount: 'x' })
expect(result.success).toBe(false)
if (result.success) return
expect(result.response.status).toBe(400)
const body = await result.response.json()
expect(body.errors[0].field).toBe('amount')
})
// Forwarding through ctx.addIssue must not flatten the issue to `custom`:
// validateBody puts `code` in the 400 envelope and clients branch on it.
it('keeps the original issue CODE, not a flattened `custom`', async () => {
const result = await patch({ amount: 'x' })
expect(result.success).toBe(false)
if (result.success) return
const body = await result.response.json()
expect(body.errors[0].code).toBe('invalid_type')
expect(body.errors[0].code).not.toBe('custom')
})
it('forwards every issue, each with its own nested path and code', async () => {
const result = await patch({ meta: { source: 1 }, description: '' })
expect(result.success).toBe(false)
if (result.success) return
const body = await result.response.json()
expect(body.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: 'meta.source', code: 'invalid_type' }),
expect.objectContaining({ field: 'description', code: 'too_small' }),
]),
)
})
it('produces the same issues a plain validateBody(schema) would', async () => {
const bare = await validateBody(
createMockRequest('/api/thing/1', { method: 'PATCH', body: { amount: 'x' } }),
UpdateLine,
)
const sparse = await patch({ amount: 'x' })
expect(bare.success).toBe(false)
expect(sparse.success).toBe(false)
if (bare.success || sparse.success) return
expect(await sparse.response.json()).toEqual(await bare.response.json())
})
it('returns a 400 when the body is not a JSON object', async () => {
const result = await patch([1, 2])
expect(result.success).toBe(false)
if (result.success) return
expect(result.response.status).toBe(400)
})
it('an empty body parses to an empty patch, not to a pile of defaults', async () => {
const result = await patch({})
expect(result.success).toBe(true)
if (!result.success) return
expect(result.data).toEqual({})
})
})
+82 -1
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import { z } from 'zod'
import { validateBody, validateQuery } from '../validate'
import { getErrorMessage } from '@/lib/errors/get-error-message'
// ============================================================
// Helpers
@@ -74,13 +75,93 @@ describe('validateBody', () => {
if (!result.success) {
const body = await result.response.json()
expect(result.response.status).toBe(400)
expect(body.error).toBe('Validation failed')
// Inverted on purpose: the old assertion pinned the constant
// 'Validation failed', which is exactly the bug. `error` now carries the
// actionable field message so clients that read only `error` show
// something useful; `errors[]` keeps the full machine-readable detail and
// `type` stays the discriminator.
expect(body.error).not.toBe('Validation failed')
expect(body.error).toMatch(/^Valideringsfel: /)
expect(body.error).toContain('name')
expect(body.error).toContain('age')
expect(body.type).toBe('validation_error')
expect(body.errors).toBeInstanceOf(Array)
expect(body.errors.length).toBeGreaterThan(0)
}
})
it('summarizes at most three issues and counts the rest', async () => {
const WideSchema = z.object({
a: z.string(),
b: z.string(),
c: z.string(),
d: z.string(),
e: z.string(),
})
const request = createJsonRequest({})
const result = await validateBody(request, WideSchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = await result.response.json()
expect(body.errors).toHaveLength(5)
expect(body.error).toContain('(+2 till)')
// Only the first three are named in the prose.
expect(body.error).not.toContain('d:')
expect(body.error).not.toContain('e:')
}
})
it('surfaces the actionable Swedish sentence to a client that reads only body.error', async () => {
// Reproduces the failing UI path: pages like arsredovisning/page.tsx and
// assets/[id]/dispose/page.tsx forward `body.error` (a string) into
// getErrorMessage, which collapsed the constant into "Något gick fel."
const SwedishSchema = z.object({
period_start: z.string({ message: 'Ange periodens startdatum' }),
})
const request = await validateBody(createJsonRequest({}), SwedishSchema)
expect(request.success).toBe(false)
if (!request.success) {
const body = await request.response.json()
const shown = getErrorMessage(body.error, { statusCode: 400 })
expect(shown).toBe(body.error)
expect(shown).toContain('Ange periodens startdatum')
expect(shown).not.toBe('Något gick fel. Försök igen.')
// Clients that forward the whole body keep the existing errors[] path.
expect(getErrorMessage(body, { statusCode: 400 })).toContain(
'Ange periodens startdatum',
)
}
})
it('keeps the machine-readable discriminator and per-field detail intact', async () => {
const request = createJsonRequest({ name: '', age: -5 })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = await result.response.json()
// A machine consumer branches on `type` / `errors[].code`, never on prose.
// Both are pinned to concrete values here: asserting only `typeof` would
// keep passing if the codes turned into empty strings.
expect(body.type).toBe('validation_error')
for (const issue of body.errors as Array<Record<string, unknown>>) {
expect(typeof issue.field).toBe('string')
expect(typeof issue.message).toBe('string')
expect(typeof issue.code).toBe('string')
}
// Field-keyed lookup, the way a client maps issues onto form inputs.
const byField = new Map(
(body.errors as Array<{ field: string; code: string }>).map((e) => [e.field, e.code]),
)
expect(byField.get('name')).toBe('too_small')
expect(byField.get('age')).toBe('too_small')
}
})
it('returns field paths in error details', async () => {
const request = createJsonRequest({ name: 'Alice', age: 'not-a-number' })
const result = await validateBody(request, TestSchema)
+106 -8
View File
@@ -35,6 +35,33 @@ const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactl
/** Non-negative monetary amount (>= 0) */
const nonNegativeAmount = z.number().nonnegative()
/**
* SEK per one unit of a foreign currency.
*
* Mirrors the database CHECK that every table storing a rate carries:
* `invoices_exchange_rate_check`, `supplier_invoices_exchange_rate_check`,
* `invoice_payments_payment_exchange_rate_check` and
* `supplier_invoice_payments_payment_exchange_rate_check` all read
* `rate IS NULL OR (rate > 0 AND rate < 100000)`. BOTH bounds are exclusive,
* so this mirror is `.positive()` + `.lt(100000)`; `.max(100000)` would let
* exactly 100000 through the schema and straight into a 23514 violation.
*
* Without the mirror a plausible fat-fingered rate (250000, a pasted total
* instead of a rate) passed validation, hit the constraint in Postgres, and
* surfaced as an unexplained 500. Through this primitive it lands in
* `validateBody`'s 400 with a message naming the field and the fix.
*
* The ceiling is a typo guard rather than a precise band: no currency the app
* supports comes near it (USD ~10.5, EUR ~11.5, GBP ~13.5).
*/
const exchangeRate = z
.number()
.positive('Växelkursen måste vara större än 0')
.lt(
100000,
'Växelkursen måste vara mindre än 100 000. Ange kursen per 1 enhet av valutan, till exempel 11,45 för EUR, inte fakturans belopp.',
)
const invoiceEmailAddress = z
.string()
.trim()
@@ -660,10 +687,16 @@ export const CreateSelfBillingInvoiceSchema = z.object({
// Recurring invoice schedule schemas
// ============================================================
// Swedish VAT rates per ML 17 kap 24§ p.9: null means "use customer default
// from getAvailableVatRates". Any other value would produce a non-compliant
// invoice (buyer cannot deduct ingående moms). Cron-time validation against
// the customer's allowed set still runs in executeRecurringSchedule.
// Swedish VAT rates per ML 17 kap 24§ p.9. null means "use the customer's
// default rate" (getAvailableVatRates), which is 0% for a VAT-validated EU
// business or an export customer: huvudregeln, ML 6 kap. 34 §, taxes a B2B
// service where the buyer is established. An explicit 25/12/6 is still lawful
// for those customers when the supply is taxed where it is performed
// (fastighetstjänst, persontransport, korttidsuthyrning, restaurang/catering,
// admission to cultural and sports events), so cron-time validation in
// executeRecurringSchedule gates on getPermittedVatRates, not on the default.
// A rate outside 0/6/12/25 is rejected here: there is no such Swedish rate, and
// the buyer could not deduct ingående moms on it.
export const RecurringScheduleItemSchema = z.object({
description: z.string().min(1, 'Item description is required'),
quantity: z.number().positive('Quantity must be positive'),
@@ -814,7 +847,17 @@ export const UpdateCustomerSchema = z.object({
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
personal_number: z.string().regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, 'Invalid personal number').nullable().optional(),
// Plaintext personnummer (validated here, then encrypted by the route), or
// the masked form '********-1234' that every read path returns. The route
// reads the mask as "leave the stored value alone" and never stores it, so
// a client echoing back what it read cannot wipe the personnummer.
// CreateCustomerSchema stays strict: on create there is no stored value to
// preserve, so a mask there is a client error and earns a 400.
personal_number: z
.string()
.regex(/^(?:(\d{6}|\d{8})[-+]?\d{4}|\*{8}-\d{4})$/, 'Invalid personal number')
.nullable()
.optional(),
language: z.enum(['sv', 'en']).optional(),
default_payment_terms: z.number().int().positive().optional(),
notes: z.string().optional(),
@@ -929,7 +972,9 @@ export const CreateSupplierInvoiceSchema = z.object({
due_date: isoDate,
delivery_date: optionalIsoDate,
currency: CurrencySchema.optional(),
exchange_rate: z.number().positive().optional(),
// Bounded by the shared `exchangeRate` primitive so the value can never
// reach `supplier_invoices_exchange_rate_check` and come back as a 500.
exchange_rate: exchangeRate.optional(),
vat_treatment: VatTreatmentSchema.optional(),
reverse_charge: z.boolean().optional(),
payment_reference: z.string().optional(),
@@ -1347,13 +1392,16 @@ export const MatchInvoiceSchema = z
// settlement. Used when the Riksbanken lookup returns nothing (rate not
// published for that date): the dialog surfaces an input so the user can
// type the rate from their bank statement. Ignored when tx.currency ===
// invoice.currency. The .max() is a sanity ceiling against pasted garbage /
// invoice.currency. The ceiling is a sanity guard against pasted garbage /
// scientific-notation input silently corrupting the FX-diff posting and
// invoice_payments.amount: no supported currency's SEK rate approaches it
// (USD~10.5, EUR~11.5, GBP~13.5). It is a guard rail, not a precise band;
// the dialog's live preview (paid_in_invoice_currency + FX gain/loss) is
// what catches a plausible-but-wrong decimal-shift typo before confirm.
manual_exchange_rate: z.number().positive().max(100000).optional(),
// It used to be `.max(100000)`, an inclusive ceiling against an exclusive
// `payment_exchange_rate < 100000` CHECK: exactly 100000 passed Zod and
// died in Postgres. The shared primitive is exclusive on both ends.
manual_exchange_rate: exchangeRate.optional(),
})
.refine((v) => !v.force || !!v.expected_journal_entry_id, {
message: 'expected_journal_entry_id is required when force=true',
@@ -2492,6 +2540,23 @@ export const UpdateEmployeeSchema = EmployeeSchemaPatchBase.partial().superRefin
export const EmployeeBenefitTypeSchema = z.enum(['bike', 'car', 'meals', 'housing', 'wellness', 'other'])
/**
* Mirrors the table-level CHECK on employee_benefits (migration
* 20260512200100_employee_benefits.sql):
*
* CHECK (valid_to IS NULL OR valid_to >= valid_from)
*
* The bound is INCLUSIVE (`>=`): valid_to === valid_from is a legal single-day
* benefit, and the run-calculation window is inclusive at both ends too
* (`valid_from <= payment_date` AND `valid_to IS NULL OR valid_to >=
* payment_date`, lib/salary/run-calculation.ts). A NULL/omitted valid_to means
* an open-ended benefit and stays legal. Only a strictly earlier valid_to is
* rejected. Shared with the routes so the schema 400 and the route's
* merged-state 400 say the same thing.
*/
export const BENEFIT_PERIOD_ORDER_MESSAGE =
'"Gäller till" måste vara samma dag som eller efter "Gäller från". Lämna fältet tomt för en löpande förmån.'
export const CreateEmployeeBenefitSchema = z.object({
benefit_type: EmployeeBenefitTypeSchema,
description: z.string().min(1).max(200),
@@ -2519,6 +2584,20 @@ export const CreateEmployeeBenefitSchema = z.object({
path: ['monthly_value'],
})
}
// Validity period: exact mirror of the DB CHECK (see
// BENEFIT_PERIOD_ORDER_MESSAGE). Both dates are always fully visible on a
// create, so the whole constraint is checkable here and the insert can no
// longer trip the CHECK and surface as an opaque 500. ISO YYYY-MM-DD strings
// order lexicographically the same as chronologically, so a plain `<` is
// exact; `=== undefined` keeps the open-ended case legal.
if (data.valid_to !== undefined && data.valid_to < data.valid_from) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: BENEFIT_PERIOD_ORDER_MESSAGE,
path: ['valid_to'],
})
}
})
export const UpdateEmployeeBenefitSchema = z.object({
@@ -2529,6 +2608,25 @@ export const UpdateEmployeeBenefitSchema = z.object({
valid_to: isoDate.nullable().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
is_active: z.boolean().optional(),
}).superRefine((data, ctx) => {
// Same DB CHECK mirror as the create schema, with the .partial() caveat: an
// all-optional body only lets the schema compare the two dates when it
// carries BOTH. A single-date PATCH has nothing in-body to compare against
// (the other half lives on the stored row), so the route re-checks the merged
// stored+patched pair before it writes. `valid_to: null` clears the end date
// and stays legal, exactly as `valid_to IS NULL` is in the CHECK.
if (
data.valid_from !== undefined &&
data.valid_to !== undefined &&
data.valid_to !== null &&
data.valid_to < data.valid_from
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: BENEFIT_PERIOD_ORDER_MESSAGE,
path: ['valid_to'],
})
}
})
export const CreateSalaryRunSchema = z.object({
+196
View File
@@ -0,0 +1,196 @@
/**
* Sparse PATCH parsing: keep only the fields the caller actually sent.
*
* ## The problem
*
* Zod's `.partial()` makes every key optional; it does NOT strip `.default()`.
* Verified against the zod version in this repo (4.4.3):
*
* ```
* const Base = z.object({ amount: z.number(), is_taxable: z.boolean().default(true) })
* Base.partial().parse({ amount: 5500 })
* // -> { amount: 5500, is_taxable: true } <- is_taxable was NEVER sent
* ```
*
* Routes that spread that result into `.update()` therefore reset every
* defaulted column on every PATCH: naming one field silently rewrites the
* others. The repo hit this three times independently (an
* `EmployeeSchemaPatchBase` re-declaration in lib/api/schemas.ts and a
* hand-rolled `rawKeys` intersection in three v1 routes). This module is the
* shared version of the `rawKeys` intersection, which is the one that
* generalises: it needs nothing from the schema's internals, so it works on
* `.partial()`, `.omit()`, and `.superRefine()`-wrapped schemas alike, and it
* cannot drift the way a hand-maintained duplicate base shape does.
*
* ## Semantics
*
* The output contains exactly the keys that are BOTH (a) literally present as
* own properties of the raw JSON body and (b) known to the schema.
*
* | raw body | in output? |
* |-------------------------|-------------------------------------------------|
* | `{ "amount": 5500 }` | `amount: 5500` only. Defaults never materialise. |
* | `{ "note": null }` | `note: null`. An explicit null is a deliberate |
* | | clear and MUST survive: null-vs-absent is the |
* | | entire point of this helper. |
* | key absent | absent. The column is left untouched. |
* | `{ "q": undefined }` | dropped. `undefined` is not expressible in JSON, |
* | | so a caller can never have meant it; inventing a |
* | | `null` would clear a column nobody asked to |
* | | clear. (Only reachable from in-process callers.) |
* | unknown key | dropped. The intersection runs over the schema's |
* | | parsed output, so unknown keys cannot reach the |
* | | database (mass-assignment defense). |
* | `{ "tags": [] }` | `tags: []`. An array VALUE is a value: it is |
* | | taken wholesale, empty array included. |
* | body is an array/scalar | rejected. A patch document must be a JSON object.|
*
* **Shallow by design.** A key whose value is an object is replaced wholesale,
* including any `.default()` Zod filled in *inside* that object. This is
* correct for the intended sink: `UPDATE ... SET col = $1` replaces a whole
* jsonb column, so a "partially patched" nested object would write a value the
* caller never described. If a nested field genuinely needs per-key merge
* semantics, merge it against the stored row in the route, explicitly.
*
* Validation itself is unchanged: the schema parses the raw body first, so
* refinements still see the default-filled object exactly as before. Only the
* *write set* is narrowed.
*
* ## When NOT to use this
*
* The narrowing happens AFTER parsing, so it only helps a sink that writes the
* keys it is handed. Two shapes it does not fix:
*
* 1. **A fixed-column sink** (an upsert that writes a whole row or a whole
* jsonb document). Narrowing the patch does not stop the unmentioned columns
* from being written; it only makes them `undefined`. Such a route must merge
* the sparse patch over the STORED row before writing (see
* `app/api/kpi/preferences/route.ts`).
* 2. **A CROSS-FIELD `.refine` / `.superRefine`.** The refinement runs on the
* default-filled parse, so it judges values the caller never sent: it can
* reject a legitimate patch and accept an illegitimate one. Strip the
* defaults from the patch base instead (see `EmployeeSchemaPatchBase` in
* `lib/api/schemas.ts`), or validate against the stored row in the route.
*
* A schema whose own top-level `.transform()` reshapes the output is also out of
* scope: the intersection runs against the raw body's keys, so invented keys are
* dropped. Narrow before transforming, not after.
*
* ## Usage
*
* With `validateBody` (cookie-session routes):
* ```ts
* const validation = await validateBody(request, sparsePatchBody(UpdateThingSchema))
* if (!validation.success) return validation.response
* if (Object.keys(validation.data).length === 0) { ... nothing to update ... }
* ```
*
* With a raw body already in hand (v1 REST / MCP):
* ```ts
* const patch = sparsePatch(UpdateThingSchema, rawBody)
* if (!patch.success) return v1ErrorResponseFromCode('VALIDATION_ERROR', ...)
* ```
*/
import { z } from 'zod'
/**
* Own properties that `JSON.parse` can produce but that must never be carried
* into an object literal. `parsed.data` (Zod's output) can never contain them,
* so the intersection already blocks them; filtering the present-key set too
* makes the intent unambiguous. Mirrors the guard the v1 routes already ship.
*/
const POLLUTING_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
export type SparsePatchResult<T> =
| { success: true; data: Partial<T> }
| { success: false; error: z.ZodError }
/** A patch document is a plain JSON object: not null, not an array, not a scalar. */
export function isPatchDocument(raw: unknown): raw is Record<string, unknown> {
return typeof raw === 'object' && raw !== null && !Array.isArray(raw)
}
/** Own enumerable keys of a raw JSON body, minus the prototype-polluting ones. */
function presentKeys(rawBody: Record<string, unknown>): Set<string> {
return new Set(Object.keys(rawBody).filter((key) => !POLLUTING_KEYS.has(key)))
}
/**
* Parse `rawBody` with `schema`, then keep only the keys the caller literally
* sent. See the module docblock for the exact null-vs-absent semantics.
*/
export function sparsePatch<S extends z.ZodType>(
schema: S,
rawBody: unknown,
): SparsePatchResult<z.infer<S>> {
if (!isPatchDocument(rawBody)) {
return {
success: false,
error: new z.ZodError([
{
code: 'invalid_type',
expected: 'object',
input: rawBody,
path: [],
message: 'Body must be a JSON object.',
},
]),
}
}
const parsed = schema.safeParse(rawBody)
if (!parsed.success) {
return { success: false, error: parsed.error }
}
// A schema whose output is not an object has no keys to narrow, so the caller
// has misused the helper. Fail loudly instead of throwing an opaque
// "Cannot convert undefined or null to object" out of Object.entries.
if (!isPatchDocument(parsed.data)) {
throw new TypeError(
'sparsePatch requires a schema whose output is an object; got ' + typeof parsed.data,
)
}
const present = presentKeys(rawBody)
const data: Record<string, unknown> = {}
// Iterate the PARSED output, not the raw body: unknown keys the schema
// stripped must not reappear, and the values must be the coerced/validated
// ones. `value !== undefined` drops keys that survived parsing without a
// value (see the table above); an explicit `null` is kept.
for (const [key, value] of Object.entries(parsed.data)) {
if (present.has(key) && value !== undefined) {
data[key] = value
}
}
return { success: true, data: data as Partial<z.infer<S>> }
}
/**
* Wrap a schema so `validateBody()` yields the sparse patch instead of the
* default-filled object. Every validation issue is forwarded with its original
* `code` and `path`, so the 400 envelope is byte-identical to a plain
* `validateBody(request, schema)` (pinned by a test that compares the two).
*/
export function sparsePatchBody<S extends z.ZodType>(
schema: S,
): z.ZodType<Partial<z.infer<S>>, unknown> {
return z.unknown().transform((raw, ctx) => {
const result = sparsePatch(schema, raw)
if (!result.success) {
// Forwarded by spread, not by passing `issue` straight through: Zod 4
// types `addIssue` against the RAW issue shape, and a finalized
// `$ZodIssue` is an interface, so it gets no implicit index signature and
// will not satisfy that shape. A spread produces an object literal type,
// which does, so the original `code` and `path` survive into the 400
// envelope with no cast and without flattening to `custom`.
for (const issue of result.error.issues) {
ctx.addIssue({ ...issue })
}
return z.NEVER
}
return result.data
})
}
+13 -1
View File
@@ -81,7 +81,19 @@ describe('nextCursorFromPage', () => {
]
const cursor = nextCursorFromPage(rows, 2)
expect(cursor).not.toBeNull()
expect(decodeDefaultCursor(cursor)).toEqual({ ts: '2026-01-03T00:00:00Z', id: ID_C })
// The cursor is the LAST row of the trimmed page (rows[limit - 1]), never
// rows[limit]: routes paginate with strictly-greater/less predicates, so
// encoding the first row of the next page would skip it at the boundary.
expect(decodeDefaultCursor(cursor)).toEqual({ ts: '2026-01-02T00:00:00Z', id: ID_B })
})
it('with limit=1 the cursor is the single returned row, so the next page starts at row 2', () => {
const rows = [
row(ID_A, '2026-01-01T00:00:00Z'),
row(ID_B, '2026-01-02T00:00:00Z'),
]
const cursor = nextCursorFromPage(rows, 1)
expect(decodeDefaultCursor(cursor)).toEqual({ ts: '2026-01-01T00:00:00Z', id: ID_A })
})
})
@@ -0,0 +1,472 @@
/**
* Regression suite for the documented v1 "preview, then commit" flow.
*
* The contract in `lib/api/v1/dry-run.ts` tells integrators to commit by
* re-issuing the SAME request without `dry_run=true`, carrying the SAME
* `Idempotency-Key`. That made the preview and the commit indistinguishable to
* the idempotency layer (same method, same path, same body): the preview was
* cached under the commit's hash and the commit replayed it. 200 OK, header
* `Idempotent-Replayed: true`, `{ dry_run: true, preview }` in the body, and
* nothing written. An agent reading the status code reported success.
*
* These tests exercise the wrapper against an in-memory stand-in for the
* `idempotency_keys` table, because the bug only appears when a real store
* carries state from one request to the next: a per-call `vi.fn()` that always
* returns null can never reproduce it.
*
* Mocking follows `tests/helpers.ts` conventions, but the v1 surface never
* touches `@/lib/supabase/server`: it is API-key authenticated and runs on
* `createServiceClientNoCookies()` from `@/lib/auth/api-keys`, so that is what
* is stubbed here (same as `with-api-v1.test.ts`).
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
beforeAll(() => {
// The wrapper's public-scope path fails closed without these; stubbed only
// to clear the guard (no Supabase instance is contacted).
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return {
...actual,
createClient: vi.fn().mockReturnValue({}),
}
})
vi.mock('@/lib/api/idempotency', async () => {
const actual = await vi.importActual<typeof import('@/lib/api/idempotency')>(
'@/lib/api/idempotency',
)
return {
...actual,
checkIdempotencyKey: vi.fn(),
storeIdempotencyResponse: vi.fn(),
}
})
import { createServiceClientNoCookies, validateApiKey } from '@/lib/auth/api-keys'
import {
checkIdempotencyKey,
IdempotencyKeyReuseError,
storeIdempotencyResponse,
} from '@/lib/api/idempotency'
import { withApiV1 } from '../with-api-v1'
import { dryRunPreview } from '../dry-run'
import { created } from '../response'
import { registerEndpoint } from '../registry'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockCheckIdempotency = checkIdempotencyKey as ReturnType<typeof vi.fn>
const mockStoreIdempotency = storeIdempotencyResponse as ReturnType<typeof vi.fn>
const COMPANY_ID = 'company-1'
const USER_ID = 'user-1'
const INVOICES_URL = `https://x.test/api/v1/companies/${COMPANY_ID}/invoices`
const REQUEST_BODY = { customer_id: 'cust-1', amount: 1250 }
// The wrapper reads `dryRunSupported` off the registry when a TEST key writes.
// Registering the pattern here lets the forced-dry-run path run instead of
// short-circuiting to TEST_KEY_WRITE_BLOCKED. Vitest isolates module state per
// test file, so this registration is invisible to the rest of the suite.
registerEndpoint({
operation: 'invoices.create',
method: 'POST',
path: '/api/v1/companies/:companyId/invoices',
summary: 'Create an invoice (test fixture).',
description: 'Fixture registration used by the dry-run idempotency tests.',
useWhen: 'never: test fixture',
doNotUseFor: 'anything outside this test file',
pitfalls: [],
example: { response: {} },
scope: 'invoices:write',
risk: 'medium',
idempotent: false,
reversible: true,
dryRunSupported: true,
response: { success: z.object({}) },
})
function makeSupabaseStub(membership: { company_id: string; role: string } | null) {
return {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
maybeSingle: vi.fn().mockResolvedValue({ data: membership, error: null }),
}),
}),
}),
}),
}
}
function keyAuth(mode: 'live' | 'test') {
return {
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: mode === 'test' ? 'ak_test' : 'ak_live',
apiKeyName: `${mode} key`,
scopes: ['invoices:write'],
mode,
}
}
interface StoredRow {
requestHash: string
status: 'success' | 'error'
body: Record<string, unknown>
}
/**
* In-memory stand-in for the `idempotency_keys` table. Reproduces the three
* behaviours the wrapper depends on:
* - rows are scoped by (user_id, company_id, key)
* - a stored row whose request_hash differs raises IdempotencyKeyReuseError
* - insert is first-writer-wins (the unique index swallows the loser)
*/
function installIdempotencyTable(): Map<string, StoredRow> {
const rows = new Map<string, StoredRow>()
const rowKey = (userId: string, companyId: string, key: string) =>
`${userId}::${companyId}::${key}`
mockCheckIdempotency.mockImplementation(
async (
_supabase: unknown,
userId: string,
companyId: string,
key: string,
requestHash: string,
) => {
const row = rows.get(rowKey(userId, companyId, key))
if (!row) return null
if (row.requestHash !== requestHash) throw new IdempotencyKeyReuseError(key)
return { status: row.status, body: row.body }
},
)
mockStoreIdempotency.mockImplementation(
async (
_supabase: unknown,
userId: string,
companyId: string,
key: string,
requestHash: string,
status: 'success' | 'error',
body: Record<string, unknown>,
) => {
const k = rowKey(userId, companyId, key)
if (rows.has(k)) return
rows.set(k, { requestHash, status, body })
},
)
return rows
}
/**
* A write route shaped like the real ones: previews when `ctx.dryRun`, and
* otherwise performs the side-effect. `committed` is the observable proof that
* the write actually happened, which is the whole point of this suite.
*/
function makeInvoiceRoute() {
const committed: string[] = []
const previews: string[] = []
let seq = 0
const route = withApiV1<{ params: Promise<{ companyId: string }> }>(
'invoices.create',
async (request, ctx) => {
const body = (await request.json()) as { customer_id: string }
if (ctx.dryRun) {
previews.push(body.customer_id)
return dryRunPreview(
{ customer_id: body.customer_id },
{ requestId: ctx.requestId, log: ctx.log },
)
}
seq += 1
const id = `inv-${seq}`
committed.push(id)
return created({ id, customer_id: body.customer_id }, { requestId: ctx.requestId })
},
{ requireScope: 'invoices:write' },
)
return { route, committed, previews }
}
function companyParams(companyId: string) {
return { params: Promise.resolve({ companyId }) }
}
function postInvoice(opts: {
key?: string
dryRunQuery?: boolean
/** Raw value for the ?dry_run= query param (case-sensitivity matrix). */
dryRunQueryValue?: string
dryRunHeader?: boolean
}): Request {
const headers: Record<string, string> = {
Authorization: 'Bearer gnubok_sk_x',
'Content-Type': 'application/json',
}
if (opts.key) headers['Idempotency-Key'] = opts.key
if (opts.dryRunHeader) headers['X-Dry-Run'] = 'true'
const url =
opts.dryRunQueryValue !== undefined
? `${INVOICES_URL}?dry_run=${opts.dryRunQueryValue}`
: opts.dryRunQuery
? `${INVOICES_URL}?dry_run=true`
: INVOICES_URL
return new Request(url, {
method: 'POST',
headers,
body: JSON.stringify(REQUEST_BODY),
})
}
beforeEach(() => {
vi.clearAllMocks()
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: COMPANY_ID, role: 'owner' }))
mockValidate.mockResolvedValue(keyAuth('live'))
installIdempotencyTable()
})
describe('withApiV1: dry-run then commit on the same Idempotency-Key', () => {
// THE missing test. Before the fix this returned the cached preview with
// Idempotent-Replayed: true and `committed` stayed empty.
it('commits for real after a dry-run preview issued under the same key', async () => {
const { route, committed, previews } = makeInvoiceRoute()
const preview = await route(
postInvoice({ key: 'key-1', dryRunQuery: true }),
companyParams(COMPANY_ID),
)
expect(preview.status).toBe(200)
expect(preview.headers.get('X-Dry-Run')).toBe('true')
expect(preview.headers.get('Idempotent-Replayed')).toBeNull()
const previewBody = await preview.json()
expect(previewBody.data.dry_run).toBe(true)
expect(previews).toEqual(['cust-1'])
expect(committed).toEqual([])
const commit = await route(postInvoice({ key: 'key-1' }), companyParams(COMPANY_ID))
expect(commit.status).toBe(201)
expect(commit.headers.get('Idempotent-Replayed')).toBeNull()
const commitBody = await commit.json()
expect(commitBody.data.id).toBe('inv-1')
expect(commitBody.data.dry_run).toBeUndefined()
expect(committed).toEqual(['inv-1'])
})
it('never writes the dry-run response into the idempotency cache', async () => {
const { route } = makeInvoiceRoute()
await route(postInvoice({ key: 'key-2', dryRunQuery: true }), companyParams(COMPANY_ID))
expect(mockStoreIdempotency).not.toHaveBeenCalled()
})
it('does not replay a repeated dry-run: each preview re-runs the handler', async () => {
const { route, committed, previews } = makeInvoiceRoute()
const first = await route(
postInvoice({ key: 'key-3', dryRunQuery: true }),
companyParams(COMPANY_ID),
)
const second = await route(
postInvoice({ key: 'key-3', dryRunQuery: true }),
companyParams(COMPANY_ID),
)
expect(first.headers.get('Idempotent-Replayed')).toBeNull()
expect(second.headers.get('Idempotent-Replayed')).toBeNull()
expect(second.status).toBe(200)
expect((await second.json()).data.dry_run).toBe(true)
expect(previews).toHaveLength(2)
expect(committed).toEqual([])
})
// The dry-run flag is part of the request hash, so a key that already
// committed for real cannot be re-used for a simulation: that would hand back
// a committed result wearing a preview's clothes. 409 is the honest answer.
it('rejects a dry-run that re-uses a key which already committed', async () => {
const { route, committed } = makeInvoiceRoute()
await route(postInvoice({ key: 'key-4' }), companyParams(COMPANY_ID))
expect(committed).toEqual(['inv-1'])
const res = await route(
postInvoice({ key: 'key-4', dryRunQuery: true }),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('IDEMPOTENCY_KEY_REUSE')
expect(committed).toEqual(['inv-1'])
})
})
describe('withApiV1: idempotent replay of real commits (must not regress)', () => {
// Also the lockstep guard for the two hashRequest call sites: the lookup only
// finds what the store wrote if both hash the same input. Diverge them and
// this test fails with a second write instead of a replay.
it('replays the second of two identical commits under the same key', async () => {
const { route, committed } = makeInvoiceRoute()
const first = await route(postInvoice({ key: 'key-5' }), companyParams(COMPANY_ID))
const second = await route(postInvoice({ key: 'key-5' }), companyParams(COMPANY_ID))
expect(first.status).toBe(201)
expect(first.headers.get('Idempotent-Replayed')).toBeNull()
expect(second.headers.get('Idempotent-Replayed')).toBe('true')
expect((await second.json()).data.id).toBe('inv-1')
expect(committed).toEqual(['inv-1'])
})
it('still rejects the same key carrying a different body', async () => {
const { route, committed } = makeInvoiceRoute()
await route(postInvoice({ key: 'key-6' }), companyParams(COMPANY_ID))
const changed = new Request(INVOICES_URL, {
method: 'POST',
headers: {
Authorization: 'Bearer gnubok_sk_x',
'Content-Type': 'application/json',
'Idempotency-Key': 'key-6',
},
body: JSON.stringify({ ...REQUEST_BODY, amount: 9999 }),
})
const res = await route(changed, companyParams(COMPANY_ID))
expect(res.status).toBe(409)
expect((await res.json()).error.code).toBe('IDEMPOTENCY_KEY_REUSE')
expect(committed).toEqual(['inv-1'])
})
})
describe('withApiV1: dry-run query flag is case-insensitive', () => {
// '?dry_run=True' used to fall through the exact-match check and COMMIT for
// real while the caller believed it previewed: the one direction this flag
// must never fail in.
it('previews on ?dry_run=True (mis-cased flag must never commit)', async () => {
const { route, committed, previews } = makeInvoiceRoute()
const res = await route(
postInvoice({ key: 'key-10', dryRunQueryValue: 'True' }),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
expect((await res.json()).data.dry_run).toBe(true)
expect(previews).toHaveLength(1)
expect(committed).toEqual([])
expect(mockStoreIdempotency).not.toHaveBeenCalled()
})
it('previews on ?dry_run=TRUE as well', async () => {
const { route, committed } = makeInvoiceRoute()
const res = await route(
postInvoice({ key: 'key-11', dryRunQueryValue: 'TRUE' }),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
expect((await res.json()).data.dry_run).toBe(true)
expect(committed).toEqual([])
})
it('still commits on non-true values (?dry_run=1, ?dry_run=false)', async () => {
const { route, committed, previews } = makeInvoiceRoute()
const one = await route(
postInvoice({ key: 'key-12', dryRunQueryValue: '1' }),
companyParams(COMPANY_ID),
)
const falsy = await route(
postInvoice({ key: 'key-13', dryRunQueryValue: 'false' }),
companyParams(COMPANY_ID),
)
expect(one.status).toBe(201)
expect(falsy.status).toBe(201)
expect(previews).toHaveLength(0)
expect(committed).toEqual(['inv-1', 'inv-2'])
})
})
describe('withApiV1: X-Dry-Run header parity with ?dry_run=true', () => {
it('previews on the header and still commits on the follow-up request', async () => {
const { route, committed, previews } = makeInvoiceRoute()
const preview = await route(
postInvoice({ key: 'key-7', dryRunHeader: true }),
companyParams(COMPANY_ID),
)
expect(preview.status).toBe(200)
expect((await preview.json()).data.dry_run).toBe(true)
expect(previews).toHaveLength(1)
expect(committed).toEqual([])
expect(mockStoreIdempotency).not.toHaveBeenCalled()
const commit = await route(postInvoice({ key: 'key-7' }), companyParams(COMPANY_ID))
expect(commit.status).toBe(201)
expect(commit.headers.get('Idempotent-Replayed')).toBeNull()
expect((await commit.json()).data.id).toBe('inv-1')
expect(committed).toEqual(['inv-1'])
})
it('does not replay a repeated header-driven dry-run', async () => {
const { route, previews } = makeInvoiceRoute()
await route(postInvoice({ key: 'key-8', dryRunHeader: true }), companyParams(COMPANY_ID))
const second = await route(
postInvoice({ key: 'key-8', dryRunHeader: true }),
companyParams(COMPANY_ID),
)
expect(second.headers.get('Idempotent-Replayed')).toBeNull()
expect(previews).toHaveLength(2)
})
})
describe('withApiV1: test keys (forced dry-run) never poison the cache', () => {
it('lets a live commit through on a key a test key already simulated', async () => {
const { route, committed, previews } = makeInvoiceRoute()
mockValidate.mockResolvedValueOnce(keyAuth('test'))
const simulated = await route(postInvoice({ key: 'key-9' }), companyParams(COMPANY_ID))
expect(simulated.status).toBe(200)
expect(simulated.headers.get('X-Gnubok-Mode')).toBe('test')
expect((await simulated.json()).data.dry_run).toBe(true)
expect(previews).toHaveLength(1)
expect(committed).toEqual([])
expect(mockStoreIdempotency).not.toHaveBeenCalled()
const commit = await route(postInvoice({ key: 'key-9' }), companyParams(COMPANY_ID))
expect(commit.status).toBe(201)
expect(commit.headers.get('Idempotent-Replayed')).toBeNull()
expect(committed).toEqual(['inv-1'])
})
})
+7 -1
View File
@@ -9,7 +9,13 @@
* normal success status (201, 204, etc.). A caller that sees `200`
* with `X-Dry-Run` knows the write was NOT committed.
* 3. Commit by re-issuing the same request without `dry_run=true`, passing
* the same `Idempotency-Key` to guarantee at-most-once semantics.
* the same `Idempotency-Key` to guarantee at-most-once semantics. The
* wrapper keeps the two apart: a dry-run response is never written to the
* idempotency cache, and the dry-run flag is folded into the request hash,
* so the commit executes for real instead of replaying the preview.
* Order matters. Once a key has committed for real, re-issuing it WITH
* `dry_run=true` is rejected as key reuse (409 IDEMPOTENCY_KEY_REUSE)
* rather than answered with the committed result dressed up as a preview.
*
* Two preview shapes are supported:
*
+9 -4
View File
@@ -111,14 +111,19 @@ export function decodeDefaultCursor(cursor: string | null | undefined): DefaultC
* cursor for the *next* page (or null when this was the final page).
*
* Convention: the caller fetches `limit + 1` rows, passes the full slice in,
* and we return either the cursor of row[limit] or null when the page wasn't
* full. The caller should then trim the slice to `limit` before returning it
* to the user.
* and we return either the cursor of the LAST ROW OF THE TRIMMED PAGE
* (rows[limit - 1]) or null when the page wasn't full. The caller should then
* trim the slice to `limit` before returning it to the user.
*
* Contract: the cursor marks the last row already returned; every v1 route's
* keyset predicate is strictly greater/less than the cursor, so encoding
* rows[limit] (the first row of the NEXT page) would skip that row at every
* page boundary.
*/
export function nextCursorFromPage<T extends { created_at: string; id: string }>(
rows: T[],
limit: number,
): string | null {
if (rows.length <= limit) return null
return encodeDefaultCursor(rows[limit])
return encodeDefaultCursor(rows[limit - 1])
}
+67 -10
View File
@@ -14,8 +14,11 @@
* 4. When the URL contains `companyId`, verifies the API key's user has
* access to that company via `company_members`. Multi-company keys are
* supported transparently: the URL is the source of truth.
* 5. Resolves `Idempotency-Key` (header) and replays cached responses.
* 6. Resolves the dry-run flag (`?dry_run=true` query OR `X-Dry-Run` header).
* 5. Resolves the dry-run flag (`?dry_run=true` query OR `X-Dry-Run` header).
* 6. Resolves `Idempotency-Key` (header) and replays cached responses. The
* dry-run flag is part of the cache identity and dry-run responses are
* never cached, so a simulation can never be replayed in place of the
* real write that follows it.
* 7. Invokes the handler with a typed RouteContext.
* 8. Stamps `X-Request-Id`, `Gnubok-Version`, `X-RateLimit-Limit` on the
* response.
@@ -198,12 +201,53 @@ function extractForensicContext(request: Request, log: Logger): { ip: string | u
}
function isDryRun(request: Request, url: URL): boolean {
if (url.searchParams.get('dry_run') === 'true') return true
// Case-insensitive on BOTH surfaces. The header was always lowercased, but
// the query flag used to require exactly 'true', so '?dry_run=True'
// committed for real while the caller believed it previewed: the worst
// possible parse of a preview flag. Any other value ('1', 'yes', 'false')
// stays non-dry-run, unchanged.
const queryVal = url.searchParams.get('dry_run')
if (queryVal !== null && queryVal.toLowerCase() === 'true') return true
const headerVal = request.headers.get(DRY_RUN_HEADER)
if (headerVal && headerVal.toLowerCase() === 'true') return true
return false
}
/**
* Canonical idempotency hash for a v1 request.
*
* `dryRun` is part of the identity of the request. The documented commit flow
* (see `dry-run.ts`) is "re-issue the exact same request without
* `dry_run=true`, same Idempotency-Key", so a simulation and the real write
* that follows it share method, path and body. Hashing only those three made
* the two indistinguishable: the preview got cached under the commit's hash
* and the commit replayed it, returning 200 with `{ dry_run: true, preview }`
* while writing nothing.
*
* The flag is only added to the hashed object when it is TRUE, so an ordinary
* (non-dry-run) write hashes byte-identically to previous releases. Idempotency
* rows live for 24h; folding `dry_run: false` in unconditionally would make
* every key in flight across this deploy fail the `request_hash` comparison and
* answer a legitimate retry with IDEMPOTENCY_KEY_REUSE.
*
* Both the cache lookup and the cache store go through this function: if the
* two hash inputs ever drift apart, a stored response can never be found
* again, which is a subtler failure than the one this fixes.
*/
function buildRequestHash(input: {
method: string
path: string
body: unknown
dryRun: boolean
}): string {
return hashRequest({
method: input.method,
path: input.path,
body: input.body,
...(input.dryRun ? { dry_run: true } : {}),
})
}
async function readBodyForHash(request: Request): Promise<{ body: unknown; cloned: Request }> {
// We need the body to hash it, but the handler also needs it. Read from a
// CLONE for the hash and pass the original through to the handler: that
@@ -406,14 +450,20 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
})
}
// 7. If idempotency-key supplied, check for cached response.
// 7. Dry-run resolution. Test keys force it on regardless of the flag.
// Resolved BEFORE the idempotency lookup because it feeds the request
// hash: a preview and its follow-up commit must never share a cache
// entry.
const dryRun = isDryRun(request, url) || forceDryRun
// 8. If idempotency-key supplied, check for cached response.
let bodyForHash: unknown = null
let workingRequest = request
if (idempotencyKey && isMutation && companyId) {
const { body, cloned } = await readBodyForHash(request)
bodyForHash = body
workingRequest = cloned
const reqHash = hashRequest({ method: request.method, path, body })
const reqHash = buildRequestHash({ method: request.method, path, body, dryRun })
try {
const hit = await checkIdempotencyKey(supabase, auth.userId, companyId, idempotencyKey, reqHash)
if (hit) {
@@ -434,9 +484,6 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
}
}
// 8. Dry-run resolution. Test keys force it on regardless of the flag.
const dryRun = isDryRun(workingRequest, url) || forceDryRun
const ctx: ApiV1Context = {
requestId,
log: userLog.child({ companyId }),
@@ -461,10 +508,20 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
}
// 10. Persist idempotency cache (best-effort).
if (idempotencyKey && isMutation && companyId && response.status < 500) {
//
// Never cache a dry-run: the response describes a write that did not
// happen, and caching it under a real Idempotency-Key is exactly how
// the documented "preview, then commit with the same key" flow used
// to lose the commit. A simulation has nothing worth replaying.
if (idempotencyKey && isMutation && companyId && !dryRun && response.status < 500) {
try {
const body = await response.clone().json().catch(() => ({}))
const reqHash = hashRequest({ method: request.method, path, body: bodyForHash })
const reqHash = buildRequestHash({
method: request.method,
path,
body: bodyForHash,
dryRun,
})
const status: 'success' | 'error' = response.status >= 400 ? 'error' : 'success'
await storeIdempotencyResponse(
supabase,
+32 -1
View File
@@ -21,6 +21,37 @@ interface ValidationOptions {
operation?: string
}
/** How many field issues the human-readable `error` summary names before truncating. */
const SUMMARY_ISSUE_LIMIT = 3
/**
* Build the human-readable `error` string for a Zod validation failure.
*
* `errors[]` keeps the full machine-readable detail, but plenty of clients read
* only `error`. A constant there ('Validation failed') either reached the user
* as English boilerplate in a Swedish UI, or got swallowed by
* `getErrorMessage()`'s generic "Något gick fel" fallback because the constant
* matches nothing it knows. The 'Valideringsfel' lead-in is load-bearing: it is
* what makes `getErrorMessage()` recognize the sentence as an already-Swedish
* user message and pass it through verbatim, so clients that forward
* `body.error` alone still show the actionable field message.
*
* `type: 'validation_error'` remains the machine-readable discriminator; nothing
* should branch on this prose.
*/
function summarizeIssues(errors: Array<{ field: string; message: string }>): string {
const shown = errors
.slice(0, SUMMARY_ISSUE_LIMIT)
.map((issue) => (issue.field ? `${issue.field}: ${issue.message}` : issue.message))
.filter((text) => text.trim() !== '')
if (shown.length === 0) return 'Valideringsfel: kontrollera fälten och försök igen.'
const hidden = errors.length - SUMMARY_ISSUE_LIMIT
const more = hidden > 0 ? ` (+${hidden} till)` : ''
return `Valideringsfel: ${shown.join('. ')}${more}`
}
function logIssues(
options: ValidationOptions | undefined,
kind: 'body' | 'query' | 'json',
@@ -86,7 +117,7 @@ export async function validateBody<T>(
success: false,
response: NextResponse.json(
{
error: 'Validation failed',
error: summarizeIssues(errors),
type: 'validation_error',
errors,
},
@@ -0,0 +1,316 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
consumeInviteCookie,
classifyInviteAcceptStatus,
isDefinitiveInviteDisposition,
readInviteCookie,
INVITE_COOKIE_NAME,
INVITE_PROBLEM_MESSAGE_KEYS,
} from '../consume-invite-cookie'
/**
* Minimal document.cookie stand-in. Vitest runs in the node env, so there is
* no DOM: this models the one behaviour the helper depends on, namely that
* 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)
const expiring = attrs.some((a) => /^max-age=0$/i.test(a))
if (expiring || value === '') {
jar.delete(name)
} else {
jar.set(name, value)
}
},
},
})
return jar
}
const TOKEN = 'gnubok_inv_Zm9vYmFyLXRva2Vu'
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(globalThis, 'document')
vi.restoreAllMocks()
})
function respondWith(status: number) {
fetchMock.mockResolvedValue({ status, ok: status >= 200 && status < 300 })
}
describe('classifyInviteAcceptStatus', () => {
it('treats 2xx and 409 (already a member) as accepted', () => {
expect(classifyInviteAcceptStatus(200)).toBe('accepted')
expect(classifyInviteAcceptStatus(204)).toBe('accepted')
expect(classifyInviteAcceptStatus(409)).toBe('accepted')
})
it('treats 400, 404 and 410 as a spent or invalid token', () => {
expect(classifyInviteAcceptStatus(400)).toBe('spent')
expect(classifyInviteAcceptStatus(404)).toBe('spent')
expect(classifyInviteAcceptStatus(410)).toBe('spent')
})
it('treats 403 email mismatch as its own case, not spent', () => {
expect(classifyInviteAcceptStatus(403)).toBe('wrong_email')
expect(isDefinitiveInviteDisposition('wrong_email')).toBe(false)
})
it('treats session races, rate limits and server errors as retryable', () => {
for (const status of [401, 408, 425, 429, 500, 502, 503, 504]) {
expect(classifyInviteAcceptStatus(status)).toBe('retryable')
expect(isDefinitiveInviteDisposition('retryable')).toBe(false)
}
})
it('marks only accepted and spent as definitive', () => {
expect(isDefinitiveInviteDisposition('accepted')).toBe(true)
expect(isDefinitiveInviteDisposition('spent')).toBe(true)
})
})
describe('readInviteCookie', () => {
it('reads the token out of the cookie jar', () => {
installCookieJar({ 'sb-access-token': 'x', [INVITE_COOKIE_NAME]: TOKEN })
expect(readInviteCookie()).toBe(TOKEN)
})
it('returns null when no invite cookie is present', () => {
installCookieJar({ 'sb-access-token': 'x' })
expect(readInviteCookie()).toBeNull()
})
it('does not match a differently-named cookie sharing the suffix', () => {
installCookieJar({ [`x-${INVITE_COOKIE_NAME}`]: 'not-the-invite' })
expect(readInviteCookie()).toBeNull()
})
it('returns null without a DOM (server render)', () => {
expect(readInviteCookie()).toBeNull()
})
})
describe('consumeInviteCookie', () => {
it('does nothing when there is no invite cookie', async () => {
installCookieJar({})
const result = await consumeInviteCookie()
expect(result).toEqual({
attempted: false,
disposition: null,
accepted: false,
cleared: false,
problem: null,
})
expect(fetchMock).not.toHaveBeenCalled()
})
it('happy path: posts the token, clears the cookie, reports accepted', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(200)
const result = await consumeInviteCookie()
expect(fetchMock).toHaveBeenCalledWith('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: TOKEN }),
})
expect(result.accepted).toBe(true)
expect(result.problem).toBeNull()
expect(result.cleared).toBe(true)
expect(jar.has(INVITE_COOKIE_NAME)).toBe(false)
})
it('already a member (409) counts as accepted and clears', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(409)
const result = await consumeInviteCookie()
expect(result.accepted).toBe(true)
expect(jar.has(INVITE_COOKIE_NAME)).toBe(false)
})
it('definitively invalid (400) clears the cookie and reports the problem', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(400)
const result = await consumeInviteCookie()
expect(result.accepted).toBe(false)
expect(result.disposition).toBe('spent')
expect(result.problem).toBe('spent')
expect(result.cleared).toBe(true)
expect(jar.has(INVITE_COOKIE_NAME)).toBe(false)
})
it('expired invitation (410) clears the cookie', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(410)
const result = await consumeInviteCookie()
expect(result.problem).toBe('spent')
expect(jar.has(INVITE_COOKIE_NAME)).toBe(false)
})
// The finding: a transient failure used to destroy the token.
it('transient 500 RETAINS the token and reports it', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(500)
const result = await consumeInviteCookie()
expect(result.accepted).toBe(false)
expect(result.disposition).toBe('retryable')
expect(result.problem).toBe('retryable')
expect(result.cleared).toBe(false)
expect(jar.get(INVITE_COOKIE_NAME)).toBe(TOKEN)
})
it('session not yet propagated (401) RETAINS the token', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(401)
const result = await consumeInviteCookie()
expect(result.problem).toBe('retryable')
expect(result.cleared).toBe(false)
expect(jar.get(INVITE_COOKIE_NAME)).toBe(TOKEN)
})
it('network throw RETAINS the token and reports it', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
const result = await consumeInviteCookie()
expect(result.accepted).toBe(false)
expect(result.disposition).toBe('retryable')
expect(result.problem).toBe('retryable')
expect(result.cleared).toBe(false)
expect(jar.get(INVITE_COOKIE_NAME)).toBe(TOKEN)
})
it('email mismatch (403) RETAINS the token so the right account can still use it', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(403)
const result = await consumeInviteCookie()
expect(result.accepted).toBe(false)
expect(result.disposition).toBe('wrong_email')
expect(result.problem).toBe('wrong_email')
expect(result.cleared).toBe(false)
expect(jar.get(INVITE_COOKIE_NAME)).toBe(TOKEN)
})
it('accepts an out-of-band token without reading the cookie', async () => {
installCookieJar({})
respondWith(200)
const result = await consumeInviteCookie({ token: 'gnubok_inv_from-the-url' })
expect(result.accepted).toBe(true)
expect(fetchMock).toHaveBeenCalledWith(
'/api/team/accept',
expect.objectContaining({ body: JSON.stringify({ token: 'gnubok_inv_from-the-url' }) }),
)
})
it('every problem disposition has a message key pair', async () => {
for (const problem of ['retryable', 'wrong_email', 'spent'] as const) {
expect(INVITE_PROBLEM_MESSAGE_KEYS[problem].title).toBeTruthy()
expect(INVITE_PROBLEM_MESSAGE_KEYS[problem].body).toBeTruthy()
}
})
})
/**
* Control: the pre-fix logic, copied verbatim from what login/register/mfa
* used to inline. It clears the cookie on the `!res.ok` fall-through and on
* the throw, and never tells the user anything. Running the retention
* expectations against it proves the tests above are actually load-bearing
* and not passing by construction.
*/
async function legacyInviteHandling(): Promise<{ accepted: boolean; problem: string | null }> {
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'
return { accepted: true, problem: null }
}
} catch {
// swallowed
}
// Clear cookie even on failure to avoid retrying stale tokens
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
}
return { accepted: false, problem: null }
}
describe('regression control: the old unconditional-clear logic', () => {
it('fails the transient-500 retention expectation', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(500)
const legacy = await legacyInviteHandling()
// What the new tests assert, and what the old code does not do:
expect(jar.get(INVITE_COOKIE_NAME)).toBeUndefined() // token destroyed
expect(legacy.problem).toBeNull() // user told nothing
})
it('fails the network-throw retention expectation', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
await legacyInviteHandling()
expect(jar.get(INVITE_COOKIE_NAME)).toBeUndefined() // token destroyed
})
it('fails the 403 email-mismatch retention expectation', async () => {
const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN })
respondWith(403)
await legacyInviteHandling()
expect(jar.get(INVITE_COOKIE_NAME)).toBeUndefined() // token destroyed
})
})
+38
View File
@@ -40,6 +40,44 @@ describe('safeReturnTo', () => {
expect(safeReturnTo('javascript:alert(1)', '/')).toBe('/')
})
it('rejects dot segments that normalise into a protocol-relative URL', () => {
// The raw input starts with a single '/', so the prefix guards let it
// through, and it resolves against the sentinel origin, so the origin
// check lets it through too. But URL normalisation collapses it to
// '//evil.com', which navigates off-origin the moment a caller uses it
// as a bare href (window.location.assign / router.push).
expect(safeReturnTo('/..//evil.com', '/')).toBe('/')
expect(safeReturnTo('/.//evil.com', '/')).toBe('/')
expect(safeReturnTo('/a/../..//evil.com', '/')).toBe('/')
expect(safeReturnTo('/../\\evil.com', '/')).toBe('/')
expect(safeReturnTo('/..//evil.com?x=1', '/')).toBe('/')
})
it('rejects the percent-encoded spelling of those dot segments', () => {
expect(safeReturnTo('/%2e%2e//evil.com', '/')).toBe('/')
expect(safeReturnTo('/%2E%2E//evil.com', '/')).toBe('/')
})
it('rejects dot segments that normalise into the /@ form', () => {
// The raw-input rule rejects '/@evil.com' outright, but dot-segment
// resolution can smuggle it past that guard: '/a/../@evil.com' starts
// with an innocent '/a' yet normalises to '/@evil.com'. The
// post-normalisation check must reject every prefix the raw check does.
expect(safeReturnTo('/a/../@evil.com', '/')).toBe('/')
expect(safeReturnTo('/.//@evil.com', '/')).toBe('/')
expect(safeReturnTo('/%2e%2e/@evil.com', '/')).toBe('/')
expect(safeReturnTo('/a/../@evil.com?x=1', '/')).toBe('/')
})
it('still allows ordinary dot segments that stay on-origin', () => {
expect(safeReturnTo('/settings/../invoices', '/')).toBe('/invoices')
expect(safeReturnTo('/./settings/tax', '/')).toBe('/settings/tax')
})
it('keeps percent-encoded slashes, which are path data and stay on-origin', () => {
expect(safeReturnTo('/%2F%2Fevil.com', '/')).toBe('/%2F%2Fevil.com')
})
it('rejects data: URIs', () => {
expect(safeReturnTo('data:text/html,<script>alert(1)</script>', '/')).toBe('/')
expect(safeReturnTo('data:,', '/')).toBe('/')
+6
View File
@@ -189,10 +189,16 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_update_article: 'articles:write',
// Invoices
gnubok_list_invoices: 'invoices:read',
gnubok_get_invoice_deliveries: 'invoices:read',
gnubok_create_invoice: 'invoices:write',
gnubok_update_invoice: 'invoices:write',
gnubok_send_invoice: 'invoices:write',
gnubok_mark_invoice_as_paid: 'invoices:write',
gnubok_mark_invoice_as_sent: 'invoices:write',
// Recurring invoice schedules (staged template writes; no send/book at commit)
gnubok_list_recurring_schedules: 'invoices:read',
gnubok_create_recurring_schedule: 'invoices:write',
gnubok_update_recurring_schedule: 'invoices:write',
// Suppliers
gnubok_list_suppliers: 'suppliers:read',
gnubok_list_supplier_invoices: 'suppliers:read',
+195
View File
@@ -0,0 +1,195 @@
/**
* Shared consumption of the pre-auth invite cookie.
*
* `app/invite/[token]/page.tsx` drops the raw invite token into
* `gnubok-invite-token` before sending an invitee off to log in or register.
* Every auth surface that can complete a session (login, register, MFA
* verify) has to pick that token back up and POST it to /api/team/accept,
* otherwise the invitee lands in the app without the membership they were
* invited to.
*
* The rule this helper exists to enforce: the cookie is deleted ONLY on a
* definitive outcome. Those surfaces each used to clear it unconditionally,
* so a network blip or a 500 from /api/team/accept destroyed the token even
* though the invitation was still `pending` in the database. The cookie is
* the only copy the browser holds, so nothing short of the inviter re-sending
* the invitation could recover it.
*
* Retention is a real recovery path, not just a deferral: `/onboarding` and
* `/select-company` both re-run acceptance server-side from the surviving
* cookie via `acceptPendingInviteByToken()` (lib/company/pending-invites.ts),
* and the dashboard root redirects a company-less user straight to
* `/onboarding`. A retained token therefore gets retried on the very next
* page load.
*
* Security note: retention does not widen the token's authority. Its lifetime
* is bounded server-side by `company_invitations.expires_at` (7 days) and by
* the single-use `status` transition, both enforced on every POST in
* app/api/team/accept/route.ts; the cookie's own `max-age` is set at write
* time and is untouched here. Acceptance is re-authorized on every attempt
* (`requireAuth()` plus an email equality check against the invitation), so a
* surviving cookie confers nothing on its own.
*/
export const INVITE_COOKIE_NAME = 'gnubok-invite-token'
const ACCEPT_ENDPOINT = '/api/team/accept'
/**
* What POST /api/team/accept told us about the token's fate.
*
* - `accepted` the invitee is a member of the company now.
* - `spent` the token is genuinely used up or invalid: it can never work.
* - `wrong_email` the signed-in account is not the invited one. The invitation
* is untouched and still pending; the wrong account simply
* cannot redeem it.
* - `retryable` nothing conclusive happened. The token is still live.
*
* `accepted` and `spent` are the only definitive outcomes, and therefore the
* only ones that may clear the cookie.
*/
export type InviteAcceptDisposition = 'accepted' | 'spent' | 'wrong_email' | 'retryable'
/**
* Map an HTTP status from POST /api/team/accept onto a disposition.
*
* Derived from app/api/team/accept/route.ts:
*
* | Status | Route meaning | Disposition |
* |--------|----------------------------------------|---------------|
* | 2xx | membership created, invite accepted | accepted |
* | 409 | 'Du är redan medlem.' | accepted |
* | 400 | token missing, unknown, or not pending | spent |
* | 404 | invitation not found (GET shape) | spent |
* | 410 | 'Inbjudan har gått ut.' (+ marked so) | spent |
* | 403 | 'E-postadressen matchar inte...' | wrong_email |
* | other | 401 session race, 429, 5xx, proxy hiccup | retryable |
*
* 409 counts as accepted: the invitee is in the company, which is the whole
* point of holding the token. 401 is deliberately retryable rather than a
* hard failure: it means the session cookie had not propagated yet, and the
* next attempt (or the server-side retry on /onboarding) succeeds.
*/
export function classifyInviteAcceptStatus(status: number): InviteAcceptDisposition {
if (status >= 200 && status < 300) return 'accepted'
switch (status) {
case 409:
return 'accepted'
case 400:
case 404:
case 410:
return 'spent'
case 403:
return 'wrong_email'
default:
return 'retryable'
}
}
/** Only a definitive disposition may destroy the token. */
export function isDefinitiveInviteDisposition(disposition: InviteAcceptDisposition): boolean {
return disposition === 'accepted' || disposition === 'spent'
}
/** Non-success dispositions the caller has to surface to the user. */
export type InviteAcceptProblem = Exclude<InviteAcceptDisposition, 'accepted'>
/**
* i18n keys (namespace `invite`) for each problem. Kept here so the four auth
* surfaces render the same wording instead of inventing their own.
*/
export const INVITE_PROBLEM_MESSAGE_KEYS: Record<
InviteAcceptProblem,
{ title: string; body: string }
> = {
retryable: { title: 'accept_retry_title', body: 'accept_retry_body' },
wrong_email: { title: 'accept_wrong_email_title', body: 'accept_wrong_email_body' },
spent: { title: 'accept_spent_title', body: 'accept_spent_body' },
}
export interface ConsumeInviteCookieResult {
/** False when there was no invite to process: the caller keeps its normal flow. */
attempted: boolean
/** Null only when `attempted` is false. */
disposition: InviteAcceptDisposition | null
/** True when the invitee is now a member: land them in the app. */
accepted: boolean
/** True when the token was removed from the browser. */
cleared: boolean
/** Null on success; otherwise the message the caller must show. */
problem: InviteAcceptProblem | null
}
const NO_INVITE: ConsumeInviteCookieResult = {
attempted: false,
disposition: null,
accepted: false,
cleared: false,
problem: null,
}
/**
* Read the raw invite token from the cookie jar. Anchored on a cookie
* boundary so a differently-named cookie ending in the same suffix cannot
* masquerade as the invite token.
*/
export function readInviteCookie(): string | null {
if (typeof document === 'undefined') return null
const match = document.cookie.match(
new RegExp(`(?:^|;\\s*)${INVITE_COOKIE_NAME}=([^;]*)`),
)
const value = match?.[1]
return value ? value : null
}
/**
* Expire the invite cookie. Same attributes the auth surfaces have always
* used: path-scoped delete, no change to domain, samesite or secure.
*/
export function clearInviteCookie(): void {
if (typeof document === 'undefined') return
document.cookie = `${INVITE_COOKIE_NAME}=; path=/; max-age=0`
}
/**
* Read the pending invite token, try to accept it, and clear the cookie only
* if the outcome was definitive.
*
* Pass `token` explicitly when the caller already has it out of band (the
* invite page reads it from the route params); the cookie is still cleared on
* a definitive outcome so a stale copy does not linger.
*/
export async function consumeInviteCookie(
options: { token?: string | null } = {},
): Promise<ConsumeInviteCookieResult> {
const token = options.token ?? readInviteCookie()
if (!token) return NO_INVITE
let disposition: InviteAcceptDisposition
try {
const res = await fetch(ACCEPT_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
disposition = classifyInviteAcceptStatus(res.status)
} catch (err) {
// The request never reached the server, so the invitation is certainly
// still pending. Keep the token.
console.error('[invite] acceptance request failed:', err)
disposition = 'retryable'
}
const cleared = isDefinitiveInviteDisposition(disposition)
if (cleared) clearInviteCookie()
return {
attempted: true,
disposition,
accepted: disposition === 'accepted',
cleared,
problem: disposition === 'accepted' ? null : disposition,
}
}
+235 -4
View File
@@ -1,18 +1,94 @@
import crypto from 'crypto'
import { NextResponse } from 'next/server'
import { captureException, captureMessage } from '@/lib/observability'
/**
* Cron authentication and failure reporting.
*
* Every scheduled job in `vercel.json` enters through `verifyCronSecret()`,
* which makes this the one place that sees all of them without touching 16
* route files. A cron that silently stops running is the worst failure mode in
* this product: nothing is visibly broken, the work just never happens, and
* nobody finds out until a period does not close or a reminder never goes out.
*
* The reporting shape is generalised from the one well-built alerting path in
* the repo, `extensions/general/cloud-backup/lib/backup-alert.ts`: a pure
* "should we report" predicate, a throttle window, a consecutive-failure
* counter, and best-effort delivery that can never fail the loop it monitors.
*
* Two deliberate differences from the backup-alert defaults:
*
* - Threshold defaults to 1, not 3. Backup alerts email a human, so waiting
* for three consecutive nights avoids nagging. This sink is a machine
* aggregator; suppressing the first occurrence of a failure is exactly how
* an outage stays invisible. The consecutive count ships as context so an
* alert rule can threshold on it downstream, where it belongs.
* - Throttle defaults to 15 minutes, not 7 days. It exists to stop a cron
* looping over thousands of failing items from emitting thousands of
* events, not to ration notifications.
*
* The failure state is in-process and therefore per serverless instance. That
* is fine for its purpose (storm damping); it is NOT a durable state machine,
* and nothing here should be relied on to remember anything across cold
* starts. Every distinct failure still reports.
*/
/** At most one report per operation+kind per window. Damps event storms. */
export const CRON_REPORT_THROTTLE_MS = 15 * 60 * 1000
/** Consecutive failures before reporting. 1 = report the first failure. */
export const CRON_REPORT_FAILURE_THRESHOLD = 1
/**
* Upper bound on tracked keys. Keys derive from cron operation names (a fixed,
* small set), but the fallback keys off the request pathname, so this caps the
* map rather than trusting that assumption forever.
*/
const MAX_TRACKED_CRON_KEYS = 200
export type CronFailureKind = 'auth' | 'run' | 'item'
interface CronReportState {
consecutiveFailures: number
lastReportAt: number | null
}
const cronReportState = new Map<string, CronReportState>()
export interface VerifyCronSecretOptions {
/**
* Stable name for this cron, used as the reporting key. Defaults to the
* request pathname, which is already unique per scheduled job.
*/
operation?: string
/** Set false to skip sink reporting entirely (tests, probes). */
report?: boolean
}
/**
* Verify cron secret using constant-time comparison to prevent timing attacks.
* Expects `Authorization: Bearer <CRON_SECRET>` header.
*
* Returns null if authorized, or a 401 NextResponse if not.
*
* A 401 here is reported to the observability sink (throttled, best-effort).
* That is the highest-value automatic signal available: if CRON_SECRET is
* rotated in Vercel but not in the scheduler (or unset entirely), all 16 jobs
* start failing auth and stop doing their work, while every endpoint keeps
* answering "fine, 401" to anyone looking at status codes.
*/
export function verifyCronSecret(request: Request): NextResponse | null {
export function verifyCronSecret(
request: Request,
options: VerifyCronSecretOptions = {},
): NextResponse | null {
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (!cronSecret || !authHeader) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!cronSecret) {
return denyCron(request, options, 'secret_not_configured')
}
if (!authHeader) {
return denyCron(request, options, 'missing_authorization')
}
const token = authHeader.startsWith('Bearer ')
@@ -25,8 +101,163 @@ export function verifyCronSecret(request: Request): NextResponse | null {
const secretHash = crypto.createHash('sha256').update(cronSecret).digest()
if (!crypto.timingSafeEqual(tokenHash, secretHash)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
return denyCron(request, options, 'token_mismatch')
}
// A good run clears the auth failure streak so the next outage reports from
// a clean count rather than inheriting an old one.
if (options.report !== false) {
reportCronSuccess(resolveCronOperation(request, options), 'auth')
}
return null
}
function denyCron(
request: Request,
options: VerifyCronSecretOptions,
reason: string,
): NextResponse {
if (options.report !== false) {
reportCronFailure({
operation: resolveCronOperation(request, options),
kind: 'auth',
status: 401,
reason,
})
}
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
function resolveCronOperation(request: Request, options: VerifyCronSecretOptions): string {
if (options.operation) return options.operation
try {
return new URL(request.url).pathname
} catch {
return 'unknown'
}
}
/**
* Pure predicate: given the current streak and the last report time, should
* this failure be reported? Mirrors `shouldSendBackupAlert()` so the two
* alerting paths stay recognisably the same shape.
*/
export function shouldReportCronFailure(params: {
consecutiveFailures: number
lastReportAt: number | null
threshold?: number
throttleMs?: number
now?: number
}): boolean {
const threshold = params.threshold ?? CRON_REPORT_FAILURE_THRESHOLD
const throttleMs = params.throttleMs ?? CRON_REPORT_THROTTLE_MS
const now = params.now ?? Date.now()
if (params.consecutiveFailures < threshold) return false
if (
params.lastReportAt !== null &&
Number.isFinite(params.lastReportAt) &&
now - params.lastReportAt < throttleMs
) {
return false
}
return true
}
export interface CronFailureReport {
/** Stable cron identity, e.g. 'cron.invoice-reminders' or the route path. */
operation: string
/** auth = rejected at the door, run = the whole run failed, item = one unit failed. */
kind?: CronFailureKind
/** HTTP status the cron is about to return, when there is one. */
status?: number
/** The thrown value, if any. Serialized and redacted before it leaves. */
error?: unknown
/** Short machine-readable cause, e.g. 'token_mismatch', 'timeout'. */
reason?: string
/** Correlation id, matching the one in the cron's log lines. */
requestId?: string
itemsTotal?: number
itemsFailed?: number
/** Extra structured context. Redacted by the sink before delivery. */
context?: Record<string, unknown>
/** Overrides, mainly for tests. */
threshold?: number
throttleMs?: number
now?: number
}
/**
* Report a non-2xx cron outcome to the observability sink.
*
* Best-effort by design and by contract: synchronous, swallows everything, and
* can never throw into the cron's own path. A monitoring call that breaks the
* job it monitors is worse than no monitoring.
*/
export function reportCronFailure(report: CronFailureReport): void {
try {
const kind = report.kind ?? 'run'
const key = `${report.operation}:${kind}`
const now = report.now ?? Date.now()
if (!cronReportState.has(key) && cronReportState.size >= MAX_TRACKED_CRON_KEYS) {
// Evict the oldest entry only (Map preserves insertion order). A
// wholesale clear() would drop every live throttle at once, so an
// in-progress failure storm would re-report immediately: the exact
// event flood the throttle exists to damp.
const oldestKey = cronReportState.keys().next().value
if (oldestKey !== undefined) cronReportState.delete(oldestKey)
}
const state = cronReportState.get(key) ?? { consecutiveFailures: 0, lastReportAt: null }
state.consecutiveFailures += 1
cronReportState.set(key, state)
const shouldReport = shouldReportCronFailure({
consecutiveFailures: state.consecutiveFailures,
lastReportAt: state.lastReportAt,
threshold: report.threshold,
throttleMs: report.throttleMs,
now,
})
if (!shouldReport) return
state.lastReportAt = now
const context: Record<string, unknown> = {
...(report.context ?? {}),
cron: true,
alert: true,
operation: report.operation,
cronKind: kind,
consecutiveFailures: state.consecutiveFailures,
}
if (report.status !== undefined) context.status = report.status
if (report.reason !== undefined) context.reason = report.reason
if (report.requestId !== undefined) context.requestId = report.requestId
if (report.itemsTotal !== undefined) context.itemsTotal = report.itemsTotal
if (report.itemsFailed !== undefined) context.itemsFailed = report.itemsFailed
if (report.error !== undefined) {
captureException(report.error, context)
} else {
captureMessage(`cron failed: ${report.operation}`, 'error', context)
}
} catch {
// Best-effort by design: reporting must never fail the cron it monitors.
}
}
/** Clear the failure streak for an operation after a good run. Never throws. */
export function reportCronSuccess(operation: string, kind: CronFailureKind = 'run'): void {
try {
cronReportState.delete(`${operation}:${kind}`)
} catch {
// Best-effort by design.
}
}
/** Test helper: drop all in-process throttle and streak state. */
export function resetCronReportingState(): void {
cronReportState.clear()
}
+20 -1
View File
@@ -20,7 +20,26 @@ export function safeReturnTo(value: string | null | undefined, fallback: string)
const base = 'https://gnubok.invalid'
const parsed = new URL(value, base)
if (parsed.origin !== base) return fallback
return parsed.pathname + parsed.search + parsed.hash
const path = parsed.pathname + parsed.search + parsed.hash
// Re-check the NORMALISED path, not just the raw input. Dot-segment
// resolution turns several innocent-looking inputs into a
// protocol-relative one: `/..//evil.com`, `/.//evil.com` and
// `/%2e%2e//evil.com` all normalise to `//evil.com`, and
// `/a/../@evil.com` normalises to `/@evil.com`. Those keep the
// sentinel origin above (they are relative to it), so the origin check
// passes, but the returned string navigates off-origin the moment a
// caller uses it as a bare href (window.location.assign, router.push).
// Every prefix the raw-input guard rejects is re-rejected here, in
// normal form, so the post-check cannot itself be re-normalised around.
if (
!path.startsWith('/') ||
path.startsWith('//') ||
path.startsWith('/\\') ||
path.startsWith('/@')
) {
return fallback
}
return path
} catch {
return fallback
}
@@ -0,0 +1,177 @@
/**
* Error-contract tests for the årsredovisning narrative service.
*
* The service used to re-wrap Supabase's PostgrestError in a plain
* `new Error(...)`, which kept only `.message` and dropped `.code`. Without the
* SQLSTATE, errorResponse() could not recognise the failure as a Postgres
* constraint violation and returned INTERNAL_ERROR / 500 for what is a caller
* mistake. These tests pin the SQLSTATE pass-through and the resulting envelope.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { errorResponse, type ErrorEnvelope } from '@/lib/errors/get-structured-error'
import { getNarrative, upsertNarrative } from '../narrative-service'
const noopLogger = { error: () => {}, warn: () => {} }
/**
* Shape PostgREST actually returns for a failed CHECK: the constraint name is
* in `message`, the failing row in `details`, and the SQLSTATE in `code`.
*/
function checkViolation(constraint: string) {
return Object.assign(
new Error(
`new row for relation "arsredovisning_narratives" violates check constraint "${constraint}"`,
),
{
name: 'PostgrestError',
code: '23514',
details: 'Failing row contains (...)',
hint: null,
},
)
}
async function envelopeFor(err: unknown): Promise<{ status: number; body: ErrorEnvelope }> {
const res = errorResponse(err, noopLogger, { requestId: 'req_test' })
return { status: res.status, body: (await res.json()) as ErrorEnvelope }
}
const narrativeRow = {
id: 'narrative-1',
company_id: 'company-1',
fiscal_period_id: 'period-1',
description: 'Bolaget bedriver konsultverksamhet.',
agm_disposition_outcome: null,
agm_disposition_decision: null,
updated_at: '2026-01-15T10:00:00Z',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('upsertNarrative', () => {
it('returns the persisted row on the happy path', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: narrativeRow })
const result = await upsertNarrative(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
'period-1',
{ description: 'Bolaget bedriver konsultverksamhet.' },
)
expect(result).toEqual(narrativeRow)
})
it('preserves the Postgres SQLSTATE on a CHECK violation', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ error: checkViolation('arsredovisning_narratives_agm_decision_consistency') })
const err = await upsertNarrative(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
'period-1',
{ agm_disposition_outcome: 'alternative_decision' },
).then(
() => null,
(e: unknown) => e,
)
expect(err).toMatchObject({ code: '23514' })
expect((err as Error).message).toContain(
'arsredovisning_narratives_agm_decision_consistency',
)
})
it('maps a CHECK violation to 400 with a Swedish message', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ error: checkViolation('arsredovisning_narratives_agm_decision_consistency') })
const err = await upsertNarrative(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
'period-1',
{ agm_disposition_decision: ' ' },
).catch((e: unknown) => e)
const { status, body } = await envelopeFor(err)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.message).toBe('Förfrågan innehåller ogiltiga uppgifter.')
expect(body.error.details).toMatchObject({ pgCode: '23514' })
// The failing row must not reach the client.
expect(JSON.stringify(body)).not.toContain('Failing row')
})
it('still yields 500 for a genuine internal database failure', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
error: Object.assign(new Error('internal error occurred'), {
name: 'PostgrestError',
// XX000 internal_error: not a caller mistake, must not become a 400.
code: 'XX000',
details: null,
hint: null,
}),
})
const err = await upsertNarrative(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
'period-1',
{ description: 'x' },
).catch((e: unknown) => e)
const { status, body } = await envelopeFor(err)
expect(status).toBe(500)
expect(body.error.code).toBe('INTERNAL_ERROR')
})
it('still yields 500 when the write reports no row and no error', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
const err = await upsertNarrative(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
'period-1',
{ description: 'x' },
).catch((e: unknown) => e)
expect((await envelopeFor(err)).status).toBe(500)
})
})
describe('getNarrative', () => {
it('returns null when nothing has been customised yet', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
const result = await getNarrative(
supabase as unknown as SupabaseClient,
'company-1',
'period-1',
)
expect(result).toBeNull()
})
it('preserves the Postgres SQLSTATE on a read failure', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
error: Object.assign(new Error('permission denied for table arsredovisning_narratives'), {
name: 'PostgrestError',
code: '42501',
details: null,
hint: null,
}),
})
const err = await getNarrative(
supabase as unknown as SupabaseClient,
'company-1',
'period-1',
).catch((e: unknown) => e)
expect(err).toMatchObject({ code: '42501' })
expect((await envelopeFor(err)).status).toBe(403)
})
})
@@ -0,0 +1,196 @@
/**
* Error-contract tests for the annual-report profile service.
*
* The service used to re-wrap Supabase's PostgrestError in a plain
* `new Error(...)`, which kept only `.message` and dropped `.code`. Without the
* SQLSTATE, errorResponse() could not recognise the failure as a Postgres
* constraint violation and returned INTERNAL_ERROR / 500 for what is a caller
* mistake. These tests pin the SQLSTATE pass-through and the resulting envelope.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { errorResponse, type ErrorEnvelope } from '@/lib/errors/get-structured-error'
import { getAnnualReportProfile, upsertAnnualReportProfile } from '../profile-service'
const noopLogger = { error: () => {}, warn: () => {} }
/**
* Shape PostgREST actually returns for a failed CHECK: the constraint name is
* in `message`, the failing row in `details`, and the SQLSTATE in `code`.
*/
function checkViolation(constraint: string) {
return Object.assign(
new Error(
`new row for relation "annual_report_profiles" violates check constraint "${constraint}"`,
),
{
name: 'PostgrestError',
code: '23514',
details: 'Failing row contains (...)',
hint: null,
},
)
}
async function envelopeFor(err: unknown): Promise<{ status: number; body: ErrorEnvelope }> {
const res = errorResponse(err, noopLogger, { requestId: 'req_test' })
return { status: res.status, body: (await res.json()) as ErrorEnvelope }
}
const profileRow = {
id: 'profile-1',
company_id: 'company-1',
fiscal_period_id: 'period-1',
is_parent_company: false,
reporting_currency: 'SEK',
updated_at: '2026-01-15T10:00:00Z',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('upsertAnnualReportProfile', () => {
it('returns the persisted row on the happy path', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: profileRow })
const result = await upsertAnnualReportProfile(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
'period-1',
{ reporting_currency: 'SEK' },
)
expect(result).toEqual(profileRow)
})
it('preserves the Postgres SQLSTATE on a CHECK violation', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ error: checkViolation('annual_report_profiles_parent_consistency') })
const err = await upsertAnnualReportProfile(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
'period-1',
{ parent_group_size: 'small' },
).then(
() => null,
(e: unknown) => e,
)
expect(err).toMatchObject({ code: '23514' })
expect((err as Error).message).toContain('annual_report_profiles_parent_consistency')
})
it('maps a CHECK violation to 400 with a Swedish message', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ error: checkViolation('annual_report_profiles_parent_consistency') })
const err = await upsertAnnualReportProfile(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
'period-1',
{ prepares_consolidated_accounts: true },
).catch((e: unknown) => e)
const { status, body } = await envelopeFor(err)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.message).toBe('Förfrågan innehåller ogiltiga uppgifter.')
expect(body.error.details).toMatchObject({ pgCode: '23514' })
// The failing row must not reach the client.
expect(JSON.stringify(body)).not.toContain('Failing row')
})
it('maps an RLS refusal to 403 instead of 500', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
error: Object.assign(
new Error('new row violates row-level security policy for table "annual_report_profiles"'),
{ name: 'PostgrestError', code: '42501', details: null, hint: null },
),
})
const err = await upsertAnnualReportProfile(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
'period-1',
{ reporting_currency: 'SEK' },
).catch((e: unknown) => e)
const { status, body } = await envelopeFor(err)
expect(status).toBe(403)
expect(body.error.code).toBe('FORBIDDEN')
})
it('still yields 500 for a genuine internal database failure', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
error: Object.assign(new Error('internal error occurred'), {
name: 'PostgrestError',
// XX000 internal_error: not a caller mistake, must not become a 400.
code: 'XX000',
details: null,
hint: null,
}),
})
const err = await upsertAnnualReportProfile(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
'period-1',
{ reporting_currency: 'SEK' },
).catch((e: unknown) => e)
const { status, body } = await envelopeFor(err)
expect(status).toBe(500)
expect(body.error.code).toBe('INTERNAL_ERROR')
})
it('still yields 500 when the write reports no row and no error', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
const err = await upsertAnnualReportProfile(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
'period-1',
{ reporting_currency: 'SEK' },
).catch((e: unknown) => e)
expect((await envelopeFor(err)).status).toBe(500)
})
})
describe('getAnnualReportProfile', () => {
it('falls back to the empty profile when no row exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
const result = await getAnnualReportProfile(
supabase as unknown as SupabaseClient,
'company-1',
'period-1',
)
expect(result.company_id).toBe('company-1')
expect(result.fiscal_period_id).toBe('period-1')
})
it('preserves the Postgres SQLSTATE on a read failure', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
error: Object.assign(new Error('permission denied for table annual_report_profiles'), {
name: 'PostgrestError',
code: '42501',
details: null,
hint: null,
}),
})
const err = await getAnnualReportProfile(
supabase as unknown as SupabaseClient,
'company-1',
'period-1',
).catch((e: unknown) => e)
expect(err).toMatchObject({ code: '42501' })
expect((await envelopeFor(err)).status).toBe(403)
})
})
@@ -1,3 +1,20 @@
/**
* Persistence for the årsredovisning narrative overrides.
*
* Error contract: Supabase errors are rethrown untouched, exactly as
* `lib/articles/validate-revenue-account.ts` does. Re-wrapping them in a plain
* `new Error(...)` kept only `.message` and dropped `.code`, so `errorResponse()`
* could no longer see the SQLSTATE and mapped every constraint failure on this
* table to INTERNAL_ERROR / 500. The reachable one is a real caller mistake:
* 23514 from `arsredovisning_narratives_agm_decision_consistency`, which the
* route's Zod superRefine only catches when both fields arrive in the same
* payload. A partial save (the upsert writes only the columns present in the
* payload) can still leave the stored row with
* `agm_disposition_outcome = 'alternative_decision'` and a blank decision, and
* the DB is the last line of defence. With the code intact that becomes a 400
* with a Swedish message; SQLSTATEs that are genuine infrastructure trouble are
* not in `postgresCodeToStructured()` and still fall through to 500.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
export interface NarrativeOverrides {
@@ -86,7 +103,7 @@ export async function getNarrative(
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.maybeSingle()
if (error) throw new Error(`Failed to load narrative: ${error.message}`)
if (error) throw error
return (data as NarrativeRow | null) ?? null
}
@@ -117,8 +134,10 @@ export async function upsertNarrative(
.upsert(payload, { onConflict: 'company_id,fiscal_period_id' })
.select(NARRATIVE_API_COLUMNS)
.single()
if (error || !data) {
throw new Error(`Failed to save narrative: ${error?.message ?? 'unknown'}`)
}
if (error) throw error
// `.single()` always reports a missing row as an error, so this is a
// belt-and-braces guard rather than a reachable branch: keep it a plain
// Error (no SQLSTATE to preserve) so it maps to 500.
if (!data) throw new Error('Failed to save narrative: no row returned')
return data as NarrativeRow
}
+21 -4
View File
@@ -1,3 +1,18 @@
/**
* Persistence for the annual-report compliance profile.
*
* Error contract: Supabase errors are rethrown untouched, exactly as
* `lib/articles/validate-revenue-account.ts` does. Re-wrapping them in a plain
* `new Error(...)` kept only `.message` and dropped `.code`, so `errorResponse()`
* could no longer see the SQLSTATE and mapped every constraint failure on this
* table to INTERNAL_ERROR / 500. The reachable ones are real caller mistakes:
* 23514 from `annual_report_profiles_parent_consistency` (e.g. setting
* `parent_group_size` on a row already stored with `is_parent_company = false`,
* since the upsert only writes the columns present in the payload) and 42501
* when RLS refuses the write. With the code intact those become 400/403 with a
* Swedish message. SQLSTATEs that are genuine infrastructure trouble are not in
* `postgresCodeToStructured()` and still fall through to 500.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import {
emptyAnnualReportProfile,
@@ -22,7 +37,7 @@ export async function getAnnualReportProfile(
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.maybeSingle()
if (error) throw new Error(`Failed to load annual report profile: ${error.message}`)
if (error) throw error
return data
? (data as AnnualReportProfile)
: emptyAnnualReportProfile(companyId, fiscalPeriodId)
@@ -50,8 +65,10 @@ export async function upsertAnnualReportProfile(
.upsert(payload, { onConflict: 'company_id,fiscal_period_id' })
.select(PROFILE_COLUMNS)
.single()
if (error || !data) {
throw new Error(`Failed to save annual report profile: ${error?.message ?? 'unknown'}`)
}
if (error) throw error
// `.single()` always reports a missing row as an error, so this is a
// belt-and-braces guard rather than a reachable branch: keep it a plain
// Error (no SQLSTATE to preserve) so it maps to 500.
if (!data) throw new Error('Failed to save annual report profile: no row returned')
return data as AnnualReportProfile
}
@@ -1,9 +1,27 @@
import { describe, it, expect, vi } from 'vitest'
import { cancelOrphanedPaymentEntry } from '../cancel-orphaned-entry'
import {
cancelOrphanedPaymentEntry,
recordVoucherGapExplanation,
} from '../cancel-orphaned-entry'
// The real voucher_gap_explanations column set (supabase/migrations/
// 20260402100100_voucher_gap_explanations.sql). company_id, user_id,
// fiscal_period_id, voucher_series, gap_start, gap_end and explanation are all
// NOT NULL; anything else is a phantom column that PostgREST rejects.
const REQUIRED_GAP_COLUMNS = [
'company_id',
'user_id',
'fiscal_period_id',
'voucher_series',
'gap_start',
'gap_end',
'explanation',
].sort()
function createMockSupabase(opts: {
orphan?: { fiscal_period_id: string; voucher_series: string | null; voucher_number: number } | null
cancelError?: { message: string } | null
insertError?: { message: string; code?: string } | null
}) {
const updates: unknown[] = []
const inserts: Record<string, unknown[]> = {}
@@ -30,13 +48,101 @@ function createMockSupabase(opts: {
}),
insert: vi.fn().mockImplementation((payload: unknown) => {
;(inserts[table] ??= []).push(payload)
return Promise.resolve({ error: null })
return Promise.resolve({ error: opts.insertError ?? null })
}),
})),
}
return { supabase, updates, inserts }
}
describe('recordVoucherGapExplanation', () => {
it('writes exactly the real NOT NULL columns, with the single voucher as a closed gap range', async () => {
const { supabase, inserts } = createMockSupabase({})
const ok = await recordVoucherGapExplanation(supabase as never, {
companyId: 'company-1',
userId: 'user-1',
fiscalPeriodId: 'fp-1',
voucherSeries: 'B',
voucherNumber: 66,
explanation: 'Automatiskt makulerad: test',
})
expect(ok).toBe(true)
const gaps = inserts['voucher_gap_explanations'] as Record<string, unknown>[]
expect(gaps).toHaveLength(1)
// toEqual, not toMatchObject: the mocked harness would happily accept a
// phantom column, so the payload is asserted exhaustively.
expect(gaps[0]).toEqual({
company_id: 'company-1',
user_id: 'user-1',
fiscal_period_id: 'fp-1',
voucher_series: 'B',
gap_start: 66,
gap_end: 66,
explanation: 'Automatiskt makulerad: test',
})
expect(Object.keys(gaps[0]).sort()).toEqual(REQUIRED_GAP_COLUMNS)
// The readers (voucher-gaps UI, gnubok_list_voucher_gaps, year-end
// readiness) key on series:gap_start:gap_end, so both bounds are the
// stranded number itself.
expect(gaps[0].gap_start).toBe(gaps[0].gap_end)
})
it('reports failure when the insert errors (an undocumented gap must surface)', async () => {
const { supabase } = createMockSupabase({
insertError: { message: 'new row violates row-level security policy', code: '42501' },
})
const ok = await recordVoucherGapExplanation(supabase as never, {
companyId: 'company-1',
userId: 'user-1',
fiscalPeriodId: 'fp-1',
voucherSeries: 'A',
voucherNumber: 9,
explanation: 'x',
})
expect(ok).toBe(false)
})
it('treats a duplicate-key conflict as already documented', async () => {
const { supabase } = createMockSupabase({
insertError: { message: 'duplicate key value', code: '23505' },
})
const ok = await recordVoucherGapExplanation(supabase as never, {
companyId: 'company-1',
userId: 'user-1',
fiscalPeriodId: 'fp-1',
voucherSeries: 'A',
voucherNumber: 9,
explanation: 'x',
})
expect(ok).toBe(true)
})
it('never throws when the client rejects unexpectedly', async () => {
const supabase = {
from: vi.fn().mockImplementation(() => {
throw new Error('network blip')
}),
}
await expect(
recordVoucherGapExplanation(supabase as never, {
companyId: 'company-1',
userId: 'user-1',
fiscalPeriodId: 'fp-1',
voucherSeries: 'A',
voucherNumber: 9,
explanation: 'x',
}),
).resolves.toBe(false)
})
})
describe('cancelOrphanedPaymentEntry', () => {
it('cancels the voucher and records a gap explanation', async () => {
const { supabase, updates, inserts } = createMockSupabase({
@@ -50,13 +156,14 @@ describe('cancelOrphanedPaymentEntry', () => {
expect(updates).toEqual([{ status: 'cancelled' }])
const gaps = inserts['voucher_gap_explanations'] as Record<string, unknown>[]
expect(gaps).toHaveLength(1)
expect(gaps[0]).toMatchObject({
expect(gaps[0]).toEqual({
company_id: 'company-1',
user_id: 'user-1',
fiscal_period_id: 'fp-1',
voucher_series: 'A',
gap_number: 66,
gap_start: 66,
gap_end: 66,
explanation: 'Automatiskt makulerad: test',
created_by: 'user-1',
})
})
@@ -96,6 +203,18 @@ describe('cancelOrphanedPaymentEntry', () => {
).resolves.toBeUndefined()
})
it('never throws when the gap insert itself fails (the CAS response must survive)', async () => {
const { supabase, updates } = createMockSupabase({
orphan: { fiscal_period_id: 'fp-1', voucher_series: 'A', voucher_number: 66 },
insertError: { message: 'permission denied', code: '42501' },
})
await expect(
cancelOrphanedPaymentEntry(supabase as never, 'company-1', 'user-1', 'je-1', 'x'),
).resolves.toBeUndefined()
expect(updates).toEqual([{ status: 'cancelled' }])
})
it('does not record a gap when the cancel itself fails', async () => {
const { supabase, inserts } = createMockSupabase({
orphan: { fiscal_period_id: 'fp-1', voucher_series: 'A', voucher_number: 9 },
@@ -1,10 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { CreateJournalEntryInput, Currency } from '@/types'
import { makeInvoice, makeSupplierInvoice } from '@/tests/helpers'
import { getErrorMessage } from '@/lib/errors/get-error-message'
// Mock riksbanken
// Mock riksbanken. The revaluation deliberately uses fetchExchangeRate, the
// documented booking path that returns null rather than one of the hardcoded
// display-only fallback constants, so the mock returns null for any currency
// the test did not price.
vi.mock('@/lib/currency/riksbanken', () => ({
fetchMultipleRates: vi.fn(),
fetchExchangeRate: vi.fn(),
}))
// Mock engine
@@ -34,8 +38,8 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
const { fetchMultipleRates } = await import('@/lib/currency/riksbanken')
const mockedFetchRates = vi.mocked(fetchMultipleRates)
const { fetchExchangeRate } = await import('@/lib/currency/riksbanken')
const mockedFetchRate = vi.mocked(fetchExchangeRate)
const { createJournalEntry } = await import('../engine')
const mockedCreateEntry = vi.mocked(createJournalEntry)
@@ -47,6 +51,18 @@ const {
executeCurrencyRevaluation,
} = await import('../currency-revaluation')
/**
* Price the given currencies. Anything not listed resolves to null, which is
* exactly what fetchExchangeRate does when Riksbanken is unreachable and the
* cache is empty: no rate at all, never a fabricated one.
*/
function mockRates(rates: Partial<Record<Currency, number>>) {
mockedFetchRate.mockImplementation(async (currency: Currency) => {
const rate = rates[currency]
return rate === undefined ? null : { currency, rate, date: '2024-12-31' }
})
}
// Helper to build mock supabase
function createMockSupabase(config: {
invoices?: ReturnType<typeof makeInvoice>[]
@@ -221,7 +237,34 @@ describe('currency-revaluation', () => {
expect(result).toHaveLength(0)
})
it('excludes invoices without exchange_rate', async () => {
it('includes partially_paid invoices: the unpaid remainder is still an open FX item', async () => {
// payment-sync.ts moves a customer invoice to 'partially_paid' on a
// partial settlement. Omitting the status made these receivables
// entirely invisible to the balansdagen revaluation (ÅRL 4 kap. 13 §);
// the payables side has always included it.
const partialEur = makeInvoice({
status: 'partially_paid',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
paid_amount: 400,
remaining_amount: 600,
})
const supabase = createMockSupabase({ invoices: [partialEur] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'company-1')
expect(result).toHaveLength(1)
expect(result[0].status).toBe('partially_paid')
})
// INVERTED (was: 'excludes invoices without exchange_rate'). The SQL
// `.not('exchange_rate','is',null)` filter meant the invoices with the
// largest unmeasured FX exposure never reached the caller and were never
// reported. The fetcher now returns them; previewCurrencyRevaluation
// partitions and counts them (see 'reports unconverted rows' below).
it('returns invoices without exchange_rate so the caller can report them', async () => {
const noRateInvoice = makeInvoice({
status: 'sent',
currency: 'EUR',
@@ -233,7 +276,8 @@ describe('currency-revaluation', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'company-1')
expect(result).toHaveLength(0)
expect(result).toHaveLength(1)
expect(result[0].exchange_rate).toBeNull()
})
})
@@ -291,17 +335,6 @@ describe('currency-revaluation', () => {
})
describe('previewCurrencyRevaluation', () => {
function mockRates(rates: Record<Currency, number>) {
mockedFetchRates.mockResolvedValue(
new Map(
Object.entries(rates).map(([currency, rate]) => [
currency as Currency,
{ currency: currency as Currency, rate, date: '2024-12-31' },
])
)
)
}
it('returns empty preview when no foreign currency items', async () => {
const supabase = createMockSupabase({
invoices: [],
@@ -326,7 +359,7 @@ describe('currency-revaluation', () => {
invoice_number: 'F-001',
})
mockRates({ EUR: 11.5 } as Record<Currency, number>)
mockRates({ EUR: 11.5 })
const supabase = createMockSupabase({ invoices: [eurInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -359,7 +392,7 @@ describe('currency-revaluation', () => {
invoice_number: 'F-002',
})
mockRates({ EUR: 11.5 } as Record<Currency, number>)
mockRates({ EUR: 11.5 })
const supabase = createMockSupabase({ invoices: [eurInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -390,7 +423,7 @@ describe('currency-revaluation', () => {
supplier_invoice_number: 'LF-001',
})
mockRates({ EUR: 11.5 } as Record<Currency, number>)
mockRates({ EUR: 11.5 })
const supabase = createMockSupabase({ supplierInvoices: [eurSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -418,7 +451,7 @@ describe('currency-revaluation', () => {
supplier_invoice_number: 'LF-002',
})
mockRates({ EUR: 11.5 } as Record<Currency, number>)
mockRates({ EUR: 11.5 })
const supabase = createMockSupabase({ supplierInvoices: [eurSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -453,7 +486,7 @@ describe('currency-revaluation', () => {
invoice_number: 'F-USD',
})
mockRates({ EUR: 11.5, USD: 10.5 } as Record<Currency, number>)
mockRates({ EUR: 11.5, USD: 10.5 })
const supabase = createMockSupabase({ invoices: [eurInvoice, usdInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -484,7 +517,7 @@ describe('currency-revaluation', () => {
})
// EUR went up to 11.5
mockRates({ EUR: 11.5 } as Record<Currency, number>)
mockRates({ EUR: 11.5 })
const supabase = createMockSupabase({
invoices: [gainInvoice],
@@ -516,7 +549,7 @@ describe('currency-revaluation', () => {
supplier_invoice_number: 'LF-PARTIAL',
})
mockRates({ EUR: 11.5 } as Record<Currency, number>)
mockRates({ EUR: 11.5 })
const supabase = createMockSupabase({ supplierInvoices: [partialSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -527,6 +560,99 @@ describe('currency-revaluation', () => {
expect(preview.items[0].difference_sek).toBe(2500) // 5000 * (11.5 - 11.0)
})
it('revalues only the outstanding amount of a partially paid receivable', async () => {
// Hand-computed: 1 000 EUR invoiced at 11,00 (11 000 kr on 1510),
// 400 EUR since paid. Outstanding = 600 EUR. Closing 11,50:
// originalSek = 600 * 11,00 = 6 600,00
// closingSek = 600 * 11,50 = 6 900,00
// unrealized gain = 300,00 kr (NOT 500,00 from the 1 000 EUR face value)
const partialEur = makeInvoice({
id: 'inv-partial',
status: 'partially_paid',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
paid_amount: 400,
remaining_amount: 600,
invoice_number: 'F-PARTIAL',
})
mockRates({ EUR: 11.5 })
const supabase = createMockSupabase({ invoices: [partialEur] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
expect(preview.items).toHaveLength(1)
expect(preview.items[0].amount_in_currency).toBe(600)
expect(preview.items[0].original_sek).toBe(6600)
expect(preview.items[0].closing_sek).toBe(6900)
expect(preview.items[0].difference_sek).toBe(300)
const debit1510 = preview.lines.find(l => l.account_number === '1510' && l.debit_amount > 0)
const credit3960 = preview.lines.find(l => l.account_number === '3960' && l.credit_amount > 0)
expect(debit1510!.debit_amount).toBe(300)
expect(credit3960!.credit_amount).toBe(300)
// Balanced to the öre.
const totalDebit = preview.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = preview.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(Math.round(totalDebit * 100)).toBe(Math.round(totalCredit * 100))
})
it('skips a receivable with nothing outstanding instead of revaluing its face value', async () => {
// Edge: status still open but paid_amount covers the total (e.g. the
// status update lagged the payment sync). There is no monetary item
// left to value and nothing to report as unconverted either.
const settledEur = makeInvoice({
id: 'inv-settled',
status: 'partially_paid',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
paid_amount: 1000,
remaining_amount: 0,
})
mockRates({ EUR: 11.5 })
const supabase = createMockSupabase({ invoices: [settledEur] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
expect(preview.items).toHaveLength(0)
expect(preview.lines).toHaveLength(0)
expect(preview.unconvertedFxCount).toBe(0)
expect(mockedFetchRate).not.toHaveBeenCalled()
})
it('reports the outstanding amount (not face value) for an unrated partially paid receivable', async () => {
const unratedPartial = makeInvoice({
id: 'inv-unrated-partial',
status: 'partially_paid',
currency: 'USD',
exchange_rate: null,
total: 4000,
paid_amount: 1500,
remaining_amount: 2500,
invoice_number: 'F-UNRATED-PARTIAL',
})
mockRates({})
const supabase = createMockSupabase({ invoices: [unratedPartial] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
expect(preview.unconvertedFxCount).toBe(1)
expect(preview.unconvertedFx[0]).toMatchObject({
source_id: 'inv-unrated-partial',
type: 'receivable',
currency: 'USD',
amount_in_currency: 2500,
})
})
it('skips items with zero difference', async () => {
const eurInvoice = makeInvoice({
id: 'inv-same',
@@ -538,7 +664,7 @@ describe('currency-revaluation', () => {
})
// Closing rate equals original rate
mockRates({ EUR: 11.5 } as Record<Currency, number>)
mockRates({ EUR: 11.5 })
const supabase = createMockSupabase({ invoices: [eurInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -548,6 +674,123 @@ describe('currency-revaluation', () => {
expect(preview.lines).toHaveLength(0)
})
it('reports rows with no exchange_rate as unconverted instead of dropping them', async () => {
const ratedInvoice = makeInvoice({
id: 'inv-rated',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
invoice_number: 'F-RATED',
})
const unratedInvoice = makeInvoice({
id: 'inv-unrated',
status: 'sent',
currency: 'USD',
exchange_rate: null,
total: 4000,
invoice_number: 'F-UNRATED',
})
const unratedSI = makeSupplierInvoice({
id: 'si-unrated',
status: 'registered',
currency: 'GBP',
exchange_rate: null,
remaining_amount: 700,
supplier_invoice_number: 'LF-UNRATED',
})
mockRates({ EUR: 11.5 })
const supabase = createMockSupabase({
invoices: [ratedInvoice, unratedInvoice],
supplierInvoices: [unratedSI],
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
// Only the rated invoice is revalued.
expect(preview.items).toHaveLength(1)
expect(preview.items[0].source_id).toBe('inv-rated')
// The other two are surfaced, not silently swallowed.
expect(preview.unconvertedFxCount).toBe(2)
expect(preview.unconvertedFx.map((u) => u.source_id).sort()).toEqual([
'inv-unrated',
'si-unrated',
])
expect(preview.unconvertedFx.find((u) => u.source_id === 'inv-unrated')).toMatchObject({
type: 'receivable',
currency: 'USD',
reference: 'F-UNRATED',
amount_in_currency: 4000,
})
expect(preview.unconvertedFx.find((u) => u.source_id === 'si-unrated')).toMatchObject({
type: 'payable',
currency: 'GBP',
reference: 'LF-UNRATED',
amount_in_currency: 700,
})
// No rate was ever requested for a currency we cannot revalue anyway.
expect(mockedFetchRate).toHaveBeenCalledTimes(1)
expect(mockedFetchRate.mock.calls[0][0]).toBe('EUR')
// Looked up for the closing date (not "today") and through the shared
// exchange_rates cache, so the balansdagen rate stays reproducible.
expect(mockedFetchRate.mock.calls[0][1]).toEqual(new Date('2024-12-31'))
expect(mockedFetchRate.mock.calls[0][2]).toBe(supabase)
})
it('treats a zero exchange_rate as unconverted instead of valuing the row at 0 SEK', async () => {
const zeroRateInvoice = makeInvoice({
id: 'inv-zero',
status: 'sent',
currency: 'EUR',
exchange_rate: 0,
total: 1000,
invoice_number: 'F-ZERO',
})
mockRates({ EUR: 11.5 })
const supabase = createMockSupabase({ invoices: [zeroRateInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
// A 0 original rate makes originalSek 0, so the whole 11 500 SEK would
// read as an orealiserad kursvinst on 3960. Same > 0 rule the reskontra
// reports use for unconverted_fx_count.
expect(preview.items).toHaveLength(0)
expect(preview.lines).toHaveLength(0)
expect(preview.unconvertedFxCount).toBe(1)
expect(preview.unconvertedFx[0].source_id).toBe('inv-zero')
expect(mockedFetchRate).not.toHaveBeenCalled()
})
it('reports a missing closing rate instead of substituting a fallback constant', async () => {
const eurInvoice = makeInvoice({
id: 'inv-norate',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
invoice_number: 'F-NORATE',
})
// Riksbanken unreachable and cache empty: fetchExchangeRate resolves null.
mockRates({})
const supabase = createMockSupabase({ invoices: [eurInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
expect(preview.missingClosingRates).toEqual([{ currency: 'EUR', date: '2024-12-31' }])
// Nothing is valued off a guessed rate: no items, no lines, no 11.5.
expect(preview.items).toHaveLength(0)
expect(preview.lines).toHaveLength(0)
expect(preview.closingRates).toEqual({})
})
it('all generated journal lines balance (debits === credits)', async () => {
const eurInvoice = makeInvoice({
id: 'inv-bal',
@@ -566,7 +809,7 @@ describe('currency-revaluation', () => {
supplier_invoice_number: 'LF-BAL',
})
mockRates({ EUR: 11.8, GBP: 13.5 } as Record<Currency, number>)
mockRates({ EUR: 11.8, GBP: 13.5 })
const supabase = createMockSupabase({
invoices: [eurInvoice],
@@ -582,17 +825,6 @@ describe('currency-revaluation', () => {
})
describe('executeCurrencyRevaluation', () => {
function mockRates(rates: Record<Currency, number>) {
mockedFetchRates.mockResolvedValue(
new Map(
Object.entries(rates).map(([currency, rate]) => [
currency as Currency,
{ currency: currency as Currency, rate, date: '2024-12-31' },
])
)
)
}
it('returns null when no foreign currency items exist', async () => {
const supabase = createFullMockSupabase({
invoices: [],
@@ -600,7 +832,7 @@ describe('currency-revaluation', () => {
existingRevaluation: false,
})
mockRates({} as Record<Currency, number>)
mockRates({})
const result = await executeCurrencyRevaluation(supabase, 'company-1', '2024-12-31', 'period-1')
@@ -617,7 +849,7 @@ describe('currency-revaluation', () => {
invoice_number: 'F-001',
})
mockRates({ EUR: 11.5 } as Record<Currency, number>)
mockRates({ EUR: 11.5 })
const supabase = createFullMockSupabase({
invoices: [eurInvoice],
@@ -648,6 +880,175 @@ describe('currency-revaluation', () => {
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('books the receivable gain to 1510 / 3960 with a real Riksbanken rate', async () => {
const eurInvoice = makeInvoice({
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
invoice_number: 'F-001',
})
const gbpSI = makeSupplierInvoice({
status: 'registered',
currency: 'GBP',
exchange_rate: 14.0,
remaining_amount: 500,
supplier_invoice_number: 'LF-001',
})
// EUR up (receivable gain), GBP down (payable gain).
mockRates({ EUR: 11.5, GBP: 13.5 })
const supabase = createFullMockSupabase({
invoices: [eurInvoice],
supplierInvoices: [gbpSI],
existingRevaluation: false,
})
await executeCurrencyRevaluation(supabase, 'company-1', '2024-12-31', 'period-1')
const lines = mockedCreateEntry.mock.calls[0][3].lines
const find = (account: string, side: 'debit_amount' | 'credit_amount') =>
lines.find((l) => l.account_number === account && l[side] > 0)
// Receivable revalued up 1000 * 0.5 = 500 → Dr 1510 / Cr 3960
expect(find('1510', 'debit_amount')!.debit_amount).toBe(500)
// Payable shrank 500 * 0.5 = 250 → Dr 2440 / Cr 3960
expect(find('2440', 'debit_amount')!.debit_amount).toBe(250)
expect(find('3960', 'credit_amount')!.credit_amount).toBe(750)
expect(find('7960', 'debit_amount')).toBeUndefined()
const totalDebit = lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.round(totalDebit * 100) / 100).toBe(Math.round(totalCredit * 100) / 100)
expect(totalDebit).toBeGreaterThan(0)
})
it('refuses to post when the closing rate is unavailable', async () => {
const eurInvoice = makeInvoice({
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
invoice_number: 'F-001',
})
// No rate at all. Before the fix, fetchMultipleRates padded its Map with
// the display-only fallback (EUR 11.5) and this posted a real 3960/7960
// verifikat computed from a hardcoded constant.
mockRates({})
const supabase = createFullMockSupabase({
invoices: [eurInvoice],
existingRevaluation: false,
})
await expect(
executeCurrencyRevaluation(supabase, 'company-1', '2024-12-31', 'period-1')
).rejects.toMatchObject({
code: 'FX_CLOSING_RATE_UNAVAILABLE',
missingRates: [{ currency: 'EUR', date: '2024-12-31' }],
})
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('translates the refusal into a Swedish message naming currency and date', async () => {
const eurInvoice = makeInvoice({
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
invoice_number: 'F-001',
})
mockRates({})
const supabase = createFullMockSupabase({
invoices: [eurInvoice],
existingRevaluation: false,
})
const err = await executeCurrencyRevaluation(
supabase, 'company-1', '2024-12-31', 'period-1'
).catch((e: unknown) => e)
const message = getErrorMessage(err)
expect(message).toContain('EUR per 2024-12-31')
expect(message).toContain('har inte bokförts')
// Never leak the raw English engine message into a Swedish UI.
expect(message).not.toContain('Riksbanken exchange rate available')
})
it('refuses a partial valuation when only some currencies have a rate', async () => {
const eurInvoice = makeInvoice({
id: 'inv-eur',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
invoice_number: 'F-EUR',
})
const usdInvoice = makeInvoice({
id: 'inv-usd',
status: 'sent',
currency: 'USD',
exchange_rate: 10.0,
total: 500,
invoice_number: 'F-USD',
})
mockRates({ EUR: 11.5 })
const supabase = createFullMockSupabase({
invoices: [eurInvoice, usdInvoice],
existingRevaluation: false,
})
await expect(
executeCurrencyRevaluation(supabase, 'company-1', '2024-12-31', 'period-1')
).rejects.toMatchObject({
code: 'FX_CLOSING_RATE_UNAVAILABLE',
missingRates: [{ currency: 'USD', date: '2024-12-31' }],
})
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('surfaces unconverted rows on the result so the caller can warn', async () => {
const ratedInvoice = makeInvoice({
id: 'inv-rated',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
invoice_number: 'F-RATED',
})
const unratedInvoice = makeInvoice({
id: 'inv-unrated',
status: 'sent',
currency: 'EUR',
exchange_rate: null,
total: 9000,
invoice_number: 'F-UNRATED',
})
mockRates({ EUR: 11.5 })
const supabase = createFullMockSupabase({
invoices: [ratedInvoice, unratedInvoice],
existingRevaluation: false,
})
const result = await executeCurrencyRevaluation(
supabase, 'company-1', '2024-12-31', 'period-1'
)
expect(result).not.toBeNull()
expect(result!.preview.unconvertedFxCount).toBe(1)
expect(result!.preview.unconvertedFx[0].source_id).toBe('inv-unrated')
})
it('returns entry and preview in result', async () => {
const eurInvoice = makeInvoice({
status: 'sent',
@@ -657,7 +1058,7 @@ describe('currency-revaluation', () => {
invoice_number: 'F-001',
})
mockRates({ EUR: 12.0 } as Record<Currency, number>)
mockRates({ EUR: 12.0 })
const supabase = createFullMockSupabase({
invoices: [eurInvoice],
@@ -1,6 +1,90 @@
import { describe, it, expect } from 'vitest'
import { resolveSekAmount, buildCurrencyMetadata } from '../currency-utils'
import {
resolveSekAmount,
resolveSekAmountOrNull,
buildCurrencyMetadata,
} from '../currency-utils'
/**
* The strict ladder. Booking code must use this one: it answers "no SEK value
* can be established" with null instead of handing back the foreign amount.
*/
describe('resolveSekAmountOrNull', () => {
it('returns amount as-is for SEK currency', () => {
expect(resolveSekAmountOrNull(1000, null, 'SEK', null)).toBe(1000)
})
it('returns amount as-is when currency is null (legacy data means SEK)', () => {
expect(resolveSekAmountOrNull(1000, null, null, null)).toBe(1000)
})
it('returns amount as-is when currency is undefined', () => {
expect(resolveSekAmountOrNull(1000, null, undefined, null)).toBe(1000)
})
it('returns amountSek when populated for foreign currency', () => {
expect(resolveSekAmountOrNull(100, 1150, 'EUR', 11.5)).toBe(1150)
})
it('rounds amountSek to öre', () => {
expect(resolveSekAmountOrNull(100, 1150.456, 'EUR', 11.5)).toBe(1150.46)
})
it('computes via exchangeRate when amountSek is null', () => {
expect(resolveSekAmountOrNull(100, null, 'EUR', 11.5)).toBe(1150)
})
it('rounds the computed amount to öre', () => {
// 100.33 * 11.5 = 1153.795 -> 1153.8
expect(resolveSekAmountOrNull(100.33, null, 'EUR', 11.5)).toBe(1153.8)
})
it('prefers amountSek over exchangeRate computation', () => {
expect(resolveSekAmountOrNull(100, 1200, 'EUR', 11.5)).toBe(1200)
})
it('handles negative amounts', () => {
expect(resolveSekAmountOrNull(-100, null, 'EUR', 11.5)).toBe(-1150)
})
it('handles zero amount', () => {
expect(resolveSekAmountOrNull(0, null, 'EUR', 11.5)).toBe(0)
})
// The whole point of the strict sibling: a foreign amount with no SEK value
// and no rate is refused, never relabelled as kronor. 100 EUR is not 100 SEK.
it('returns null when a foreign amount has neither amountSek nor exchangeRate', () => {
expect(resolveSekAmountOrNull(100, null, 'EUR', null)).toBeNull()
})
it('returns null when exchangeRate is 0', () => {
expect(resolveSekAmountOrNull(100, null, 'EUR', 0)).toBeNull()
})
it('returns null when exchangeRate is negative', () => {
expect(resolveSekAmountOrNull(100, null, 'EUR', -11.5)).toBeNull()
})
it('returns null when exchangeRate is undefined', () => {
expect(resolveSekAmountOrNull(100, null, 'EUR', undefined)).toBeNull()
})
it('never returns null for SEK, whatever the rate looks like', () => {
expect(resolveSekAmountOrNull(100, null, 'SEK', null)).toBe(100)
expect(resolveSekAmountOrNull(100, null, 'SEK', 0)).toBe(100)
})
})
/**
* The lenient wrapper, kept for read-only callers over legacy pre-FX rows.
*
* Its fallback behaviour is UNCHANGED and deliberately still pinned below: the
* root fix added `resolveSekAmountOrNull()` as a strict sibling rather than
* flipping this function's return type, so no unread call site could shift. The
* two "falls back to the original amount" cases therefore still pass, and they
* now assert that the lenient contract is intact rather than that guessing is
* acceptable: the matching strict cases above assert the refusal.
*/
describe('resolveSekAmount', () => {
it('returns amount as-is for SEK currency', () => {
expect(resolveSekAmount(1000, null, 'SEK', null)).toBe(1000)
@@ -31,12 +115,37 @@ describe('resolveSekAmount', () => {
expect(resolveSekAmount(100.33, null, 'EUR', 11.5)).toBe(1153.8)
})
it('falls back to original amount when both amountSek and exchangeRate are null', () => {
// These two are the legacy-reader contract, NOT a statement that 100 EUR may
// be booked as 100 SEK. Booking code must call resolveSekAmountOrNull(), which
// returns null for exactly these inputs (see the strict block above).
it('falls back to original amount when both amountSek and exchangeRate are null (legacy readers only)', () => {
expect(resolveSekAmount(100, null, 'EUR', null)).toBe(100)
expect(resolveSekAmountOrNull(100, null, 'EUR', null)).toBeNull()
})
it('falls back when exchangeRate is 0', () => {
it('falls back when exchangeRate is 0 (legacy readers only)', () => {
expect(resolveSekAmount(100, null, 'EUR', 0)).toBe(100)
expect(resolveSekAmountOrNull(100, null, 'EUR', 0)).toBeNull()
})
// The lenient function is a thin wrapper: everything except the last resort
// must be byte-identical to the strict ladder, so the two can never drift.
it('delegates every resolvable case to resolveSekAmountOrNull', () => {
const cases: Array<[number, number | null, string | null, number | null]> = [
[1000, null, 'SEK', null],
[1000, null, null, null],
[100, 1150, 'EUR', 11.5],
[100, 1150.456, 'EUR', 11.5],
[100.33, null, 'EUR', 11.5],
[-100, null, 'EUR', 11.5],
[0, null, 'EUR', 11.5],
[100, 1200, 'EUR', 11.5],
]
for (const [amount, amountSek, currency, rate] of cases) {
expect(resolveSekAmount(amount, amountSek, currency, rate)).toBe(
resolveSekAmountOrNull(amount, amountSek, currency, rate)
)
}
})
it('handles negative amounts correctly', () => {
@@ -39,6 +39,24 @@ function buildSupabase(tables: Record<string, TableResult>) {
const inserts: Record<string, unknown[]> = {}
const updates: Record<string, unknown[]> = {}
// commitEntry's mandatory-dimension check uses the two-step entry-lines
// fetch (lib/bookkeeping/entry-lines.ts): journal_entries is paged first,
// then journal_entry_lines by parent id, and the parent is reattached under
// `journal_entries`. Fixtures stay embed-shaped (a line carrying its parent
// source_type); the two pages are derived from them here. `.range()` is the
// paging terminal, while `.single()`/`.maybeSingle()` keep serving the
// row-shaped results the rest of the engine reads.
const lineFixtures = (tables.journal_entry_lines?.data ?? null) as
| Array<Record<string, unknown>>
| null
const entryPage = lineFixtures
? [{ id: 'entry-1', ...((lineFixtures[0]?.journal_entries as object) ?? {}) }]
: []
const linePage = (lineFixtures ?? []).map((line, i) => {
const { journal_entries: _parent, ...rest } = line
return { id: `line-${i}`, journal_entry_id: 'entry-1', ...rest }
})
const from = vi.fn().mockImplementation((table: string) => {
const result = tables[table] ?? {}
const resolved = { data: result.data ?? null, error: result.error ?? null }
@@ -46,6 +64,11 @@ function buildSupabase(tables: Record<string, TableResult>) {
for (const m of ['select', 'eq', 'in', 'delete', 'order', 'limit', 'lte', 'gte']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.range = vi.fn().mockImplementation(() => {
if (table === 'journal_entries') return Promise.resolve({ data: entryPage, error: null })
if (table === 'journal_entry_lines') return Promise.resolve({ data: linePage, error: null })
return Promise.resolve(resolved)
})
chain.insert = vi.fn().mockImplementation((payload: unknown) => {
;(inserts[table] ??= []).push(payload)
return chain
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest'
import {
extractFiscalPeriodWarnings,
fiscalPeriodAdvisoryText,
} from '../fiscal-period-warnings'
/** The exact 200 body the route returns when a prior räkenskapsår is open. */
const PRIOR_OPEN_MESSAGE =
'Räkenskapsåret är skapat och du kan bokföra i det direkt. FY2025 ä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.'
const successWithAdvisory = {
data: { id: 'p1', name: 'FY2026' },
warnings: [{ code: 'PRIOR_FISCAL_YEAR_STILL_OPEN', message: PRIOR_OPEN_MESSAGE }],
}
/** The plain 200 body: the route omits `warnings` entirely. */
const successWithoutAdvisory = { data: { id: 'p1', name: 'FY2026' } }
describe('extractFiscalPeriodWarnings', () => {
it('reads the PRIOR_FISCAL_YEAR_STILL_OPEN advisory off a success response', () => {
expect(extractFiscalPeriodWarnings(successWithAdvisory)).toEqual([
{ code: 'PRIOR_FISCAL_YEAR_STILL_OPEN', message: PRIOR_OPEN_MESSAGE },
])
})
it('returns nothing when the route omits the warnings key', () => {
expect(extractFiscalPeriodWarnings(successWithoutAdvisory)).toEqual([])
})
it('returns nothing for an empty warnings array', () => {
expect(extractFiscalPeriodWarnings({ data: {}, warnings: [] })).toEqual([])
})
it('returns nothing for null, non-objects and a non-array warnings value', () => {
expect(extractFiscalPeriodWarnings(null)).toEqual([])
expect(extractFiscalPeriodWarnings(undefined)).toEqual([])
expect(extractFiscalPeriodWarnings('boom')).toEqual([])
expect(extractFiscalPeriodWarnings({ warnings: 'nope' })).toEqual([])
expect(extractFiscalPeriodWarnings({ warnings: { code: 'X', message: 'Y' } })).toEqual([])
})
it('drops malformed entries instead of rendering a blank advisory', () => {
const payload = {
data: {},
warnings: [
null,
'string entry',
{ code: 'NO_MESSAGE' },
{ message: 'no code' },
{ code: '', message: 'empty code' },
{ code: 'BLANK', message: ' ' },
{ code: 'OK', message: 'Detta visas.' },
],
}
expect(extractFiscalPeriodWarnings(payload)).toEqual([{ code: 'OK', message: 'Detta visas.' }])
})
it('keeps multiple well-formed warnings in order', () => {
const payload = {
warnings: [
{ code: 'A', message: 'Första.' },
{ code: 'B', message: 'Andra.' },
],
}
expect(extractFiscalPeriodWarnings(payload).map((w) => w.code)).toEqual(['A', 'B'])
})
// The old behaviour: creation was REFUSED with 409
// PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS and the blocking periods rode in
// `error.details`. That payload carries no advisory, so the new UI shows
// none and falls through to its error toast, as it should.
it('returns nothing for the retired 409 error envelope', () => {
const legacy409 = {
error: {
code: 'PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS',
message: 'Du måste låsa föregående räkenskapsår innan du kan skapa ett nytt.',
details: { blockingPeriods: [{ id: 'p0', name: 'FY2025' }] },
},
}
expect(extractFiscalPeriodWarnings(legacy409)).toEqual([])
})
})
describe('fiscalPeriodAdvisoryText', () => {
it('is the message itself for the single advisory the route emits', () => {
expect(fiscalPeriodAdvisoryText(successWithAdvisory)).toBe(PRIOR_OPEN_MESSAGE)
})
it('is empty on a clean success, so the UI renders no attn line', () => {
expect(fiscalPeriodAdvisoryText(successWithoutAdvisory)).toBe('')
expect(fiscalPeriodAdvisoryText({ data: {}, warnings: [] })).toBe('')
})
it('joins several advisories into one ochre sentence run', () => {
const payload = {
warnings: [
{ code: 'A', message: 'Första.' },
{ code: 'B', message: 'Andra.' },
],
}
expect(fiscalPeriodAdvisoryText(payload)).toBe('Första. Andra.')
})
})
@@ -0,0 +1,263 @@
import { describe, it, expect } from 'vitest'
import {
resolveFxLineSlot,
isMonetaryLedgerAccount,
type FxSlotLine,
} from '@/lib/bookkeeping/fx-line-slot'
/**
* The FX metadata (currency / amount_in_currency / exchange_rate) is the only
* record of the foreign figure behind a SEK-denominated ledger line, and every
* consumer looks it up BY ACCOUNT: bank reconciliation resolves a foreign cash
* account against amount_in_currency on that account's lines, voucher matching
* narrows on `currency = invoice.currency` under the 1510/2440 prefix, storno
* mirrors whatever is stored. So the slot has to be the leg that actually is
* the foreign monetary item (ÅRL 4 kap. 13 §), not the first 19xx line.
*/
function line(
account_number: string,
debit: number,
credit: number,
currency?: string
): FxSlotLine {
return { account_number, debit_amount: debit, credit_amount: credit, currency }
}
const EUR_1000 = { entryCurrency: 'EUR', exchangeRate: 11.5, foreignAmount: 1000 }
describe('resolveFxLineSlot', () => {
describe('denomination, not account prefix', () => {
it('stamps the EUR leg of a SEK/EUR transfer, not the SEK leg that comes first', () => {
// 1000 EUR moved into the EUR account, paid from the SEK account. Both
// legs carry the same SEK amount, so only the account can say which one
// is the EUR side. The old rule took the first account starting with 19.
const lines = [line('1930', 0, 11500), line('1932', 11500, 0)]
const result = resolveFxLineSlot(lines, EUR_1000)
expect(result).toEqual({ kind: 'slot', index: 1 })
expect(lines[(result as { index: number }).index].account_number).toBe('1932')
})
it('is order-independent: both orders of the same transfer land on 1932', () => {
// Both orders are asserted in ONE test on purpose. The reversed order
// alone also passes the old "first account starting with 19" rule (1932
// simply happens to come first there), so on its own it would pin
// nothing; the invariant that has to hold is that the two orders agree.
const forward = [line('1930', 0, 11500), line('1932', 11500, 0)]
const reversed = [line('1932', 11500, 0), line('1930', 0, 11500)]
const forwardSlot = resolveFxLineSlot(forward, EUR_1000)
const reversedSlot = resolveFxLineSlot(reversed, EUR_1000)
expect(forwardSlot).toEqual({ kind: 'slot', index: 1 })
expect(reversedSlot).toEqual({ kind: 'slot', index: 0 })
expect(forward[(forwardSlot as { index: number }).index].account_number).toBe(
reversed[(reversedSlot as { index: number }).index].account_number
)
})
it('drops the leg known to be denominated in another currency when the EUR account is non-default', () => {
// Company books its EUR account on 1931 rather than the conventional 1932.
const lines = [line('1930', 0, 11500), line('1931', 11500, 0)]
const result = resolveFxLineSlot(lines, EUR_1000)
expect(result).toEqual({ kind: 'slot', index: 1 })
})
it('keeps the SEK cash leg when it is the only monetary leg (EUR card purchase)', () => {
// NO-REGRESSION guard, not a bug pin: the old rule reached 1930 here too.
// It exists so a later narrowing of MONETARY_ACCOUNT_PREFIXES cannot
// break the common case. A EUR purchase paid from the SEK company
// account: the cash leg is the document leg, exactly as
// transaction-entries.ts stamps it.
const lines = [line('5410', 11500, 0), line('1930', 0, 11500)]
const result = resolveFxLineSlot(lines, EUR_1000)
expect(result).toEqual({ kind: 'slot', index: 1 })
})
it('picks the payable on a EUR supplier invoice with no 19xx line at all', () => {
// The case where the old rule discarded the rate entirely.
const lines = [line('4010', 9200, 0), line('2641', 2300, 0), line('2440', 0, 11500)]
const result = resolveFxLineSlot(lines, EUR_1000)
expect(result).toEqual({ kind: 'slot', index: 2 })
})
it('picks the payable over the non-monetary asset leg', () => {
// A EUR machine: 1220 is measured once in SEK and never revalued, the
// payable is the monetary item.
const lines = [line('1220', 11500, 0), line('2440', 0, 11500)]
expect(resolveFxLineSlot(lines, EUR_1000)).toEqual({ kind: 'slot', index: 1 })
})
it('tolerates the double rounding of a derived foreign amount', () => {
// Pins the new rule's tolerance, not a behaviour change: the old rule had
// no amount comparison at all, so it "passed" this by never checking.
// Without the tolerance the new rule would report no_carrier here.
// The form derives the foreign amount as roundOre(totalDebit / rate), so
// converting it back lands 4 öre away from the booked SEK figure.
const derived = Math.round((12500 / 11.5) * 100) / 100 // 1086.96
expect(Math.round(derived * 11.5 * 100) / 100).not.toBe(12500)
const lines = [line('5410', 12500, 0), line('1930', 0, 12500)]
expect(
resolveFxLineSlot(lines, { entryCurrency: 'EUR', exchangeRate: 11.5, foreignAmount: derived })
).toEqual({ kind: 'slot', index: 1 })
})
})
describe('pre-pass: never two lines claiming the same foreign amount', () => {
it('leaves an agent-created EUR draft alone instead of also stamping the bank leg', () => {
// MCP/agent draft: the 2440 leg already carries the EUR metadata, and the
// bank leg comes FIRST. The order-dependent latch stamped 1930 as well,
// so two lines claimed the same 1000 EUR.
const lines = [line('1930', 0, 11500), line('2440', 11500, 0, 'EUR')]
const result = resolveFxLineSlot(lines, EUR_1000)
expect(result).toEqual({ kind: 'preset', indexes: [1] })
})
it('resolves the same whichever side the hydrated FX line is on', () => {
// Both orders in one test for the same reason as the transfer case above:
// with the hydrated line FIRST the old order-dependent latch happened to
// close before it reached the bank leg, so that order alone passed the
// old rule too. Only the agreement between the orders is the invariant.
const fxFirst = [line('2440', 11500, 0, 'EUR'), line('1930', 0, 11500)]
const fxLast = [line('1930', 0, 11500), line('2440', 11500, 0, 'EUR')]
expect(resolveFxLineSlot(fxFirst, EUR_1000)).toEqual({ kind: 'preset', indexes: [0] })
expect(resolveFxLineSlot(fxLast, EUR_1000)).toEqual({ kind: 'preset', indexes: [1] })
})
it('reports a conflict when the line currency is not the entry currency', () => {
const lines = [line('1930', 0, 11500), line('2440', 11500, 0, 'USD')]
expect(resolveFxLineSlot(lines, EUR_1000)).toEqual({
kind: 'unplaceable',
reason: 'currency_conflict',
accounts: ['2440'],
lineCurrency: 'USD',
})
})
})
describe('refuses instead of discarding the rate', () => {
it('reports no_carrier when no monetary leg matches the foreign amount', () => {
// journal_entries has no currency/exchange_rate column, so a rate that is
// not written to a line is gone: the caller must refuse, not post.
const lines = [line('1930', 0, 11500), line('5410', 11500, 0)]
const result = resolveFxLineSlot(lines, {
entryCurrency: 'EUR',
exchangeRate: 11.5,
foreignAmount: 900, // 10 350 SEK: matches neither leg
})
expect(result).toMatchObject({ kind: 'unplaceable', reason: 'no_carrier' })
})
it('reports no_carrier when the entry has no monetary leg at all', () => {
const lines = [line('5410', 11500, 0), line('3001', 0, 11500)]
expect(resolveFxLineSlot(lines, EUR_1000)).toMatchObject({
kind: 'unplaceable',
reason: 'no_carrier',
})
})
it('does not fall back onto a leg explicitly labelled SEK', () => {
const lines = [line('1930', 0, 11500, 'SEK'), line('5410', 11500, 0)]
expect(resolveFxLineSlot(lines, EUR_1000)).toMatchObject({
kind: 'unplaceable',
reason: 'no_carrier',
})
})
it('reports ambiguous when two indistinguishable monetary legs match', () => {
const lines = [line('1650', 11500, 0), line('2890', 0, 11500)]
expect(resolveFxLineSlot(lines, EUR_1000)).toEqual({
kind: 'unplaceable',
reason: 'ambiguous',
accounts: ['1650', '2890'],
})
})
it('reports ambiguous when the same account carries the amount twice', () => {
const lines = [line('1932', 11500, 0), line('1932', 11500, 0), line('1930', 0, 23000)]
expect(resolveFxLineSlot(lines, EUR_1000)).toMatchObject({
kind: 'unplaceable',
reason: 'ambiguous',
})
})
})
// NO-REGRESSION guards: the old rule produced the same answers in all three
// of these. They are here so the refusal added above cannot start firing on
// the SEK path or on a half-filled currency panel, which would block routine
// bookkeeping for the 95% of entries that carry no FX at all.
describe('nothing to place', () => {
it('returns none for a SEK entry', () => {
const lines = [line('1930', 0, 11500), line('3001', 11500, 0)]
expect(
resolveFxLineSlot(lines, { entryCurrency: 'SEK', exchangeRate: 0, foreignAmount: 0 })
).toEqual({ kind: 'none' })
})
it('returns none while the rate is still missing', () => {
const lines = [line('1930', 0, 11500), line('1932', 11500, 0)]
expect(
resolveFxLineSlot(lines, { entryCurrency: 'EUR', exchangeRate: 0, foreignAmount: 1000 })
).toEqual({ kind: 'none' })
})
it('preserves hydrated FX lines when the picker is back on SEK', () => {
const lines = [line('1930', 0, 11500), line('2440', 11500, 0, 'EUR')]
expect(
resolveFxLineSlot(lines, { entryCurrency: 'SEK', exchangeRate: 0, foreignAmount: 0 })
).toEqual({ kind: 'preset', indexes: [1] })
})
})
})
describe('isMonetaryLedgerAccount', () => {
it('accepts fordringar, kassa/bank and skulder', () => {
for (const account of ['1510', '1650', '1790', '1930', '1932', '2440', '2510', '2890', '2990']) {
expect(isMonetaryLedgerAccount(account)).toBe(true)
}
})
it('rejects non-monetary items, VAT and P&L accounts', () => {
// 1220 inventarier, 1460 lager, 1810 placeringar, 2081 aktiekapital,
// 2150 överavskrivningar, 2220 avsättningar, 2611/2641 moms, 4010/5410/3001.
for (const account of [
'1220',
'1460',
'1810',
'2081',
'2150',
'2220',
'2611',
'2641',
'4010',
'5410',
'3001',
]) {
expect(isMonetaryLedgerAccount(account)).toBe(false)
}
})
})
@@ -691,6 +691,326 @@ describe('createInvoiceJournalEntry: EUR foreign currency', () => {
})
})
/**
* A foreign invoice with no exchange rate has no SEK value at item granularity
* (InvoiceItem has no *_sek column). The old fallback returned the raw foreign
* amount, and since the 1510 debit is derived from the sum of the credits on the
* FX branch, the verifikation still balanced: 1 000 EUR posted as 1 000 kr to
* 3001 and 250 kr to 2611 instead of 11 500 kr and 2 875 kr, understating ruta
* 05 and ruta 10. Nothing downstream could detect it, so the generator refuses.
*/
describe('foreign currency without an exchange rate is refused, not relabelled', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function eurInvoiceWithoutRate(overrides: Partial<Invoice> = {}): Invoice {
return makeInvoice({
currency: 'EUR',
exchange_rate: null,
subtotal: 1000,
subtotal_sek: null,
vat_amount: 250,
vat_amount_sek: null,
total: 1250,
total_sek: null,
vat_treatment: 'standard_25',
items: [makeItem({ line_total: 1000, vat_rate: 25, vat_amount: 250 })],
...overrides,
})
}
it('createInvoiceJournalEntry throws INVOICE_FX_RATE_MISSING and posts nothing', async () => {
await expect(
createInvoiceJournalEntry(null as never, 'company-1', 'user-1', eurInvoiceWithoutRate())
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING', currency: 'EUR' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('refuses a zero exchange rate the same way', async () => {
await expect(
createInvoiceJournalEntry(
null as never,
'company-1',
'user-1',
eurInvoiceWithoutRate({ exchange_rate: 0 })
)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createInvoiceCashEntry refuses too', async () => {
await expect(
createInvoiceCashEntry(
null as never,
'company-1',
'user-1',
eurInvoiceWithoutRate(),
'2024-06-20'
)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createCreditNoteJournalEntry refuses too', async () => {
const creditNote = eurInvoiceWithoutRate({
credited_invoice_id: 'inv-original',
subtotal: -1000,
vat_amount: -250,
total: -1250,
items: [makeItem({ line_total: -1000, quantity: -1, vat_rate: 25, vat_amount: -250 })],
})
await expect(
createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
// generateRotRutLines has its own conversion; 1513 is a kronor receivable on
// Skatteverket, so it must refuse on the same terms. Note that
// generatePerRateLines runs first and refuses first, so this asserts the
// outcome for a ROT invoice; the positive case below is what proves the 1513
// leg itself goes through the honest ladder rather than a private fallback.
it('a ROT invoice in EUR without a rate is refused, nothing lands on 1513', async () => {
const invoice = eurInvoiceWithoutRate({
items: [
makeItem({
line_total: 1000,
unit_price: 1000,
quantity: 1,
vat_rate: 25,
vat_amount: 250,
deduction_type: 'rot',
} as Partial<InvoiceItem>),
],
})
await expect(
createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('the ROT 1513 leg converts at the rate and the split still balances', async () => {
// 1 000 EUR labour at 11,50 = 11 500 kr; ROT is 30% of labour = 3 450 kr on
// 1513, so 1510 carries 14 375 - 3 450 = 10 925 kr.
const invoice = eurInvoiceWithoutRate({
exchange_rate: 11.5,
items: [
makeItem({
line_total: 1000,
unit_price: 1000,
quantity: 1,
vat_rate: 25,
vat_amount: 250,
deduction_type: 'rot',
} as Partial<InvoiceItem>),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1513 = input.lines.find((l) => l.account_number === '1513')
expect(debit1513?.debit_amount).toBe(3450)
expect(input.lines.find((l) => l.account_number === '1510')?.debit_amount).toBe(10925)
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(Math.round(totalDebit * 100)).toBe(Math.round(totalCredit * 100))
})
it('a SEK invoice with no exchange rate is completely unaffected', async () => {
const invoice = makeInvoice({
currency: 'SEK',
exchange_rate: null,
subtotal: 1000,
vat_amount: 250,
total: 1250,
items: [makeItem({ line_total: 1000, vat_rate: 25, vat_amount: 250 })],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBe(1250)
expect(totalDebit).toBe(totalCredit)
})
it('an EUR invoice WITH a rate still books, and still balances', async () => {
const invoice = eurInvoiceWithoutRate({ exchange_rate: 11.5 })
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '3001')?.credit_amount).toBe(11500)
expect(input.lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(2875)
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBe(14375)
expect(totalDebit).toBe(totalCredit)
})
})
/**
* The no-items header fallback and the payment entry used to convert through
* the lenient resolveSekAmount ladder (READ-ONLY CODE ONLY per currency-utils):
* a rate-less foreign invoice booked via a caller without hydrated items posted
* its raw foreign number as kronor (1 250 EUR → 1 250 kr on 1510), balanced and
* undetectable. They now refuse with the same INVOICE_FX_RATE_MISSING code as
* the item-driven generators. Rows that DO carry *_sek or a usable rate keep
* byte-identical numbers.
*/
describe('header fallback and payment path: strict FX conversion', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function eurHeaderOnlyInvoice(overrides: Partial<Invoice> = {}): Invoice {
return makeInvoice({
currency: 'EUR',
exchange_rate: null,
subtotal: 1000,
subtotal_sek: null,
vat_amount: 250,
vat_amount_sek: null,
total: 1250,
total_sek: null,
vat_treatment: 'standard_25',
items: [],
...overrides,
})
}
it('createInvoiceJournalEntry (no items, no rate, no *_sek) refuses instead of posting 1 250 EUR as 1 250 kr', async () => {
await expect(
createInvoiceJournalEntry(null as never, 'company-1', 'user-1', eurHeaderOnlyInvoice())
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING', currency: 'EUR' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createInvoiceJournalEntry (no items) books from *_sek columns even without a rate', async () => {
// Header rows carry SEK twins; those ARE an honest SEK source, so nothing
// changes for them: 11 500 + 2 875 credits, 14 375 debit on 1510.
const invoice = eurHeaderOnlyInvoice({
subtotal_sek: 11500,
vat_amount_sek: 2875,
total_sek: 14375,
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '3001')?.credit_amount).toBe(11500)
expect(input.lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(2875)
expect(input.lines.find((l) => l.account_number === '1510')?.debit_amount).toBe(14375)
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(Math.round(totalDebit * 100)).toBe(Math.round(totalCredit * 100))
})
it('createInvoiceJournalEntry (no items) with a rate books converted, balanced lines', async () => {
// 1 000 EUR + 250 EUR moms at 11,50: 11 500 + 2 875 = 14 375 on 1510.
const invoice = eurHeaderOnlyInvoice({ exchange_rate: 11.5 })
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '3001')?.credit_amount).toBe(11500)
expect(input.lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(2875)
expect(input.lines.find((l) => l.account_number === '1510')?.debit_amount).toBe(14375)
})
it('createInvoiceCashEntry (no items, no rate) refuses too', async () => {
await expect(
createInvoiceCashEntry(null as never, 'company-1', 'user-1', eurHeaderOnlyInvoice(), '2024-07-01')
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createCreditNoteJournalEntry (no items, no rate) refuses too', async () => {
const creditNote = eurHeaderOnlyInvoice({
invoice_number: 'KR-1001',
credited_invoice_id: 'inv-original',
subtotal: -1000,
vat_amount: -250,
total: -1250,
})
await expect(
createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createInvoicePaymentJournalEntry (full, no rate, no total_sek) refuses instead of clearing 1510 with a mislabelled number', async () => {
await expect(
createInvoicePaymentJournalEntry(
null as never, 'company-1', 'user-1', eurHeaderOnlyInvoice(), '2024-07-15'
)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING', currency: 'EUR' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createInvoicePaymentJournalEntry (partial, no rate) refuses: paymentAmount is in EUR and has no SEK source', async () => {
await expect(
createInvoicePaymentJournalEntry(
null as never, 'company-1', 'user-1', eurHeaderOnlyInvoice(), '2024-07-15',
undefined, undefined, 400,
)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createInvoicePaymentJournalEntry (partial, with rate) converts the partial amount: 400 EUR at 11,50 = 4 600,00', async () => {
const invoice = eurHeaderOnlyInvoice({ exchange_rate: 11.5 })
await createInvoicePaymentJournalEntry(
null as never, 'company-1', 'user-1', invoice, '2024-07-15',
undefined, undefined, 400,
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
expect(input.lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(4600)
expect(input.lines.find((l) => l.account_number === '1510')?.credit_amount).toBe(4600)
})
it('createInvoicePaymentJournalEntry keeps identical numbers when total_sek is present without a rate', async () => {
const invoice = eurHeaderOnlyInvoice({ total_sek: 11500 })
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(11500)
expect(input.lines.find((l) => l.account_number === '1510')?.credit_amount).toBe(11500)
})
it('a SEK invoice without items or rate is completely unaffected', async () => {
const invoice = makeInvoice({
currency: 'SEK',
exchange_rate: null,
subtotal: 1000,
vat_amount: 250,
total: 1250,
items: [],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBe(1250)
expect(totalDebit).toBe(totalCredit)
})
})
describe('BFL-compliant descriptions with counterparty names', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest'
import { buildInvoicePaymentClearingLines } from '../invoice-payment-lines'
import {
buildInvoicePaymentClearingLines,
InvoiceBookingRateMissingError,
MATCH_INVOICE_BOOKING_RATE_MISSING,
} from '../invoice-payment-lines'
import { getErrorMessage } from '@/lib/errors/get-error-message'
describe('buildInvoicePaymentClearingLines', () => {
describe('same currency (SEK invoice + SEK tx)', () => {
@@ -245,20 +250,300 @@ describe('buildInvoicePaymentClearingLines', () => {
})
})
describe('cross currency (USD invoice + USD tx)', () => {
it('uses tx amount_sek for the bank-leg when populated', () => {
describe('same currency foreign (USD invoice + USD tx)', () => {
it('clears 1510 at the booking rate and books the realized kursdiff, using tx amount_sek for the bank-leg', () => {
// USD-denominated bank account paying a USD invoice: ingest converts
// tx → SEK using the bank-date rate.
// tx → SEK using the bank-date rate (amount_sek = 1 100). The 100 USD
// receivable was BOOKED at 10,00 (1 000 kr on 1510), so:
// arSek = 100 × 10,00 = 1 000,00
// fxDiffSek = 1 000,00 − 1 100,00 = −100,00 → kursvinst → 3960 Cr 100
const result = buildInvoicePaymentClearingLines(
{ amount: 100, amount_sek: 1100, currency: 'USD', exchange_rate: 11 },
{ currency: 'USD', exchange_rate: 10, remaining_amount: 100, total: 100, paid_amount: 0 },
'desc',
)
// Same-currency path: bank-leg uses resolveSekAmount (which honours
// amount_sek), AR-leg equals bank-leg, no FX diff line.
expect(result.bankSek).toBe(1100)
expect(result.arSek).toBe(1100)
expect(result.arSek).toBe(1000)
expect(result.fxDiffSek).toBe(-100)
expect(result.lines).toHaveLength(3)
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 1100 })
expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 1000 })
expect(result.lines[2]).toMatchObject({
account_number: '3960',
credit_amount: 100,
line_description: 'Valutakursvinst',
})
// Balanced: Dr 1100 = Cr 1000 + 100.
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.round((debit - credit) * 100)).toBe(0)
})
})
describe('missing invoice booking rate (foreign invoice, no exchange_rate)', () => {
// Reported case: a 1 000 EUR invoice settled by an 11 496,70 kr deposit
// while invoice.exchange_rate was never populated. The old `?? 1` valued
// the receivable at 1 000 (EUR read as SEK) and posted
// 11 496,70 − 1 000 = 10 496,70 kr to 3960 as a phantom kursvinst:
// revenue understated and FX gain overstated by the whole amount, with a
// verifikat that balanced so no trigger fired. Per ML 8 kap 21-23§ the
// receivable is valued at the rate on the taxable event and the FX
// difference is the movement between that rate and the settlement rate:
// with no booking rate there is no difference to compute, only a guess.
const EUR_INVOICE_NO_RATE = {
currency: 'EUR',
exchange_rate: null,
remaining_amount: 1000,
total: 1000,
paid_amount: 0,
}
const BANK_11496_70 = {
amount: 11496.7,
amount_sek: null,
currency: 'SEK',
exchange_rate: null,
}
it('fallback path (no paidInInvoiceCurrency): throws instead of booking a 10 496,70 phantom 3960 gain', () => {
expect(() =>
buildInvoicePaymentClearingLines(
BANK_11496_70,
EUR_INVOICE_NO_RATE,
'Inbetalning kundfaktura',
),
).toThrow(InvoiceBookingRateMissingError)
})
it('spot-rate path (with paidInInvoiceCurrency): throws too', () => {
// paidInInvoiceCurrency = 11496.70 / 11.4967 = 1000 EUR. Without the
// BOOKING rate the AR leg is still unknown, so this path must fail
// exactly like the fallback: it used the same `?? 1`.
expect(() =>
buildInvoicePaymentClearingLines(
BANK_11496_70,
EUR_INVOICE_NO_RATE,
'Inbetalning kundfaktura',
1000,
),
).toThrow(InvoiceBookingRateMissingError)
})
it('carries the structured code and the offending currency/rate', () => {
try {
buildInvoicePaymentClearingLines(BANK_11496_70, EUR_INVOICE_NO_RATE, 'desc')
expect.unreachable('should have thrown')
} catch (err) {
const e = err as InvoiceBookingRateMissingError
expect(e.code).toBe(MATCH_INVOICE_BOOKING_RATE_MISSING)
expect(e.invoiceCurrency).toBe('EUR')
expect(e.exchangeRate).toBeNull()
}
})
it('resolves to the actionable Swedish message (registry wiring)', () => {
const err = new InvoiceBookingRateMissingError('EUR', null)
const message = getErrorMessage(err)
expect(message).toContain('växelkurs')
// Never the raw English engine text.
expect(message).not.toContain('exchange rate')
})
it('a zero or absurd rate is as unusable as null', () => {
for (const rate of [0, -9.3, 1e6]) {
expect(() =>
buildInvoicePaymentClearingLines(
BANK_11496_70,
{ ...EUR_INVOICE_NO_RATE, exchange_rate: rate },
'desc',
),
).toThrow(InvoiceBookingRateMissingError)
}
})
it('WITH a booking rate the same settlement books a real kursvinst on 3960', () => {
// 1 000 EUR booked at 11,30 (11 300 kr on 1510); the bank credited
// 11 496,70 kr. Real gain = 196,70 kr, not 10 496,70.
const result = buildInvoicePaymentClearingLines(
BANK_11496_70,
{ ...EUR_INVOICE_NO_RATE, exchange_rate: 11.3 },
'Inbetalning kundfaktura',
)
expect(result.bankSek).toBe(11496.7)
expect(result.arSek).toBe(11300)
expect(result.fxDiffSek).toBe(-196.7)
expect(result.lines).toHaveLength(3)
expect(result.lines[2]).toMatchObject({
account_number: '3960',
debit_amount: 0,
credit_amount: 196.7,
line_description: 'Valutakursvinst',
})
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.round((debit - credit) * 100)).toBe(0)
})
it('WITH a booking rate above the settlement rate it books a kursförlust on 7960', () => {
// Same 1 000 EUR settled for 11 496,70 kr, but booked at 11,60
// (11 600 kr on 1510): the 1 000 EUR fetched 103,30 kr less than booked.
const result = buildInvoicePaymentClearingLines(
BANK_11496_70,
{ ...EUR_INVOICE_NO_RATE, exchange_rate: 11.6 },
'Inbetalning kundfaktura',
1000,
)
expect(result.bankSek).toBe(11496.7)
expect(result.arSek).toBe(11600)
expect(result.fxDiffSek).toBe(103.3)
expect(result.lines[2]).toMatchObject({
account_number: '7960',
debit_amount: 103.3,
credit_amount: 0,
line_description: 'Valutakursförlust',
})
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.round((debit - credit) * 100)).toBe(0)
})
it('a SEK invoice with no exchange_rate is completely unaffected', () => {
// "Currency is SEK" and "rate is missing" are different conditions: a
// SEK receivable already carries its SEK value, so no rate is consulted
// and nothing may throw. exchange_rate is null on every SEK invoice.
const result = buildInvoicePaymentClearingLines(
{ amount: 11496.7, amount_sek: null, currency: 'SEK', exchange_rate: null },
{
currency: 'SEK',
exchange_rate: null,
remaining_amount: 11496.7,
total: 11496.7,
paid_amount: 0,
},
'Inbetalning kundfaktura',
)
expect(result.arSek).toBe(11496.7)
expect(result.fxDiffSek).toBe(0)
expect(result.lines).toHaveLength(2)
})
it('a SEK invoice paid from a foreign-currency bank account still needs no invoice rate', () => {
// !invoiceIsForeign branch: the tx side carries its own rate/amount_sek.
const result = buildInvoicePaymentClearingLines(
{ amount: 1000, amount_sek: 11496.7, currency: 'EUR', exchange_rate: 11.4967 },
{
currency: 'SEK',
exchange_rate: null,
remaining_amount: 11496.7,
total: 11496.7,
paid_amount: 0,
},
'Inbetalning kundfaktura',
)
expect(result.bankSek).toBe(11496.7)
expect(result.arSek).toBe(11496.7)
expect(result.fxDiffSek).toBe(0)
expect(result.lines).toHaveLength(2)
})
it('a same-currency foreign settlement (EUR invoice, EUR tx) also requires the booking rate', () => {
// Without the booking rate the SEK value the receivable was posted at
// is unknown, so neither the 1510 credit nor the kursdiff is computable.
// The old shape (arSek = bankSek, fxDiffSek = 0) credited 1510 at the
// settlement-date value and stranded the realized kursdiff there.
expect(() =>
buildInvoicePaymentClearingLines(
{ amount: 1000, amount_sek: 11496.7, currency: 'EUR', exchange_rate: 11.4967 },
{ ...EUR_INVOICE_NO_RATE },
'Inbetalning kundfaktura',
),
).toThrow(InvoiceBookingRateMissingError)
})
})
describe('same-currency foreign settlement (EUR invoice, EUR tx) with a booking rate', () => {
it('clears 1510 at the booking rate and books the realized kursvinst on 3960', () => {
// 1 000 EUR booked at 11,30 → 1510 was debited 11 300,00 at issuance.
// The EUR receipt is worth 11 496,70 kr on the bank date.
// arSek = 1 000 × 11,30 = 11 300,00
// fxDiffSek = 11 300,00 − 11 496,70 = −196,70 → kursvinst → 3960 Cr
// Balanced: Dr 11 496,70 = Cr 11 300,00 + 196,70.
const result = buildInvoicePaymentClearingLines(
{ amount: 1000, amount_sek: 11496.7, currency: 'EUR', exchange_rate: 11.4967 },
{ currency: 'EUR', exchange_rate: 11.3, remaining_amount: 1000, total: 1000, paid_amount: 0 },
'Inbetalning kundfaktura',
)
expect(result.bankSek).toBe(11496.7)
expect(result.arSek).toBe(11300)
expect(result.fxDiffSek).toBe(-196.7)
expect(result.lines).toHaveLength(3)
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 11496.7 })
expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 11300 })
expect(result.lines[2]).toMatchObject({
account_number: '3960',
debit_amount: 0,
credit_amount: 196.7,
line_description: 'Valutakursvinst',
})
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.round((debit - credit) * 100)).toBe(0)
})
it('books the realized kursförlust on 7960 when the booking rate was higher', () => {
// Same 1 000 EUR settlement worth 11 496,70 kr, but booked at 11,60:
// arSek = 1 000 × 11,60 = 11 600,00
// fxDiffSek = 11 600,00 − 11 496,70 = +103,30 → kursförlust → 7960 Dr
// Balanced: Dr 11 496,70 + 103,30 = 11 600,00 = Cr 11 600,00.
const result = buildInvoicePaymentClearingLines(
{ amount: 1000, amount_sek: 11496.7, currency: 'EUR', exchange_rate: 11.4967 },
{ currency: 'EUR', exchange_rate: 11.6, remaining_amount: 1000, total: 1000, paid_amount: 0 },
'Inbetalning kundfaktura',
)
expect(result.bankSek).toBe(11496.7)
expect(result.arSek).toBe(11600)
expect(result.fxDiffSek).toBe(103.3)
expect(result.lines[2]).toMatchObject({
account_number: '7960',
debit_amount: 103.3,
credit_amount: 0,
line_description: 'Valutakursförlust',
})
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.round((debit - credit) * 100)).toBe(0)
})
it('a partial same-currency payment credits 1510 proportionally with an accurate FX line', () => {
// 400 of 1 000 EUR paid; booked at 11,30. Bank-date value 4 598,68 kr
// (400 × 11,4967). arSek = 400 × 11,30 = 4 520,00.
// fxDiffSek = 4 520,00 − 4 598,68 = −78,68 → 3960 Cr 78,68.
// Balanced: Dr 4 598,68 = Cr 4 520,00 + 78,68. Same partial-payment
// contract as the preferred cross-currency path: 1510 moves in step
// with the AR sub-ledger and the realized kursdiff is booked now.
const result = buildInvoicePaymentClearingLines(
{ amount: 400, amount_sek: 4598.68, currency: 'EUR', exchange_rate: 11.4967 },
{ currency: 'EUR', exchange_rate: 11.3, remaining_amount: 1000, total: 1000, paid_amount: 0 },
'Delbetalning kundfaktura',
)
expect(result.bankSek).toBe(4598.68)
expect(result.arSek).toBe(4520)
expect(result.fxDiffSek).toBe(-78.68)
expect(result.lines).toHaveLength(3)
expect(result.lines[2]).toMatchObject({ account_number: '3960', credit_amount: 78.68 })
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.round((debit - credit) * 100)).toBe(0)
})
it('no FX line when the settlement-date value equals the booked value', () => {
const result = buildInvoicePaymentClearingLines(
{ amount: 1000, amount_sek: 11300, currency: 'EUR', exchange_rate: 11.3 },
{ currency: 'EUR', exchange_rate: 11.3, remaining_amount: 1000, total: 1000, paid_amount: 0 },
'Inbetalning kundfaktura',
)
expect(result.arSek).toBe(11300)
expect(result.fxDiffSek).toBe(0)
expect(result.lines).toHaveLength(2)
})
})
@@ -451,4 +451,278 @@ describe('mapping-engine', () => {
expect(result.vat_lines[1].account_number).toBe('2614')
})
})
// Both the halva-prisbasbeloppet threshold (IL 18 kap 4 §) and a rule's
// amount_min/amount_max band are SEK figures. Before the fix they were
// compared against the raw transaction amount, so a 3000 EUR laptop
// (about 34 500 kr) read as under the 29 600 kr limit for 2026 and was
// expensed to 5410 instead of capitalised to 1250.
describe('SEK thresholds on foreign-currency transactions', () => {
function makeRule(overrides: Record<string, unknown> = {}) {
return {
id: 'rule-cap',
user_id: null,
rule_name: 'Equipment',
rule_type: 'merchant_name',
priority: 50,
mcc_codes: null,
merchant_pattern: 'Equipment',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '5410',
credit_account: '1930',
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'LOW',
default_private: false,
requires_review: false,
confidence_score: 0.9,
capitalization_threshold: null,
capitalized_debit_account: '1250',
is_active: true,
source: 'system',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
...overrides,
}
}
it('capitalizes a SEK 34 500 laptop (over the 2026 half-PBB of 29 600)', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -34500,
currency: 'SEK',
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('1250')
expect(result.requires_review).toBe(false)
})
it('expenses a SEK 20 000 laptop (under the 2026 half-PBB of 29 600)', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -20000,
currency: 'SEK',
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('5410')
expect(result.requires_review).toBe(false)
})
it('capitalizes a 3000 EUR laptop when an exchange_rate is present', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
// 3000 EUR * 11.5 = 34 500 kr, over the 29 600 kr limit.
const tx = makeTransaction({
amount: -3000,
currency: 'EUR',
amount_sek: null,
exchange_rate: 11.5,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('1250')
expect(result.requires_review).toBe(false)
})
it('capitalizes a 3000 EUR laptop when amount_sek is pre-computed', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -3000,
currency: 'EUR',
amount_sek: -34500,
exchange_rate: null,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('1250')
})
it('does not silently expense a 3000 EUR laptop with no rate: declines to auto-classify', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -3000,
currency: 'EUR',
amount_sek: null,
exchange_rate: null,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// The rule alone would auto-book (confidence 0.9, requires_review false).
// Without a SEK value the capitalization branch is a guess, so the
// result must land under the 0.8 auto-book bar and explain itself.
expect(result.requires_review).toBe(true)
expect(result.confidence).toBeLessThan(0.8)
expect(result.description).toContain('växelkurs saknas')
})
it('leaves rules that cannot capitalize untouched when no rate is available', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -3000,
currency: 'EUR',
amount_sek: null,
exchange_rate: null,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({ data: [makeRule({ capitalized_debit_account: null })], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// No capitalization decision to make: the missing rate is irrelevant.
expect(result.debit_account).toBe('5410')
expect(result.requires_review).toBe(false)
expect(result.confidence).toBe(0.9)
})
it('evaluates a user amount band against SEK, not the foreign amount', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
// 3000 EUR = 34 500 kr, well over the rule's 5 000 kr ceiling. The raw
// amount (3000) would have slipped under it.
const tx = makeTransaction({
amount: -3000,
currency: 'EUR',
amount_sek: null,
exchange_rate: 11.5,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({
data: [
makeRule({
rule_name: 'Small equipment',
amount_max: 5000,
capitalized_debit_account: null,
}),
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// No match: falls through to the uncategorized default.
expect(result.rule).toBeNull()
expect(result.debit_account).toBe('6991')
expect(result.requires_review).toBe(true)
})
it('matches a user amount band when the SEK value falls inside it', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
// 100 EUR * 11.5 = 1 150 kr, inside the 1 000-5 000 kr band.
const tx = makeTransaction({
amount: -100,
currency: 'EUR',
amount_sek: null,
exchange_rate: 11.5,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({
data: [
makeRule({
rule_name: 'Small equipment',
amount_min: 1000,
amount_max: 5000,
capitalized_debit_account: null,
}),
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('5410')
expect(result.confidence).toBe(0.9)
})
it('skips an amount-band rule when the transaction has no SEK value', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -3000,
currency: 'EUR',
amount_sek: null,
exchange_rate: null,
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({
data: [
makeRule({
rule_name: 'Small equipment',
amount_max: 5000,
capitalized_debit_account: null,
}),
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
// The band is unevaluable, so the rule does not apply. The transaction
// lands in the uncategorized default where the user picks it up.
expect(result.rule).toBeNull()
expect(result.requires_review).toBe(true)
})
it('still applies SEK amount bands unchanged for SEK transactions', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -3000,
currency: 'SEK',
date: '2026-06-15',
merchant_name: 'Equipment Store',
})
mockResult({
data: [
makeRule({
rule_name: 'Small equipment',
amount_min: 1000,
amount_max: 5000,
capitalized_debit_account: null,
}),
],
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('5410')
})
})
})
+97 -8
View File
@@ -99,8 +99,8 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 1000 } },
// Fully paid before deletion: paid_amount === total_amount
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } },
// Fully paid before deletion: paid_amount === total
{ data: { paid_amount: 1000, total: 1000, due_date: '2099-12-31' } },
{ data: null }, // UPDATE result
])
@@ -124,7 +124,7 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
enqueueMany([
{ data: { amount: 500 } }, // payment being reversed
// Started with 1000 paid (multiple payments), reversing 500
{ data: { paid_amount: 1000, total_amount: 1500, due_date: '2099-12-31' } },
{ data: { paid_amount: 1000, total: 1500, due_date: '2099-12-31' } },
{ data: null },
])
@@ -183,7 +183,7 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 1000 } },
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } },
{ data: { paid_amount: 1000, total: 1000, due_date: '2099-12-31' } },
{ data: null },
])
@@ -202,7 +202,7 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null }, // no payment row
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } },
{ data: { paid_amount: 1000, total: 1000, due_date: '2099-12-31' } },
])
await expect(
@@ -305,7 +305,7 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
it('supplier reversal deletes the payment row and releases the bank line', async () => {
const { supabase, updatePayload, wasDeleted, tablesUpdated } = createRecordingSupabase([
{ data: { amount: 1000 } }, // supplier_invoice_payments select amount
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } }, // supplier_invoices select
{ data: { paid_amount: 1000, total: 1000, due_date: '2099-12-31' } }, // supplier_invoices select
{ data: null }, // supplier_invoices update
{ data: [{ transaction_id: 'tx-7' }] }, // supplier_invoice_payments select transaction_id
{ data: null }, // supplier_invoice_payments delete
@@ -322,7 +322,7 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
expect(updatePayload('supplier_invoices')).toMatchObject({
status: 'approved',
paid_amount: 0,
remaining_amount: 1000, // total_amount - 0 paid = full amount owed again
remaining_amount: 1000, // total - 0 paid = full amount owed again
})
expect(wasDeleted('supplier_invoice_payments')).toBe(true)
const resetPayload = tablesUpdated('transactions')[0].ops.find((o) => o.method === 'update')?.args[0]
@@ -343,7 +343,7 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
it('supplier cash-payment reversal restores status without a payment row', async () => {
const { supabase, updatePayload } = createRecordingSupabase([
{ data: null }, // supplier_invoice_payments select amount → none (cash entry)
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } }, // supplier_invoices select
{ data: { paid_amount: 1000, total: 1000, due_date: '2099-12-31' } }, // supplier_invoices select
{ data: null }, // supplier_invoices update
{ data: [] }, // supplier_invoice_payments select transaction_id
{ data: null }, // supplier_invoice_payments delete
@@ -364,4 +364,93 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
payment_journal_entry_id: null,
})
})
// Regression: the supplier branch selected `total_amount`, a column
// supplier_invoices has never had (the real one is `total`). PostgREST
// rejected the whole select, so the restore was skipped while the payment-row
// delete and the bank-line release still ran: the invoice stayed 'paid' with
// a stale paid_amount and nothing behind it. Asserted on the projection
// string because a queued mock happily returns rows for columns that do not
// exist, which is how the bug survived the earlier tests.
it('selects supplier_invoices.total, never the non-existent total_amount', async () => {
const { supabase, calls } = createRecordingSupabase([
{ data: { amount: 1000 } }, // supplier_invoice_payments select amount
{ data: { paid_amount: 1000, total: 1000, due_date: '2099-12-31' } }, // supplier_invoices select
{ data: null }, // supplier_invoices update
{ data: [] }, // supplier_invoice_payments select transaction_id
{ data: null }, // supplier_invoice_payments delete
{ data: null }, // transactions update
])
await syncInvoiceStatusFromPaymentEntry(supabase, 'co-1', entry())
const projection = calls
.find((c) => c.table === 'supplier_invoices')
?.ops.find((o) => o.method === 'select')?.args[0] as string
expect(projection).toBe('paid_amount, total, due_date')
expect(projection).not.toContain('total_amount')
})
// The state-level half of the same regression: with the wrong column the row
// carries no `total`, so remaining_amount was computed from undefined (NaN)
// and the AP ledger lost the amount still owed.
it('recomputes remaining_amount from total on a partial supplier reversal', async () => {
const { supabase, updatePayload } = createRecordingSupabase([
{ data: { amount: 500 } }, // supplier_invoice_payments select amount
{ data: { paid_amount: 1500, total: 2000, due_date: '2099-12-31' } }, // supplier_invoices select
{ data: null }, // supplier_invoices update
{ data: [] }, // supplier_invoice_payments select transaction_id
{ data: null }, // supplier_invoice_payments delete
{ data: null }, // transactions update
])
await syncInvoiceStatusFromPaymentEntry(supabase, 'co-1', entry())
expect(updatePayload('supplier_invoices')).toMatchObject({
status: 'partially_paid',
paid_amount: 1000,
remaining_amount: 1000,
})
})
// If the supplier invoice cannot be read we do not know the state we are
// about to overwrite, so nothing destructive may run: deleting the payment
// row and releasing the bank line would strand the invoice on 'paid' with no
// payment behind it. Bail out and leave the reversal safely re-runnable.
it('aborts the whole sync when the supplier invoice read errors', async () => {
const { supabase, calls, wasDeleted, tablesUpdated } = createRecordingSupabase([
{ data: { amount: 1000 } }, // supplier_invoice_payments select amount
{
data: null,
error: { code: '42703', message: 'column supplier_invoices.total_amount does not exist' },
},
])
await syncInvoiceStatusFromPaymentEntry(supabase, 'co-1', entry())
expect(calls.map((c) => c.table)).toEqual(['supplier_invoice_payments', 'supplier_invoices'])
expect(tablesUpdated('supplier_invoices').length).toBe(0)
expect(wasDeleted('supplier_invoice_payments')).toBe(false)
expect(tablesUpdated('transactions').length).toBe(0)
})
// "No row" is not a read failure: the invoice is genuinely gone, so there is
// nothing to restore and the orphan payment row plus the bank line still have
// to be cleaned up.
it('still cleans up when the supplier invoice row no longer exists (PGRST116)', async () => {
const { supabase, wasDeleted, tablesUpdated } = createRecordingSupabase([
{ data: { amount: 1000 } }, // supplier_invoice_payments select amount
{ data: null, error: { code: 'PGRST116', message: 'no rows returned' } },
{ data: [{ transaction_id: 'tx-3' }] }, // supplier_invoice_payments select transaction_id
{ data: null }, // supplier_invoice_payments delete
{ data: null }, // transactions update by journal_entry_id
{ data: null }, // transactions update by id
])
await syncInvoiceStatusFromPaymentEntry(supabase, 'co-1', entry())
expect(tablesUpdated('supplier_invoices').length).toBe(0)
expect(wasDeleted('supplier_invoice_payments')).toBe(true)
expect(tablesUpdated('transactions').length).toBe(2)
})
})
@@ -400,3 +400,94 @@ describe('proposePaymentLines: dimensions propagation (PR7)', () => {
}
})
})
/**
* The cash-method proposal IS the entry: PaymentBookingDialog pre-fills its
* editable grid from these lines and submits them. A foreign invoice with no
* exchange rate has no SEK value at item granularity, so pre-filling the raw
* foreign numbers would post 1 000 kr of revenue and 250 kr of moms where 11 500
* kr and 2 875 kr belong: balanced, undetectable, and an understated ruta 05/10.
* The dialog resolves the proposal inside a try/catch, so throwing surfaces as a
* translated toast (INVOICE_FX_RATE_MISSING) rather than a crash.
*/
describe('proposePaymentLines: foreign currency without an exchange rate', () => {
it('cash method refuses with INVOICE_FX_RATE_MISSING', () => {
expect(() =>
proposePaymentLines({
invoice: makeInvoiceInput({ currency: 'EUR', exchange_rate: null }),
accountingMethod: 'cash',
entityType: 'enskild_firma',
})
).toThrowError(expect.objectContaining({ code: 'INVOICE_FX_RATE_MISSING', currency: 'EUR' }))
})
it('cash method refuses a zero rate the same way', () => {
expect(() =>
proposePaymentLines({
invoice: makeInvoiceInput({ currency: 'EUR', exchange_rate: 0 }),
accountingMethod: 'cash',
entityType: 'enskild_firma',
})
).toThrowError(expect.objectContaining({ code: 'INVOICE_FX_RATE_MISSING' }))
})
it('cash method with a rate proposes converted, balanced lines', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({
currency: 'EUR',
exchange_rate: 11.5,
subtotal: 1000,
vat_amount: 250,
total: 1250,
items: [makeItem({ line_total: 1000, unit_price: 1000, vat_rate: 25, vat_amount: 250 })],
}),
accountingMethod: 'cash',
entityType: 'enskild_firma',
})
expect(lines.find((l) => l.account_number === '3001')?.credit_amount).toBe('11500')
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe('2875')
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('14375')
const debit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
const credit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
expect(Math.round(debit * 100)).toBe(Math.round(credit * 100))
})
it('SEK cash method without a rate is unaffected', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({ currency: 'SEK', exchange_rate: null }),
accountingMethod: 'cash',
entityType: 'enskild_firma',
})
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('12500')
const debit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
const credit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
expect(Math.round(debit * 100)).toBe(Math.round(credit * 100))
})
// The invoice-level fallback (no items) reads subtotal_sek / vat_amount_sek and
// is deliberately left lenient: those rows CAN be expressed in kronor.
it('cash method still proposes from invoice-level *_sek when there are no items', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({
currency: 'EUR',
exchange_rate: null,
items: [],
subtotal: 1000,
subtotal_sek: 11500,
vat_amount: 250,
vat_amount_sek: 2875,
total: 1250,
total_sek: 14375,
}),
accountingMethod: 'cash',
entityType: 'enskild_firma',
})
expect(lines.find((l) => l.account_number === '3001')?.credit_amount).toBe('11500')
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe('2875')
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('14375')
})
})
@@ -296,3 +296,101 @@ describe('proposeSendLines', () => {
})
})
})
/**
* The preview is not decoration: `editable` is SEK-only, so for an FX invoice the
* read-only grid is exactly what the server generator will post. A foreign
* invoice with no exchange rate has no SEK value at item granularity, and because
* the 1510 debit is derived from the sum of the credits the grid would even read
* "Balanserar" while showing 1 000 kr where 11 500 kr belongs. No proposal is the
* honest answer; the send itself is refused server-side with
* INVOICE_FX_RATE_MISSING.
*/
describe('proposeSendLines: foreign currency without an exchange rate', () => {
it('returns no proposal instead of relabelling EUR amounts as kronor', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({ currency: 'EUR', exchange_rate: null }),
entityType: 'enskild_firma',
})
expect(lines).toEqual([])
})
it('returns no proposal for a zero or negative rate', () => {
for (const rate of [0, -11.5]) {
expect(
proposeSendLines({
invoice: makeInvoiceInput({ currency: 'EUR', exchange_rate: rate }),
entityType: 'enskild_firma',
})
).toEqual([])
}
})
it('does not throw: it runs inside a useMemo during render', () => {
expect(() =>
proposeSendLines({
invoice: makeInvoiceInput({ currency: 'EUR', exchange_rate: null }),
entityType: 'enskild_firma',
})
).not.toThrow()
})
it('EUR with a rate proposes converted, balanced lines', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({
currency: 'EUR',
exchange_rate: 11.5,
subtotal: 1000,
vat_amount: 250,
total: 1250,
items: [makeItem({ line_total: 1000, unit_price: 1000, vat_rate: 25, vat_amount: 250 })],
}),
entityType: 'enskild_firma',
})
expect(lines.find((l) => l.account_number === '3001')?.credit_amount).toBe('11500')
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe('2875')
expect(lines.find((l) => l.account_number === '1510')?.debit_amount).toBe('14375')
const debit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
const credit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
expect(Math.round(debit * 100)).toBe(Math.round(credit * 100))
})
it('SEK without a rate is unaffected', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({ currency: 'SEK', exchange_rate: null }),
entityType: 'enskild_firma',
})
expect(lines.find((l) => l.account_number === '1510')?.debit_amount).toBe('12500')
const debit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
const credit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
expect(Math.round(debit * 100)).toBe(Math.round(credit * 100))
})
// The invoice-level fallback (no items at all) has a genuine second source in
// subtotal_sek / vat_amount_sek and is deliberately left lenient: killing it
// would blank the preview for rows that CAN be expressed in kronor.
it('still proposes from invoice-level *_sek when there are no items', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({
currency: 'EUR',
exchange_rate: null,
items: [],
subtotal: 1000,
subtotal_sek: 11500,
vat_amount: 250,
vat_amount_sek: 2875,
total: 1250,
total_sek: 14375,
}),
entityType: 'enskild_firma',
})
expect(lines.find((l) => l.account_number === '3001')?.credit_amount).toBe('11500')
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe('2875')
expect(lines.find((l) => l.account_number === '1510')?.debit_amount).toBe('14375')
})
})
@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupplierInvoiceItem, CreateJournalEntryLineInput, CreateJournalEntryInput } from '@/types'
import { makeSupplierInvoice } from '@/tests/helpers'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
// Mock engine
vi.mock('../engine', () => ({
@@ -90,6 +92,7 @@ const {
createSupplierInvoiceCashEntry,
createSupplierCreditNoteEntry,
createSupplierInvoicePrivatelyPaidEntry,
SupplierInvoiceFxRateMissingError,
} = await import('../supplier-invoice-entries')
function makeItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoiceItem {
@@ -850,6 +853,166 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
})
})
// ============================================================
// Foreign currency without an exchange rate: must fail loudly
// ============================================================
describe('supplier invoice booking: foreign currency without an exchange rate', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
// The 99% path: a SEK invoice never touches the FX guard.
it('SEK reverse-charge invoice is unaffected (no rate needed)', async () => {
const invoice = makeSupplierInvoice({
subtotal: 10000, vat_amount: 0, total: 10000,
currency: 'SEK', exchange_rate: null, reverse_charge: true,
})
const items = [makeItem({ line_total: 10000, vat_rate: 0.25, account_number: '6540' })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(2500)
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(2500)
assertBalanced(input)
})
// 1000 EUR at 11,50 SEK/EUR: fiktiv moms is 11 500 × 25% = 2 875,00 kr on
// 2614 (ruta 30) / 2645 (ruta 48), and the basbelopp on 4535 is 11 500
// (ruta 21). Not 250,00 kr and 1 000.
it('EUR reverse-charge invoice WITH a rate books fiktiv moms in SEK', async () => {
const invoice = makeSupplierInvoice({
subtotal: 1000, vat_amount: 0, total: 1000,
currency: 'EUR', exchange_rate: 11.50, reverse_charge: true,
})
const items = [makeItem({ line_total: 1000, vat_rate: 0.25, account_number: '6540' })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6540')[0].debit_amount).toBe(11500)
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(2875)
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(2875)
expect(findByAccount(input.lines, '4535')[0].debit_amount).toBe(11500)
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(11500)
assertBalanced(input)
})
it('EUR reverse-charge invoice WITHOUT a rate throws instead of booking 1:1', async () => {
const invoice = makeSupplierInvoice({
subtotal: 1000, vat_amount: 0, total: 1000,
currency: 'EUR', exchange_rate: null, reverse_charge: true,
})
const items = [makeItem({ line_total: 1000, vat_rate: 0.25, account_number: '6540' })]
await expect(
createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
).rejects.toThrow(SupplierInvoiceFxRateMissingError)
// No verifikat at all: a wrong one would balance and pass every trigger.
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('the thrown error carries the structured code and the currency', async () => {
const invoice = makeSupplierInvoice({
subtotal: 1000, vat_amount: 0, total: 1000,
currency: 'EUR', exchange_rate: null, reverse_charge: true,
})
const items = [makeItem({ line_total: 1000, vat_rate: 0.25, account_number: '6540' })]
const err = await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(SupplierInvoiceFxRateMissingError)
const fxErr = err as { code?: string; currency?: string }
expect(fxErr.code).toBe('SI_FX_RATE_MISSING')
expect(fxErr.currency).toBe('EUR')
// Registered in the canonical registry so routes/MCP render Swedish.
expect(getErrorEntry('SI_FX_RATE_MISSING')?.httpStatus).toBe(400)
expect(getErrorMessage(err)).toMatch(/saknar växelkurs/i)
})
// Domestic-VAT path (2641 / ruta 48) has the same exposure.
it('EUR domestic-VAT invoice WITHOUT a rate throws instead of booking 1:1', async () => {
const invoice = makeSupplierInvoice({
subtotal: 1000, vat_amount: 250, total: 1250,
currency: 'EUR', exchange_rate: null,
})
const items = [makeItem({ line_total: 1000, vat_rate: 0.25, account_number: '6200' })]
await expect(
createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
).rejects.toThrow(SupplierInvoiceFxRateMissingError)
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('kontantmetoden without a rate throws, but a settlement-derived rate still books', async () => {
const invoice = makeSupplierInvoice({
subtotal: 1000, vat_amount: 0, total: 1000,
currency: 'EUR', exchange_rate: null, reverse_charge: true,
})
const items = [makeItem({ line_total: 1000, vat_rate: 0.25, account_number: '6540' })]
await expect(
createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'eu_business'
)
).rejects.toThrow(SupplierInvoiceFxRateMissingError)
expect(mockedCreateEntry).not.toHaveBeenCalled()
// settledBankSek pins the payment-date rate (11 500 / 1 000 = 11.50),
// so the same invoice books correctly through the cash path.
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'eu_business',
undefined, undefined, 11500
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(2875)
assertBalanced(input)
})
it('credit note in EUR WITHOUT a rate throws instead of reversing 1:1', async () => {
const creditNote = makeSupplierInvoice({
subtotal: 1000, vat_amount: 0, total: 1000,
currency: 'EUR', exchange_rate: null, reverse_charge: true, is_credit_note: true,
})
const items = [makeItem({ line_total: 1000, vat_rate: 0.25, account_number: '6540' })]
await expect(
createSupplierCreditNoteEntry(
null as never, 'company-1', 'user-1', creditNote, items, 'eu_business'
)
).rejects.toThrow(SupplierInvoiceFxRateMissingError)
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('eget utlägg in EUR WITHOUT a rate throws instead of booking 1:1', async () => {
const invoice = makeSupplierInvoice({
subtotal: 1000, vat_amount: 250, total: 1250,
currency: 'EUR', exchange_rate: null,
})
const items = [makeItem({ line_total: 1000, vat_rate: 0.25, account_number: '6200' })]
await expect(
createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
).rejects.toThrow(SupplierInvoiceFxRateMissingError)
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
})
// ============================================================
// createSupplierInvoicePaymentEntry
// ============================================================
+74 -13
View File
@@ -3,6 +3,74 @@ import { createLogger } from '@/lib/logger'
const log = createLogger('cancel-orphaned-entry')
/**
* Document a single voucher number as an explained break in the
* verifikationsnummerserie (BFNAR 2013:2: the series must be unbroken, and any
* break needs a documented reason; an undocumented one blocks year-end
* closing in `checkYearEndReadiness`).
*
* A stranded voucher occupies exactly one number, so the gap is the closed
* range [voucherNumber, voucherNumber]. Every reader keys explanations on
* `voucher_series:gap_start:gap_end` (the /bookkeeping voucher-gaps view, the
* `gnubok_list_voucher_gaps` MCP tool, the year-end readiness check), so a
* single-number gap MUST be written with gap_start === gap_end or it is
* invisible to all of them.
*
* Columns are exactly the table's: company_id, user_id, fiscal_period_id,
* voucher_series, gap_start, gap_end, explanation. All are NOT NULL.
*
* Never throws. Every caller is already on a decided error path (the CAS
* conflict response is correct for the client and must not be replaced by a
* 500), so a failure is logged at error level with the full payload an
* operator needs to file the row by hand. It is logged, never swallowed.
*
* @returns true when the gap is documented (or already was), false otherwise.
*/
export async function recordVoucherGapExplanation(
supabase: SupabaseClient,
params: {
companyId: string
userId: string
fiscalPeriodId: string
voucherSeries: string
voucherNumber: number
explanation: string
},
): Promise<boolean> {
const payload = {
company_id: params.companyId,
user_id: params.userId,
fiscal_period_id: params.fiscalPeriodId,
voucher_series: params.voucherSeries,
gap_start: params.voucherNumber,
gap_end: params.voucherNumber,
explanation: params.explanation,
}
try {
const { error } = await supabase.from('voucher_gap_explanations').insert(payload)
if (error) {
// 23505 = the exact gap is already documented (unique on
// company_id, fiscal_period_id, voucher_series, gap_start, gap_end).
// A retry hitting an existing row is the desired end state, and the
// stored explanation (possibly a human's) wins.
if ((error as { code?: string }).code === '23505') return true
log.error('failed to record voucher gap explanation (gap stays undocumented)', error, payload)
return false
}
return true
} catch (err) {
log.error(
'unexpected failure recording voucher gap explanation (gap stays undocumented)',
err as Error,
payload,
)
return false
}
}
/**
* Compensation for the payment-flow CAS guard: a payment voucher was posted,
* but the invoice row was settled by a concurrent request between our read
@@ -68,21 +136,14 @@ export async function cancelOrphanedPaymentEntry(
}
if (orphan) {
const { error: gapError } = await supabase.from('voucher_gap_explanations').insert({
company_id: companyId,
fiscal_period_id: orphan.fiscal_period_id,
voucher_series: orphan.voucher_series || 'A',
gap_number: orphan.voucher_number,
await recordVoucherGapExplanation(supabase, {
companyId,
userId,
fiscalPeriodId: orphan.fiscal_period_id,
voucherSeries: orphan.voucher_series || 'A',
voucherNumber: orphan.voucher_number,
explanation,
created_by: userId,
})
if (gapError) {
log.error('failed to record voucher gap explanation for cancelled orphan', gapError, {
companyId,
journalEntryId,
voucherNumber: orphan.voucher_number,
})
}
}
} catch (err) {
// Hard never-throw guarantee: the caller is about to return the correct
+172 -23
View File
@@ -1,6 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchMultipleRates } from '@/lib/currency/riksbanken'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import {
BookkeepingDatabaseError,
@@ -16,10 +16,82 @@ import type {
CreateJournalEntryLineInput,
} from '@/types'
export const FX_CLOSING_RATE_UNAVAILABLE = 'FX_CLOSING_RATE_UNAVAILABLE' as const
/** A currency whose balansdagen rate could not be established. */
export interface MissingClosingRate {
currency: Currency
date: string
}
/**
* Raised when the revaluation would have to invent a closing rate.
*
* The balansdagen valuation of monetary items (ÅRL 4 kap. 13 §) posts a real
* verifikat to 3960/7960, so the rate behind it must be a real Riksbanken
* observation. `code` is in the structured-error registry, so REST routes and
* MCP tools translate it without any per-caller handling.
*/
export class ClosingRateUnavailableError extends Error {
readonly code = FX_CLOSING_RATE_UNAVAILABLE
constructor(public readonly missingRates: MissingClosingRate[]) {
super(
`No Riksbanken exchange rate available for ${missingRates
.map((m) => `${m.currency} on ${m.date}`)
.join(', ')}: currency revaluation refused rather than posted from an estimated rate.`
)
this.name = 'ClosingRateUnavailableError'
}
}
/** An open foreign-currency row that carries no exchange rate at all. */
export interface UnconvertedFxItem {
type: 'receivable' | 'payable'
source_id: string
reference: string
currency: Currency
amount_in_currency: number
}
/**
* Preview plus the two exclusion channels a caller must be able to show:
* rows that carry no original rate, and currencies with no closing rate.
* Same shape of contract as `unconverted_fx_count` on the reskontra reports.
*/
export interface CurrencyRevaluationPreviewWithExclusions extends CurrencyRevaluationPreview {
/**
* Open foreign-currency rows excluded because they have no `exchange_rate`
* on file. They cannot be revalued (there is no original SEK value to
* compare against) and they are usually the largest unmeasured exposure, so
* they are counted and returned instead of silently dropped.
*/
unconvertedFx: UnconvertedFxItem[]
unconvertedFxCount: number
/** Currencies with no Riksbanken observation for the closing date. */
missingClosingRates: MissingClosingRate[]
}
export interface CurrencyRevaluationResultWithExclusions extends CurrencyRevaluationResult {
preview: CurrencyRevaluationPreviewWithExclusions
}
/** A stored rate is usable only when present and strictly positive. */
function hasUsableRate(rate: number | null | undefined): boolean {
return rate != null && Number(rate) > 0
}
/**
* Fetch open foreign-currency receivables (invoices).
* Returns invoices with status 'sent' or 'overdue', non-SEK currency,
* and a known exchange rate.
* Returns invoices with status 'sent', 'overdue' or 'partially_paid' and
* non-SEK currency, INCLUDING ones with no `exchange_rate`: filtering those
* out in SQL hid the largest unmeasured FX exposure from the caller. The
* caller partitions them and reports the excluded rows.
*
* 'partially_paid' belongs in the list: payment-sync moves a customer invoice
* to that status on a partial settlement, and its unpaid remainder is still a
* monetary item that ÅRL 4 kap. 13 § values at balansdagen. Omitting it made
* partially paid foreign receivables entirely invisible to the revaluation
* (the payables side has always included it).
*/
export async function getOpenForeignCurrencyReceivables(
supabase: SupabaseClient,
@@ -33,9 +105,8 @@ export async function getOpenForeignCurrencyReceivables(
.from('invoices')
.select('*')
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
.in('status', ['sent', 'overdue', 'partially_paid'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (i) => i.id })
@@ -49,8 +120,9 @@ export async function getOpenForeignCurrencyReceivables(
/**
* Fetch open foreign-currency payables (supplier invoices).
* Returns supplier invoices with open statuses, non-SEK currency,
* and a known exchange rate. Uses remaining_amount for partial payments.
* Returns supplier invoices with open statuses and non-SEK currency,
* INCLUDING ones with no `exchange_rate` (see the receivables note above).
* Uses remaining_amount for partial payments.
*/
export async function getOpenForeignCurrencyPayables(
supabase: SupabaseClient,
@@ -66,7 +138,6 @@ export async function getOpenForeignCurrencyPayables(
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (i) => i.id })
@@ -89,24 +160,67 @@ export async function getOpenForeignCurrencyPayables(
* Payables (2440):
* closing > original → loss (liability grew): Debit 7960, Credit 2440
* closing < original → gain (liability shrank): Debit 2440, Credit 3960
*
* Never throws on a missing rate: this is the read-only surface, and the
* year-end preview must still render. Both exclusion channels come back on
* the result (`unconvertedFx*`, `missingClosingRates`) so the caller can show
* them. `executeCurrencyRevaluation` is what refuses to post.
*/
export async function previewCurrencyRevaluation(
supabase: SupabaseClient,
companyId: string,
closingDate: string
): Promise<CurrencyRevaluationPreview> {
): Promise<CurrencyRevaluationPreviewWithExclusions> {
const [receivables, payables] = await Promise.all([
getOpenForeignCurrencyReceivables(supabase, companyId),
getOpenForeignCurrencyPayables(supabase, companyId),
])
// Collect distinct currencies
// Partition into rows we can revalue and rows with no original rate, and
// collect the distinct currencies of the revaluable rows.
const currencies = new Set<Currency>()
const unconvertedFx: UnconvertedFxItem[] = []
const revaluableReceivables: Array<{ inv: Invoice; outstanding: number }> = []
const revaluablePayables: SupplierInvoice[] = []
for (const inv of receivables) {
// Only the OUTSTANDING amount is a monetary item at balansdagen: the paid
// part has already been settled at its own realized rate. Kundreskontran
// derives outstanding from total - paid_amount (see year-end-service),
// so the same definition is used here, öre-rounded.
const outstanding =
Math.round(((Number(inv.total) || 0) - (Number(inv.paid_amount) || 0)) * 100) / 100
// Nothing outstanding: nothing to revalue, and no exposure to report.
if (outstanding <= 0) continue
if (!hasUsableRate(inv.exchange_rate)) {
unconvertedFx.push({
type: 'receivable',
source_id: inv.id,
reference: inv.invoice_number ?? '',
currency: inv.currency,
amount_in_currency: outstanding,
})
continue
}
currencies.add(inv.currency)
revaluableReceivables.push({ inv, outstanding })
}
for (const si of payables) {
// Nothing outstanding: nothing to revalue, and no exposure to report.
if (Number(si.remaining_amount) <= 0) continue
if (!hasUsableRate(si.exchange_rate)) {
unconvertedFx.push({
type: 'payable',
source_id: si.id,
reference: si.supplier_invoice_number,
currency: si.currency as Currency,
amount_in_currency: si.remaining_amount,
})
continue
}
currencies.add(si.currency as Currency)
revaluablePayables.push(si)
}
if (currencies.size === 0) {
@@ -117,28 +231,49 @@ export async function previewCurrencyRevaluation(
totalGain: 0,
totalLoss: 0,
netEffect: 0,
unconvertedFx,
unconvertedFxCount: unconvertedFx.length,
missingClosingRates: [],
}
}
// Fetch closing rates
const rateMap = await fetchMultipleRates(
Array.from(currencies),
new Date(closingDate)
// Fetch closing rates one currency at a time through fetchExchangeRate: it
// is the documented booking path and returns null rather than one of the
// hardcoded display-only constants in getFallbackRate(). fetchMultipleRates
// pads its Map with those constants to keep a fully-populated-Map contract,
// which is fine for a rate widget and unacceptable for a verifikat: this
// function's output is posted to 3960/7960. Passing `supabase` uses the
// shared exchange_rates cache, so a balansdagen rate stays reproducible.
const currencyList = Array.from(currencies)
const fetched = await Promise.all(
currencyList.map((currency) => fetchExchangeRate(currency, new Date(closingDate), supabase))
)
const rateMap = new Map<Currency, number>()
const missingClosingRates: MissingClosingRate[] = []
for (let i = 0; i < currencyList.length; i++) {
const observed = fetched[i]
if (observed && Number.isFinite(observed.rate) && observed.rate > 0) {
rateMap.set(currencyList[i], observed.rate)
} else {
missingClosingRates.push({ currency: currencyList[i], date: closingDate })
}
}
const closingRates: Record<string, number> = {}
for (const [currency, rate] of rateMap) {
closingRates[currency] = rate.rate
closingRates[currency] = rate
}
const items: RevaluationItem[] = []
// Process receivables
for (const inv of receivables) {
const closingRate = rateMap.get(inv.currency)?.rate
// Process receivables (use the outstanding amount for partial payments,
// mirroring the payables' remaining_amount below)
for (const { inv, outstanding } of revaluableReceivables) {
const closingRate = rateMap.get(inv.currency)
if (!closingRate || !inv.exchange_rate) continue
const amountInCurrency = inv.total
const amountInCurrency = outstanding
const originalSek = Math.round(amountInCurrency * inv.exchange_rate * 100) / 100
const closingSek = Math.round(amountInCurrency * closingRate * 100) / 100
const difference = Math.round((closingSek - originalSek) * 100) / 100
@@ -160,12 +295,11 @@ export async function previewCurrencyRevaluation(
}
// Process payables (use remaining_amount for partial payments)
for (const si of payables) {
const closingRate = rateMap.get(si.currency as Currency)?.rate
for (const si of revaluablePayables) {
const closingRate = rateMap.get(si.currency as Currency)
if (!closingRate || !si.exchange_rate) continue
const amountInCurrency = si.remaining_amount
if (amountInCurrency <= 0) continue
const originalSek = Math.round(amountInCurrency * si.exchange_rate * 100) / 100
const closingSek = Math.round(amountInCurrency * closingRate * 100) / 100
@@ -282,6 +416,9 @@ export async function previewCurrencyRevaluation(
totalGain,
totalLoss,
netEffect,
unconvertedFx,
unconvertedFxCount: unconvertedFx.length,
missingClosingRates,
}
}
@@ -291,6 +428,8 @@ export async function previewCurrencyRevaluation(
*
* Returns null if no foreign-currency items exist.
* Throws if a revaluation entry already exists for this period (idempotency).
* Throws ClosingRateUnavailableError if any required balansdagen rate is
* missing: this entry must never be computed from an estimated rate.
*/
export async function executeCurrencyRevaluation(
supabase: SupabaseClient,
@@ -298,7 +437,7 @@ export async function executeCurrencyRevaluation(
closingDate: string,
fiscalPeriodId: string,
userId?: string
): Promise<CurrencyRevaluationResult | null> {
): Promise<CurrencyRevaluationResultWithExclusions | null> {
// Idempotency check: prevent double revaluation
const { count, error: checkError } = await supabase
.from('journal_entries')
@@ -318,6 +457,16 @@ export async function executeCurrencyRevaluation(
const preview = await previewCurrencyRevaluation(supabase, companyId, closingDate)
// Refuse before anything is posted. Checked ahead of the empty-preview
// shortcut on purpose: when every currency lacks a closing rate the item
// list is also empty, and returning null there would report "nothing to
// revalue" for what is really "we could not value it". A partial post is
// refused too: it would understate the FX result on 3960/7960 while looking
// like a complete balansdagen valuation (ÅRL 4 kap. 13 §).
if (preview.missingClosingRates.length > 0) {
throw new ClosingRateUnavailableError(preview.missingClosingRates)
}
if (preview.items.length === 0 || preview.lines.length === 0) {
return null
}
+58 -9
View File
@@ -7,20 +7,41 @@
*/
/**
* Resolve the SEK amount for a journal entry line.
* Resolve the SEK amount for a journal entry line, or null when the row carries
* no honest way to express it in kronor.
*
* This is the single conversion ladder for the whole codebase. Prefer it over
* `resolveSekAmount()` in any code that WRITES to the ledger.
*
* Priority:
* 1. If currency is SEK → return amount as-is
* 2. If amountSek is populated → return it (pre-computed SEK value)
* 3. If exchangeRate is available → compute amount * exchangeRate
* 4. Fallback → return amount (legacy data safety: assumes SEK)
* 1. Currency is SEK (or unset, which means SEK) → the amount already IS kronor
* 2. amountSek is populated → the pre-computed SEK value, rounded to öre
* 3. A positive exchangeRate is available → amount * exchangeRate, rounded to öre
* 4. Otherwise → null. A foreign amount with neither a stored SEK value nor a
* rate has no SEK value, and saying so is the only honest answer.
*
* Why null and not the raw amount: returning the foreign number relabels 100 EUR
* as 100 SEK. On a booking path every leg is then scaled by the same wrong
* factor, so the verifikation still balances, no DB trigger fires and nothing
* errors: a 1 000 EUR sale posts 1 000 kr to 3001 and 250 kr to 2611 instead of
* 11 500 kr and 2 875 kr at 11,50 SEK/EUR. That understates ruta 05 and ruta 10
* of the momsdeklaration by the same amount, which is an oriktig uppgift exposed
* to skattetillägg under SFL 49 kap 4 §. Nothing downstream can detect it,
* because the wrong number is indistinguishable from a correct one by then.
*
* The in-house precedent for refusing instead of guessing is the
* `match_batch_allocate` RPC (hard-fails with BATCH_FX_RATE_MISSING),
* `toSekOrThrow()` in supplier-invoice-entries.ts (SI_FX_RATE_MISSING),
* `itemToSekOrThrow()` in invoice-entries.ts (INVOICE_FX_RATE_MISSING) and
* lib/reports/supplier-ledger.ts, which skips unconvertible FX rows and counts
* them in `unconverted_fx_count` rather than faking a 1:1 conversion.
*/
export function resolveSekAmount(
export function resolveSekAmountOrNull(
amount: number,
amountSek: number | null | undefined,
currency: string | null | undefined,
exchangeRate: number | null | undefined
): number {
): number | null {
if (!currency || currency === 'SEK') {
return amount
}
@@ -33,8 +54,36 @@ export function resolveSekAmount(
return Math.round(amount * exchangeRate * 100) / 100
}
// Fallback: legacy data without conversion info: return original amount
return amount
return null
}
/**
* Lenient variant of `resolveSekAmountOrNull()` that falls back to the raw
* amount when no SEK value can be established.
*
* READ-ONLY CODE ONLY. Do not introduce new callers on a path that writes
* journal entry lines; use `resolveSekAmountOrNull()` and refuse (or skip) when
* it returns null.
*
* The fallback exists for legacy data: rows predating the FX columns genuinely
* have neither `*_sek` nor an `exchange_rate`, they are overwhelmingly SEK, and
* a report must still render them rather than blank out a whole ledger. Every
* remaining caller in `lib/reports/` establishes that the row is convertible
* BEFORE calling (see the `isFx && !hasRate` checks in ar-ledger.ts,
* supplier-ledger.ts, ar-reconciliation.ts, supplier-reconciliation.ts and
* kpi.ts), so for them the fallback is already unreachable and this function is
* just the shared arithmetic.
*
* Behaviour is deliberately unchanged from before the strict sibling existed, so
* that no existing call site shifts.
*/
export function resolveSekAmount(
amount: number,
amountSek: number | null | undefined,
currency: string | null | undefined,
exchangeRate: number | null | undefined
): number {
return resolveSekAmountOrNull(amount, amountSek, currency, exchangeRate) ?? amount
}
/**
+26 -10
View File
@@ -24,6 +24,7 @@ import {
fetchActiveDimensionRules,
isDimensionRuleExemptSource,
} from '@/lib/bookkeeping/dimension-rules'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
import { getActor } from '@/lib/bookkeeping/actor-context'
@@ -574,21 +575,36 @@ export async function commitEntry(
entityId: entryId,
})
} else if (rules.some((r) => r.rule_type === 'required')) {
const { data: ruleLines, error: ruleLinesError } = await supabase
.from('journal_entry_lines')
.select('account_number, dimensions, journal_entries!inner(source_type)')
.eq('journal_entry_id', entryId)
if (ruleLinesError || !ruleLines) {
// READ ONLY: this is a pre-commit policy check, not a write. The two-step
// fetch (lib/bookkeeping/entry-lines.ts) replaces a
// `journal_entries!inner(source_type)` embed so no query in the commit
// path can compile to the correlated LATERAL join that scans every
// tenant's journal_entry_lines. The parent is reattached under the same
// `journal_entries` key, so the exemption read below is unchanged. The
// entry-side company_id filter is defense in depth (repo convention);
// commitEntry is always called with the entry's own company.
type RuleLine = {
account_number: string
dimensions: Record<string, string>
journal_entries: { source_type: string }
}
let typedLines: RuleLine[] | null = null
try {
typedLines = await fetchEntryLines<RuleLine>({
supabase,
entryColumns: 'source_type',
lineColumns: 'account_number, dimensions',
filterEntries: (q: EntryLinesQuery) => q.eq('id', entryId).eq('company_id', companyId),
})
} catch {
typedLines = null
}
if (!typedLines) {
log.warn('line fetch for mandatory dimension check failed — enforcement skipped (fail-open)', {
companyId,
entityId: entryId,
})
} else {
const typedLines = ruleLines as unknown as Array<{
account_number: string
dimensions: Record<string, string>
journal_entries: { source_type: string }
}>
// System/correction sources are exempt — see
// DIMENSION_RULE_EXEMPT_SOURCE_TYPES (imported history, bokslut
// mechanics and credit instruments must never be blocked by policy).
+55
View File
@@ -0,0 +1,55 @@
/**
* Non-blocking advisories attached to a SUCCESSFUL fiscal-period create.
*
* `POST /api/bookkeeping/fiscal-periods` returns 200 with an optional
* `warnings: Array<{ code, message }>` (the same shape the invoice booking
* routes use). The route omits the key entirely when it has nothing to say, so
* the common case is "no key at all", not "empty array".
*
* Today the only code is `PRIOR_FISCAL_YEAR_STILL_OPEN`: the new räkenskapsår
* was created while a prior one is still fully open, which is the normal and
* legally required state while the bokslut runs (BFL 5 kap 2 § forces the new
* year's affärshändelser to be booked within weeks; BFL 6 kap gives the prior
* year six months to be finished). It is information, never a failure.
*/
export interface FiscalPeriodWarning {
code: string
message: string
}
/**
* Read the `warnings` array off a fiscal-period create response.
*
* Defensive by design: absence of the key, a non-array value, and malformed
* entries all collapse to `[]` so a shape change can never render an empty
* advisory line. Note that a FAILED create carries its copy in
* `{ error: ... }`, not in `warnings`, so error payloads yield `[]` here.
*/
export function extractFiscalPeriodWarnings(payload: unknown): FiscalPeriodWarning[] {
if (!payload || typeof payload !== 'object') return []
const raw = (payload as { warnings?: unknown }).warnings
if (!Array.isArray(raw)) return []
const warnings: FiscalPeriodWarning[] = []
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue
const { code, message } = entry as { code?: unknown; message?: unknown }
if (typeof code !== 'string' || code.length === 0) continue
if (typeof message !== 'string' || message.trim().length === 0) continue
warnings.push({ code, message })
}
return warnings
}
/**
* The advisory text to render, or `''` when there is nothing to say.
*
* Joined into a single string on purpose: attention is ONE ochre sentence, not
* a stack of them (UI-migration convention 6). Only one code exists today.
*/
export function fiscalPeriodAdvisoryText(payload: unknown): string {
return extractFiscalPeriodWarnings(payload)
.map((w) => w.message)
.join(' ')
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Which single ledger line carries a manual entry's FX metadata.
*
* `journal_entry_lines.debit_amount / credit_amount` are ALWAYS SEK. A foreign
* affärshändelse is converted to SEK for those columns, and the foreign figure
* survives only as `currency` + `amount_in_currency` + `exchange_rate` stamped
* onto ONE leg: the leg that is the foreign monetary item. That is the ÅRL
* 4 kap. 13 § line: fordringar och skulder i utländsk valuta are what gets
* revalued at balansdagens kurs, while a maskin, ett lager or en kostnad is
* measured once in SEK and never revalued.
*
* THE GENERATORS ARE THE REFERENCE. Each puts the metadata on exactly one leg,
* the monetary item carrying the whole document amount:
* lib/bookkeeping/invoice-entries.ts → 1510, amount = invoice.total
* lib/bookkeeping/supplier-invoice-entries.ts → 2440, amount = invoice.total
* lib/bookkeeping/transaction-entries.ts → the settlement/cash leg,
* amount = |transaction.amount|
* Never on a VAT leg, never on a P&L leg, never on two legs at once. Note that
* the account prefix is NOT the rule: 1510, 2440 and 19xx all appear.
*
* Everything that reads the metadata back finds the line BY ACCOUNT and then
* trusts the label, so a misplaced stamp is not cosmetic:
* lib/reconciliation/bank-reconciliation.ts `ledgerLineAmountIn()` reconciles
* a foreign cash account against `amount_in_currency` on that account's own
* lines. Metadata on the SEK leg instead leaves the EUR account with no
* comparable figure at all, and labels a SEK movement as a EUR one.
* lib/invoices/voucher-matching.ts narrows candidate vouchers with
* `journal_entry_lines.currency = invoice.currency` under the 1510/2440
* prefix: the metadata has to be on that leg or the voucher is invisible.
* lib/core/bookkeeping/storno-service.ts mirrors whatever is stored, so a
* wrong stamp is copied into the rättelse as well.
*
* This module is pure and order-independent on purpose: the caller resolves the
* slot in a PRE-PASS over all lines and only then builds them, so the answer
* cannot depend on which line the loop happens to reach first.
*/
import { roundOre } from '@/lib/money'
/** The subset of a form/entry line the slot decision needs. Amounts are SEK. */
export interface FxSlotLine {
account_number: string
debit_amount: number
credit_amount: number
/** FX label already on the line (hydrated draft, agent-created entry). */
currency?: string | null
}
export interface FxSlotInput {
/** Entry-level currency picked in the form. 'SEK' means nothing to place. */
entryCurrency: string
/** SEK per 1 unit of `entryCurrency`. <= 0 means the user has no rate yet. */
exchangeRate: number
/** The entry's amount expressed in `entryCurrency`. */
foreignAmount: number
}
export type FxSlotResolution =
/** Nothing to place: SEK entry, or no usable rate/amount yet. */
| { kind: 'none' }
/** These lines already carry their own FX metadata: leave them alone and
* stamp nothing else, or two lines end up claiming the same foreign amount. */
| { kind: 'preset'; indexes: number[] }
/** Stamp exactly this line, and no other. */
| { kind: 'slot'; index: number }
/**
* The metadata cannot be placed. The caller must REFUSE rather than drop it:
* `journal_entries` has no currency or exchange_rate column, so a rate that
* is not written to a line is unrecoverable afterwards.
*/
| {
kind: 'unplaceable'
reason: 'no_carrier' | 'ambiguous' | 'currency_conflict'
/** Accounts the message should name (candidates, or the conflicting leg). */
accounts: string[]
/** The line-level currency that conflicts with the entry currency. */
lineCurrency?: string
}
/**
* BAS account conventionally denominated in a given currency. Mirrors
* CURRENCY_LEDGER_DEFAULTS in lib/cash-accounts/service.ts; copied rather than
* imported because this module is pulled into a client bundle and that one
* reaches the server logger and the SIE account-sync path.
*
* Used ONLY to break a tie between two otherwise equal monetary legs (a
* SEK↔EUR växling hits both legs with the same SEK amount, so the amount alone
* cannot say which leg is the EUR one).
*/
export const CONVENTIONAL_FX_LEDGER: Record<string, string> = {
SEK: '1930',
EUR: '1932',
USD: '1933',
GBP: '1934',
}
/**
* Monetary items in BAS terms (ÅRL 4 kap. 13 §): kassa/bank, fordringar,
* interimsfordringar, and the liability side except eget kapital (20xx),
* obeskattade reserver (21xx) and avsättningar (22xx).
*
* Deliberately excluded:
* 10xx-14xx anläggningstillgångar och lager: measured in SEK at acquisition,
* never revalued, so they never carry a foreign denomination.
* 18xx kortfristiga placeringar: securities, not a monetary claim.
* 26xx moms: Swedish VAT is settled in SEK and a VAT leg is a derived
* sub-amount, never the document total.
*/
const MONETARY_ACCOUNT_PREFIXES = ['15', '16', '17', '19', '23', '24', '25', '27', '28', '29']
export function isMonetaryLedgerAccount(accountNumber: string): boolean {
return MONETARY_ACCOUNT_PREFIXES.includes(accountNumber.slice(0, 2))
}
function normalizeCurrency(currency: string | null | undefined): string {
return (currency ?? '').trim().toUpperCase()
}
/**
* Resolve which line carries the entry's FX metadata.
*
* THE RULE, in order:
* 1. A line that already carries a non-SEK `currency` IS the FX line
* (hydrated draft, MCP/agent-created entry). Those lines own the
* metadata and nothing else is stamped. A line labelled with a DIFFERENT
* currency than the entry is a conflict, not something to overwrite.
* 2. Otherwise the FX line is the monetary leg (see
* {@link isMonetaryLedgerAccount}) whose SEK amount equals
* `foreignAmount × exchangeRate`: the leg that IS the foreign amount,
* which is what the generators stamp. P&L and VAT legs are excluded, so a
* EUR supplier invoice booked 4010/2440 lands on 2440, and a EUR card
* purchase booked 5410/1930 lands on 1930.
* 3. When several monetary legs match the amount (a SEK↔EUR växling books the
* same SEK figure on both legs), the leg on the account conventionally
* denominated in the entry currency wins (EUR → 1932); failing that, legs
* on an account conventionally denominated in ANOTHER currency (1930 =
* SEK) are dropped, which lands a EUR transfer on a non-default EUR
* account too.
* 4. Anything still undecided is `unplaceable`. Never "first line wins":
* that is the order-dependence this function exists to remove.
*/
export function resolveFxLineSlot(lines: FxSlotLine[], input: FxSlotInput): FxSlotResolution {
const entryCurrency = normalizeCurrency(input.entryCurrency) || 'SEK'
const hasRate = Number.isFinite(input.exchangeRate) && input.exchangeRate > 0
const hasAmount = Number.isFinite(input.foreignAmount) && input.foreignAmount > 0
const entryIsForeign = entryCurrency !== 'SEK' && hasRate && hasAmount
// 1. Lines that already speak for themselves.
const preset = lines
.map((line, index) => ({ index, line, currency: normalizeCurrency(line.currency) }))
.filter((l) => l.currency !== '' && l.currency !== 'SEK')
if (preset.length > 0) {
const conflicting = entryIsForeign ? preset.filter((l) => l.currency !== entryCurrency) : []
if (conflicting.length > 0) {
return {
kind: 'unplaceable',
reason: 'currency_conflict',
accounts: conflicting.map((l) => l.line.account_number),
lineCurrency: conflicting[0].currency,
}
}
return { kind: 'preset', indexes: preset.map((l) => l.index) }
}
if (!entryIsForeign) return { kind: 'none' }
// 2. Candidates: monetary legs whose SEK amount is the foreign amount.
// The tolerance absorbs the double rounding on the way in: the foreign
// amount is itself öre-rounded, so converting it back can miss the booked
// SEK figure by up to half an öre times the rate.
const expectedSek = roundOre(input.foreignAmount * input.exchangeRate)
const tolerance = roundOre(input.exchangeRate * 0.005) + 0.01
const candidates = lines
.map((line, index) => ({ index, line }))
.filter(({ line }) => normalizeCurrency(line.currency) === '')
.filter(({ line }) => isMonetaryLedgerAccount(line.account_number))
.filter(({ line }) => {
const sek = roundOre(Math.abs((line.debit_amount || 0) - (line.credit_amount || 0)))
return Math.abs(sek - expectedSek) <= tolerance
})
if (candidates.length === 0) {
return {
kind: 'unplaceable',
reason: 'no_carrier',
accounts: lines.map((l) => l.account_number).filter((a) => a !== ''),
}
}
if (candidates.length === 1) return { kind: 'slot', index: candidates[0].index }
// 3. Tie-break by denomination of the account itself.
const entryCurrencyAccount = CONVENTIONAL_FX_LEDGER[entryCurrency]
const onEntryCurrencyAccount = candidates.filter(
(c) => c.line.account_number === entryCurrencyAccount
)
if (onEntryCurrencyAccount.length === 1) {
return { kind: 'slot', index: onEntryCurrencyAccount[0].index }
}
if (onEntryCurrencyAccount.length === 0) {
const otherCurrencyAccounts = new Set(
Object.entries(CONVENTIONAL_FX_LEDGER)
.filter(([currency]) => currency !== entryCurrency)
.map(([, account]) => account)
)
const remaining = candidates.filter((c) => !otherCurrencyAccounts.has(c.line.account_number))
if (remaining.length === 1) return { kind: 'slot', index: remaining[0].index }
}
// 4. Still undecided: refuse, do not guess.
return {
kind: 'unplaceable',
reason: 'ambiguous',
accounts: candidates.map((c) => c.line.account_number),
}
}
+120 -33
View File
@@ -1,5 +1,5 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { resolveSekAmountOrNull, buildCurrencyMetadata } from './currency-utils'
import { resolveBookingAccount } from './accruals/account-suggestions'
import {
coerceDimensionsBag,
@@ -25,6 +25,91 @@ import type {
const log = createLogger('invoice-entries')
/**
* Stable code for the "foreign-currency customer invoice without a rate"
* refusal. Registered in lib/errors/structured-errors.ts so REST routes, the
* MCP server and getErrorMessage() all translate it the same way.
*
* Sales-side twin of SI_FX_RATE_MISSING (supplier-invoice-entries.ts).
*/
export const INVOICE_FX_RATE_MISSING = 'INVOICE_FX_RATE_MISSING' as const
/**
* Raised when an invoice booking path is asked to translate a foreign-currency
* amount that has no usable exchange rate.
*
* The generators below derive every FX leg from the per-item amounts, and items
* carry no `*_sek` column: `exchange_rate` is the only SEK source they have. The
* old per-file fallback returned the RAW foreign amount, and because the 1510
* debit is derived from the sum of the credits on the FX branch, every leg was
* scaled by the same wrong factor: the verifikation still balanced, no DB
* trigger fired and nothing errored. 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 SEK/EUR,
* understating ruta 05 and ruta 10 of the momsdeklaration by the same amount:
* an oriktig uppgift exposed to skattetillägg under SFL 49 kap 4 §.
*
* Refusing instead of guessing follows the `match_batch_allocate` RPC
* (BATCH_FX_RATE_MISSING) and `toSekOrThrow()` in supplier-invoice-entries.ts.
*
* The same refusal covers the header-level fallbacks (no-items bookings and
* the payment entry) via `headerToSekOrThrow` below: those paths DO honour a
* populated `*_sek` column, so only rows with no SEK source at all refuse.
*/
export class InvoiceFxRateMissingError extends Error {
readonly code = INVOICE_FX_RATE_MISSING
constructor(public readonly currency: string) {
super(
`Invoice is in ${currency} but has no exchange rate on file; refusing to post it as if 1 ${currency} = 1 SEK.`
)
this.name = 'InvoiceFxRateMissingError'
}
}
/**
* Convert an invoice-currency item amount to SEK for a journal entry line.
*
* SEK invoices short-circuit exactly as before, and so does any invoice with a
* legitimately supplied positive rate: the only new behaviour is the refusal
* above when a foreign invoice reaches a booking path with no rate at all.
* `amountSek` is deliberately null: an InvoiceItem has no per-item SEK column,
* so the rate is the only honest source at item granularity.
*/
function itemToSekOrThrow(
amount: number,
currency: string | null | undefined,
exchangeRate: number | null | undefined
): number {
const sek = resolveSekAmountOrNull(amount, null, currency, exchangeRate)
if (sek === null) throw new InvoiceFxRateMissingError(currency || 'okänd valuta')
return sek
}
/**
* Convert an invoice-level (header) amount to SEK for a journal entry line.
*
* Same refusal contract as `itemToSekOrThrow`, but honours a pre-computed
* `*_sek` column when the row carries one: header amounts (subtotal /
* vat_amount / total) have SEK twins that items lack. Rows that DO carry a
* `*_sek` value or a usable rate convert exactly as before; only the "foreign
* amount with no SEK source at all" case changes, from silently relabelling
* the foreign number as kronor (the lenient `resolveSekAmount` ladder, which
* currency-utils marks READ-ONLY CODE ONLY) to the same INVOICE_FX_RATE_MISSING
* refusal the item-driven generators raise. Without this, a rate-less foreign
* invoice booked through a caller without hydrated items posted its raw
* foreign number as kronor (1 250 EUR → 1 250 kr on 1510): balanced, so no
* trigger fired, and undetectable downstream.
*/
function headerToSekOrThrow(
amount: number,
amountSek: number | null | undefined,
currency: string | null | undefined,
exchangeRate: number | null | undefined
): number {
const sek = resolveSekAmountOrNull(amount, amountSek, currency, exchangeRate)
if (sek === null) throw new InvoiceFxRateMissingError(currency || 'okänd valuta')
return sek
}
/**
* Build the invoice identifier used in line_description. Prefers the assigned
* invoice number; falls back to a draft tag with the first 8 chars of the
@@ -86,14 +171,11 @@ function generatePerRateLines(
// grouping so they can't produce a zero-amount revenue line.
items = items.filter((item) => item.line_type !== 'text')
// Helper: convert item amount to SEK when dealing with foreign currency
const toSek = (amount: number): number => {
if (!isForeign) return amount
if (exchangeRate != null && exchangeRate > 0) {
return Math.round(amount * exchangeRate * 100) / 100
}
return amount // fallback for legacy data
}
// Helper: convert item amount to SEK when dealing with foreign currency.
// Refuses (InvoiceFxRateMissingError) rather than relabelling the foreign
// number as kronor: see itemToSekOrThrow above.
const toSek = (amount: number): number =>
itemToSekOrThrow(amount, currency, exchangeRate)
// Check if items have per-line vat_rate set (new invoices)
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
@@ -250,8 +332,8 @@ function generatePerRateLines(
*
* Foreign-currency invoices: ROT/RUT-avdrag is a Sweden-only rule, so
* receivables on 1513 are always recorded in SEK. We use the same SEK
* conversion as the rest of the entry (toSek closure logic on the caller
* side reproduced here for parity with generatePerRateLines).
* conversion as the rest of the entry (the shared itemToSekOrThrow helper, so
* this function and generatePerRateLines cannot drift).
*/
function generateRotRutLines(
items: InvoiceItem[],
@@ -262,15 +344,11 @@ function generateRotRutLines(
side: 'debit' | 'credit' = 'debit',
): { lines: CreateJournalEntryLineInput[]; totalSek: number } {
const lines: CreateJournalEntryLineInput[] = []
const isForeign = currency != null && currency !== 'SEK'
const toSek = (amount: number): number => {
if (!isForeign) return amount
if (exchangeRate != null && exchangeRate > 0) {
return Math.round(amount * exchangeRate * 100) / 100
}
return amount
}
// Same refusal as generatePerRateLines: 1513 is a kronor receivable on
// Skatteverket, so an unconvertible foreign amount must not land there.
const toSek = (amount: number): number =>
itemToSekOrThrow(amount, currency, exchangeRate)
let totalSek = 0
@@ -385,9 +463,11 @@ export async function createInvoiceJournalEntry(
{ deferAccruals: true, defaultDimensions }
))
} else {
// Fallback: no items available, use invoice-level amounts
// Fallback: no items available, use invoice-level amounts. Strict
// conversion: a rate-less foreign header must refuse exactly like the
// item-driven path, not post the raw foreign number as kronor.
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
const subtotalSek = headerToSekOrThrow(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
creditLines.push({
account_number: revenueAccount,
@@ -399,7 +479,7 @@ export async function createInvoiceJournalEntry(
if (invoice.vat_amount > 0) {
if (isForeign) {
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
const vatSek = headerToSekOrThrow(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
creditLines.push({
account_number: vatAccount,
@@ -435,7 +515,7 @@ export async function createInvoiceJournalEntry(
const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount, 0)
const debitAmount = isForeign
? Math.round(totalCredits * 100) / 100
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
: headerToSekOrThrow(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
const arAmount = Math.round((debitAmount - rotRut.totalSek) * 100) / 100
lines.push({
@@ -508,10 +588,14 @@ export async function createInvoicePaymentJournalEntry(
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// When paymentAmount is provided, use it for the 1930/1510 line amounts.
// Otherwise use the full invoice total (backward compatible).
// Otherwise use the full invoice total (backward compatible). Strict
// conversion on both: a rate-less foreign payment would otherwise clear
// 1510 with the raw foreign number relabelled as kronor (balanced against
// an equally wrong 1930 debit, so nothing downstream could catch it).
// Rows carrying total_sek or a usable rate convert exactly as before.
const bookedSekAmount = isPartial
? resolveSekAmount(paymentAmount, null, invoice.currency, invoice.exchange_rate)
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
? headerToSekOrThrow(paymentAmount, null, invoice.currency, invoice.exchange_rate)
: headerToSekOrThrow(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
const lines: CreateJournalEntryLineInput[] = []
@@ -648,10 +732,12 @@ export async function createCreditNoteJournalEntry(
})
}
} else {
// Fallback: invoice-level amounts
// Fallback: invoice-level amounts. Same strict conversion as the
// createInvoiceJournalEntry fallback: a rate-less foreign credit note
// must refuse, not reverse the receivable with a mislabelled number.
const revenueAccount = getRevenueAccount(creditNote.vat_treatment, entityType)
const absSubtotal = Math.abs(resolveSekAmount(creditNote.subtotal, creditNote.subtotal_sek, creditNote.currency, creditNote.exchange_rate))
const absVat = Math.abs(resolveSekAmount(creditNote.vat_amount, creditNote.vat_amount_sek, creditNote.currency, creditNote.exchange_rate))
const absSubtotal = Math.abs(headerToSekOrThrow(creditNote.subtotal, creditNote.subtotal_sek, creditNote.currency, creditNote.exchange_rate))
const absVat = Math.abs(headerToSekOrThrow(creditNote.vat_amount, creditNote.vat_amount_sek, creditNote.currency, creditNote.exchange_rate))
debitLines.push({
account_number: revenueAccount,
@@ -756,9 +842,10 @@ export async function createInvoiceCashEntry(
{ defaultDimensions }
))
} else {
// Fallback: invoice-level amounts
// Fallback: invoice-level amounts. Strict conversion, same rationale as
// the createInvoiceJournalEntry fallback above.
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
const subtotalSek = headerToSekOrThrow(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
creditLines.push({
account_number: revenueAccount,
@@ -769,7 +856,7 @@ export async function createInvoiceCashEntry(
})
if (invoice.vat_amount > 0) {
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
const vatSek = headerToSekOrThrow(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
creditLines.push({
account_number: vatAccount,
@@ -794,7 +881,7 @@ export async function createInvoiceCashEntry(
const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount, 0)
const cashDebit = isForeign
? Math.round(totalCredits * 100) / 100
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
: headerToSekOrThrow(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
const bankAmount = Math.round((cashDebit - rotRut.totalSek) * 100) / 100
lines.push({
account_number: settlementAccountNumber,
+114 -8
View File
@@ -34,7 +34,13 @@
* tx.exchange_rate : populated at ingest only when tx.currency != SEK
* tx.amount_sek : pre-computed SEK at ingest for non-SEK tx
* invoice.currency : currency the invoice was issued in
* invoice.exchange_rate: the rate at which AR was originally booked on 1510
* invoice.exchange_rate: the rate at which AR was originally booked on 1510.
* REQUIRED whenever the invoice is foreign, regardless of
* the bank tx currency: without it the SEK value of the
* receivable was never established, so the kursvinst /
* kursförlust on settlement is not computable and the
* helper throws InvoiceBookingRateMissingError rather than
* inventing a number. Never needed for a SEK invoice.
*
* Bank-leg (1930) = always the actual SEK that hit the bank.
* AR-leg (1510) = the SEK value of the customer-debt reduction at the
@@ -55,6 +61,70 @@ import { resolveSekAmount } from './currency-utils'
const TWO_DP = (n: number): number => Math.round(n * 100) / 100
/**
* Structured code for "the foreign-currency invoice carries no usable booking
* rate". Registered in lib/errors/structured-errors.ts, so errorResponse()
* turns a throw into the canonical 400 envelope and getErrorMessage() resolves
* the Swedish sentence. Mirrors the honest BATCH_FX_RATE_MISSING guard in the
* match_batch_allocate RPC, which refuses the same situation server-side.
*/
export const MATCH_INVOICE_BOOKING_RATE_MISSING = 'MATCH_INVOICE_BOOKING_RATE_MISSING' as const
/**
* Upper bound mirrors the match_batch_allocate RPC (0 < rate < 100000): a rate
* that far out is as unusable as NULL and must not silently value the AR leg.
*/
const MAX_PLAUSIBLE_FX_RATE = 100000
/**
* Raised when a payment settles a foreign-currency invoice whose SEK value was
* never established. Booking the payment would require a kursvinst/kursförlust
* on 3960/7960, and that figure is the difference between the SEK actually
* received and the SEK the receivable was booked at. With no booking rate there
* is no second number: any "difference" is fabricated. Defaulting the rate to 1
* (the old `?? 1`) read the foreign amount as if it were SEK and dumped the
* entire bank receipt minus that number onto 3960 as a phantom kursvinst, with
* revenue understated by the same amount. The verifikat balanced, so no DB
* trigger fired: only refusing to post catches it.
*/
export class InvoiceBookingRateMissingError extends Error {
readonly code = MATCH_INVOICE_BOOKING_RATE_MISSING
constructor(
public readonly invoiceCurrency: string,
public readonly exchangeRate: number | null,
) {
super(
`Invoice is denominated in ${invoiceCurrency} but carries no usable booking exchange rate ` +
`(exchange_rate=${exchangeRate === null ? 'null' : String(exchangeRate)}). ` +
'The FX difference on settlement cannot be computed without the rate the receivable was booked at.',
)
this.name = 'InvoiceBookingRateMissingError'
}
}
/**
* The invoice's booking rate, or a loud failure. Only ever called when the
* INVOICE is foreign (settled cross-currency or in its own foreign currency):
* a SEK invoice never reaches it, because a SEK receivable already carries
* its own SEK value and needs no rate at all. "Currency is SEK" and "rate is
* missing" are therefore separate conditions, not one fallback.
*/
function requireInvoiceBookingRate(
exchangeRate: number | null,
invoiceCurrency: string,
): number {
if (
exchangeRate == null ||
!Number.isFinite(exchangeRate) ||
exchangeRate <= 0 ||
exchangeRate >= MAX_PLAUSIBLE_FX_RATE
) {
throw new InvoiceBookingRateMissingError(invoiceCurrency, exchangeRate)
}
return exchangeRate
}
export interface PaymentClearingTx {
amount: number
amount_sek: number | null
@@ -108,8 +178,20 @@ export interface PaymentClearingLines {
* Build the verifikat lines for a customer-invoice payment matched against
* a bank tx. Pure: no DB calls. Caller decides how to persist.
*
* # Same-currency
* Bank-leg = AR-leg = bankSek. No FX diff line.
* # Same-currency, both SEK (or SEK invoice paid from a foreign account)
* Bank-leg = AR-leg = bankSek. No FX diff line: a SEK receivable has no FX
* exposure, so the invoice's exchange_rate is never consulted.
*
* # Same-currency, foreign (EUR invoice settled by a EUR bank tx)
* The foreign units paid are known exactly (|tx.amount|), so:
* arSek = |tx.amount| × invoice.exchange_rate (booking rate)
* fxDiffSek = arSek − bankSek
* 1510 is cleared at the SEK value the receivable was booked at and the
* difference against the actual SEK bank leg is the REALIZED kursvinst /
* kursförlust (3960/7960), exactly like the cross-currency path. Booking
* arSek = bankSek here (the pre-fix behaviour) over/under-credited 1510 by
* the whole rate movement and stranded the realized kursdiff on the balance
* sheet instead of the P&L.
*
* # Cross-currency with explicit paidInInvoiceCurrency (preferred path)
* The caller supplies how many units of the invoice's currency this bank
@@ -130,6 +212,16 @@ export interface PaymentClearingLines {
* deferred path leaves the GL slightly understated until the final
* settlement closes the invoice.
*
* # Foreign invoice with no booking rate
* Throws InvoiceBookingRateMissingError (code
* MATCH_INVOICE_BOOKING_RATE_MISSING). Every foreign-invoice path above
* (same-currency foreign and both cross-currency paths) values the AR leg
* at the invoice's booking rate; without it the SEK value of the receivable
* is unknown and the kursvinst/kursförlust is not a computable number. The
* caller must obtain the rate the invoice was booked at instead of posting
* a guess. Only a SEK invoice can never throw: it is settled entirely by
* the SEK branches and never consults invoice.exchange_rate.
*
* # paymentAccount
* The bank-leg account (the debit line below). Defaults to '1930': callers
* that haven't resolved the transaction's actual cash account keep booking
@@ -188,17 +280,31 @@ export function buildInvoicePaymentClearingLines(
arSek = bankSek
}
fxDiffSek = 0
} else if (sameCurrency || !invoiceIsForeign) {
// Same currency (or SEK invoice paid by SEK tx): the customer-debt
// reduction equals what hit the bank. No FX diff possible.
} else if (!invoiceIsForeign) {
// SEK invoice settled from a non-SEK bank account: the receivable is a
// kronor claim, so the customer-debt reduction equals the SEK that hit
// the bank. The invoice's exchange_rate is never consulted.
arSek = bankSek
fxDiffSek = 0
} else if (sameCurrency) {
// Foreign invoice settled in its own currency (EUR invoice, EUR tx). The
// foreign units paid are known exactly, so clear 1510 at the invoice's
// BOOKING rate for those units and book the difference against the actual
// SEK bank leg as the realized kursdiff, exactly like the cross-currency
// path below. Setting arSek = bankSek here (the old behaviour) credited
// 1510 at the settlement-date value: a 1 000 EUR invoice booked at 11,30
// (11 300,00 on 1510) settled by a tx worth 11 496,70 kr over-credited
// 1510 by 196,70 and the realized kursvinst never reached 3960.
const invRate = requireInvoiceBookingRate(invoice.exchange_rate, invoice.currency)
const paidForeign = Math.abs(tx.amount)
arSek = TWO_DP(paidForeign * invRate)
fxDiffSek = TWO_DP(arSek - bankSek)
} else if (paidInInvoiceCurrency != null && paidInInvoiceCurrency > 0) {
// Proper FX path: caller computed the invoice-currency equivalent
// using today's spot rate. AR-leg comes off 1510 at the invoice's
// BOOKING rate (so the GL credit matches what was originally posted
// for those units of foreign currency). FX diff balances the verifikat.
const invRate = invoice.exchange_rate ?? 1
const invRate = requireInvoiceBookingRate(invoice.exchange_rate, invoice.currency)
arSek = TWO_DP(paidInInvoiceCurrency * invRate)
fxDiffSek = TWO_DP(arSek - bankSek)
} else {
@@ -206,7 +312,7 @@ export function buildInvoicePaymentClearingLines(
// callers, Riksbanken lookup failed with no manual override). Same
// pre-FX-rewrite behaviour: full-clear gets FX diff, partial defers.
const invRemainingForeign = invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0)
const invRate = invoice.exchange_rate ?? 1
const invRate = requireInvoiceBookingRate(invoice.exchange_rate, invoice.currency)
const arSekFullRemaining = TWO_DP(invRemainingForeign * invRate)
if (bankSek >= arSekFullRemaining - 0.005) {
arSek = arSekFullRemaining
+118
View File
@@ -0,0 +1,118 @@
/**
* The single definition of "what amount does this journal_entry_line carry,
* expressed in currency X".
*
* THE ONE THING TO KNOW: `journal_entry_lines.debit_amount` /
* `credit_amount` are ALWAYS SEK. `lib/bookkeeping/currency-utils.ts`
* (`resolveSekAmount` + `buildCurrencyMetadata`) converts a foreign amount to
* SEK for those columns and then stamps `currency` + `amount_in_currency` onto
* the SAME line as metadata about the underlying DOCUMENT. So `line.currency`
* is a LABEL, never evidence that the debit/credit figure is in that currency.
*
* Any guard shaped like "the currencies match, so these amounts are
* comparable" therefore passes on exactly the FX rows it exists to catch, and
* then compares a SEK ledger amount against a foreign figure. Every site that
* needs a ledger line's amount in some currency must go through this module
* instead of reading the raw column.
*
* Extracted from lib/reconciliation/bank-reconciliation.ts (which re-exports
* `ledgerLineAmountIn` unchanged for its existing importers) so the invoice /
* supplier-invoice voucher matchers share one rule with bank reconciliation.
*/
/**
* The columns any site needs off a journal entry line to answer "how much, in
* which unit". Numerics arrive as strings over PostgREST on some paths, hence
* the unions.
*/
export interface LedgerLineAmount {
debit_amount: number | string | null
credit_amount: number | string | null
/** Labels the DOCUMENT the line came from. NOT the unit of debit/credit. */
currency?: string | null
/** The line's amount expressed in `currency`: the only non-SEK figure here. */
amount_in_currency?: number | string | null
}
/**
* The unit a DOCUMENT's amounts are quoted in, for use as the `currency`
* argument below.
*
* `invoices.currency` is `text default 'SEK'` and therefore NULLABLE in
* Postgres, even though `Invoice['currency']` is a non-null `Currency` union:
* the type lies about legacy rows. A missing code has always meant kronor
* (that is what the column default encodes), so it must resolve to 'SEK'.
*
* Without this, a NULL tests `!== 'SEK'` and a plain domestic invoice is
* classified FOREIGN: it would be sent down the `amount_in_currency` path,
* where no line can be expressed in `null` and every candidate disappears.
* Deliberately NOT upper-cased: the comparison downstream is against the
* ledger's own `currency` label, and the two must be compared exactly as
* stored rather than through a normalisation only one side goes through.
*/
export function documentCurrency(code: string | null | undefined): string {
return code || 'SEK'
}
/**
* A ledger line's contribution to a reconciliation carried out in `currency`,
* signed like a bank movement (+ money in, - money out), or `null` when the
* line simply cannot be expressed in that currency.
*
* currency === 'SEK': debit - credit, full stop. The ledger columns already
* hold SEK, so the label is irrelevant (a EUR supplier invoice paid from a
* SEK account is a SEK movement on that account). This is the 95% path and
* it never returns null: SEK-only companies are untouched by any of this.
* currency === anything else: only `amount_in_currency` can answer, and only
* when the line is actually labelled with that same currency. The MAGNITUDE
* comes from that column; the DIRECTION comes from the debit/credit side,
* because a handful of production rows carry a negatively-signed
* amount_in_currency while the debit/credit side is always authoritative.
* No amount_in_currency, or a different label, means the row carries no
* rate: null. Never a guess, never the SEK figure as a stand-in.
*/
export function ledgerLineAmountIn(line: LedgerLineAmount, currency: string): number | null {
const debit = Number(line.debit_amount) || 0
const credit = Number(line.credit_amount) || 0
if (currency === 'SEK') return debit - credit
if (line.currency !== currency) return null
if (line.amount_in_currency == null) return null
const foreign = Number(line.amount_in_currency)
if (!Number.isFinite(foreign)) return null
const magnitude = Math.abs(foreign)
if (debit > 0) return magnitude
if (credit > 0) return -magnitude
return 0
}
/**
* The same rule, but returning the POSITIVE magnitude booked on one specific
* side, which is what the sub-ledger matchers want: an AR credit (151x), an AP
* debit (244x) or a liquid-funds debit (19xx) is always quoted as a positive
* "how much of the invoice did this voucher settle".
*
* Returns `null` when the line carries no amount in `currency` (see
* {@link ledgerLineAmountIn}); a value <= 0 means the line moves the OTHER way
* and is not a settlement on the requested side.
*
* SEK reads the requested column directly and gross, exactly as every call
* site did before this module existed: `currency === 'SEK'` never returns null
* and never nets the two columns, so SEK-only companies see byte-identical
* behaviour.
*/
export function ledgerLineSideAmountIn(
line: LedgerLineAmount,
currency: string,
side: 'debit' | 'credit'
): number | null {
if (currency === 'SEK') {
return Number(side === 'debit' ? line.debit_amount : line.credit_amount) || 0
}
const signed = ledgerLineAmountIn(line, currency)
if (signed === null) return null
return side === 'debit' ? signed : -signed
}
+104 -15
View File
@@ -36,6 +36,33 @@ function getCapitalizationThreshold(year: number): number {
return PRISBASBELOPP_HALVES[LATEST_KNOWN_YEAR]
}
/**
* Resolve the SEK value of a transaction, or null when it cannot be
* established.
*
* Deliberately stricter than `resolveSekAmount()` in currency-utils: that
* helper falls back to the raw foreign amount for legacy rows, which is
* tolerable for a line amount (the entry still balances against itself) but
* never for a comparison against a SEK limit. Both the halva-prisbasbeloppet
* threshold and a rule's amount_min/amount_max band are SEK figures; feeding
* them an unconverted foreign number silently picks the wrong branch. When no
* rate is known we return null so the caller declines instead of guessing.
*/
function resolveSekAmountOrNull(transaction: Transaction): number | null {
const currency = (transaction.currency || 'SEK').toUpperCase()
if (currency === 'SEK') return transaction.amount
if (transaction.amount_sek != null) {
return Math.round(transaction.amount_sek * 100) / 100
}
if (transaction.exchange_rate != null && transaction.exchange_rate > 0) {
return Math.round(transaction.amount * transaction.exchange_rate * 100) / 100
}
return null
}
/**
* Evaluate all mapping rules against a transaction and return the best match
*
@@ -212,13 +239,36 @@ function matchesRule(rule: MappingRule, transaction: Transaction): boolean {
}
}
// Amount threshold matching
const absAmount = Math.abs(transaction.amount)
if (rule.amount_min != null && absAmount < rule.amount_min) {
return false
}
if (rule.amount_max != null && absAmount > rule.amount_max) {
return false
// Amount threshold matching.
//
// `mapping_rules` has no currency column, so an amount_min/amount_max band
// can only mean SEK: the ledger currency. Compare it against the SEK value
// of the transaction, never the raw foreign amount (a 3000 EUR row would
// otherwise slip through a "max 5000" band that was written to mean kronor).
// When a non-SEK row carries neither amount_sek nor an exchange_rate the
// band is not evaluable, so the rule does not apply: skipping is the honest
// outcome, matching on an unconverted number is not.
if (rule.amount_min != null || rule.amount_max != null) {
const sekAmount = resolveSekAmountOrNull(transaction)
if (sekAmount === null) {
log.warn('mapping rule with a SEK amount band skipped: transaction has no SEK value', {
transactionId: transaction.id,
currency: transaction.currency,
ruleId: rule.id,
ruleName: rule.rule_name,
amountMin: rule.amount_min,
amountMax: rule.amount_max,
})
return false
}
const absAmount = Math.abs(sekAmount)
if (rule.amount_min != null && absAmount < rule.amount_min) {
return false
}
if (rule.amount_max != null && absAmount > rule.amount_max) {
return false
}
}
return true
@@ -228,17 +278,48 @@ function matchesRule(rule: MappingRule, transaction: Transaction): boolean {
* Build a MappingResult from a matched rule
*/
function buildResult(rule: MappingRule, transaction: Transaction, entityType?: EntityType): MappingResult {
// NOTE: this is the amount in the transaction's own currency. It still feeds
// the VAT line generation below, which understates ingående moms on non-SEK
// rows (a known separate defect, tracked on its own: fixing it changes
// posted VAT amounts). The capitalization check below deliberately does not
// use it: an account-selection decision must run on a SEK value.
const absAmount = Math.abs(transaction.amount)
const isExpense = transaction.amount < 0
let debitAccount = rule.debit_account || (isExpense ? '6991' : '1930')
const creditAccount = rule.credit_account || (isExpense ? '1930' : '3900')
// Check capitalization threshold for equipment
const year = new Date(transaction.date).getFullYear()
const threshold = rule.capitalization_threshold ?? getCapitalizationThreshold(year)
if (absAmount > threshold && rule.capitalized_debit_account) {
debitAccount = rule.capitalized_debit_account
// Capitalization threshold for equipment (IL 18 kap 4 §, "inventarier av
// mindre värde"): below half prisbasbelopp it may be expensed straight to a
// 54xx förbrukningsinventarie account, above it must be capitalised to 12xx
// and depreciated.
//
// The threshold is a SEK figure, so the amount compared against it has to be
// SEK as well. Comparing a raw 3000 EUR laptop (about 34 500 kr) against the
// 29 600 kr limit for 2026 reads as "under the limit" and expenses a
// capital asset to 5410: no avskrivning, wrong balansräkning, and a
// skattemässig error. Only rules that can actually capitalise are affected.
let capitalizationUndecided = false
if (rule.capitalized_debit_account) {
const year = new Date(transaction.date).getFullYear()
const threshold = rule.capitalization_threshold ?? getCapitalizationThreshold(year)
const sekAmount = resolveSekAmountOrNull(transaction)
if (sekAmount === null) {
// Non-SEK row with neither amount_sek nor an exchange_rate. Both
// branches are a guess and a wrong guess is a depreciation and tax
// error, so decline to auto-classify and hand it to the user instead
// of faking a conversion.
capitalizationUndecided = true
log.warn('capitalization threshold undecidable: transaction has no SEK value', {
transactionId: transaction.id,
currency: transaction.currency,
ruleId: rule.id,
ruleName: rule.rule_name,
})
} else if (Math.abs(sekAmount) > threshold) {
debitAccount = rule.capitalized_debit_account
}
}
// If default_private, use entity-specific private account
@@ -295,16 +376,24 @@ function buildResult(rule: MappingRule, transaction: Transaction, entityType?: E
}
}
// An undecidable capitalization check must not auto-post: drop the
// confidence under the auto-book bar (0.8 in lib/transactions/ingest.ts),
// force review, and say why in the suggestion label so the user sees the
// missing rate rather than a silently expensed asset.
return {
rule,
debit_account: debitAccount,
credit_account: creditAccount,
risk_level: rule.risk_level,
confidence: rule.confidence_score,
requires_review: rule.requires_review,
confidence: capitalizationUndecided
? Math.min(rule.confidence_score, 0.3)
: rule.confidence_score,
requires_review: capitalizationUndecided ? true : rule.requires_review,
default_private: rule.default_private,
vat_lines: vatLines,
description: rule.rule_name,
description: capitalizationUndecided
? `${rule.rule_name} (granska: växelkurs saknas, går inte att avgöra om beloppet överstiger halva prisbasbeloppet)`
: rule.rule_name,
}
}
+29 -3
View File
@@ -47,13 +47,39 @@ export async function syncInvoiceStatusFromPaymentEntry(
.eq('company_id', companyId)
.single()
const { data: supplierInvoice } = await supabase
// Column is `total`, not `total_amount` (supplier_invoices has never had a
// total_amount column). Selecting the wrong name made PostgREST reject the
// whole query, so `supplierInvoice` was always null: the restore below was
// silently skipped while the payment-row delete and the bank-line release
// still ran. The invoice then stayed 'paid' with a stale paid_amount and
// nothing behind it, so the AP ledger (leverantörsreskontra) showed money
// as paid that was never paid.
const { data: supplierInvoice, error: supplierInvoiceError } = await supabase
.from('supplier_invoices')
.select('paid_amount, total_amount, due_date')
.select('paid_amount, total, due_date')
.eq('id', entry.source_id)
.eq('company_id', companyId)
.single()
// PGRST116 = no row: the invoice itself is gone, so there is nothing to
// restore and the cleanup below is still the right thing to do. Any OTHER
// error means we could not read the state we are about to overwrite.
// Deleting the payment row and releasing the bank line at that point would
// destroy the only evidence of the payment while the invoice stays 'paid':
// exactly the wrong-AP-ledger outcome above. Abort instead, at ERROR level
// so the failure is observable: the storno is already committed and both
// callers (reverseEntry, the DELETE voucher route) treat this sync as
// best-effort, so bailing out leaves invoice + payment row + bank line
// mutually consistent and the operation safely re-runnable.
if (supplierInvoiceError && supplierInvoiceError.code !== 'PGRST116') {
log.error(
'Failed to read supplier invoice for payment reversal: aborting status sync',
supplierInvoiceError,
{ companyId, journalEntryId: entryId, supplierInvoiceId: entry.source_id }
)
return
}
if (supplierInvoice) {
// Same fallback semantics as the customer branch below: a cash payment
// (supplier_invoice_cash_payment) books no payment row and is only ever
@@ -62,7 +88,7 @@ export async function syncInvoiceStatusFromPaymentEntry(
// reversals, leaving the supplier invoice deadlocked on 'paid'.
const paymentAmount = payment?.amount ?? supplierInvoice.paid_amount
const newPaidAmount = roundOre(supplierInvoice.paid_amount - paymentAmount)
const newRemaining = roundOre(supplierInvoice.total_amount - Math.max(0, newPaidAmount))
const newRemaining = roundOre(supplierInvoice.total - Math.max(0, newPaidAmount))
let newStatus: string
if (newPaidAmount > 0) {
newStatus = 'partially_paid'
+15 -7
View File
@@ -4,8 +4,12 @@
*
* No DB or Supabase dependency: all inputs are plain data.
*/
import { resolveSekAmount } from './currency-utils'
import { getRevenueAccount, getOutputVatAccount } from './invoice-entries'
import { resolveSekAmount, resolveSekAmountOrNull } from './currency-utils'
import {
getRevenueAccount,
getOutputVatAccount,
InvoiceFxRateMissingError,
} from './invoice-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
@@ -199,12 +203,16 @@ function proposeCashLines(
const lines: FormLine[] = []
const isForeign = invoice.currency !== 'SEK'
// The cash-method preview IS the entry: PaymentBookingDialog submits these
// lines verbatim. A foreign invoice with no rate therefore must not be
// pre-filled with the raw foreign numbers relabelled as kronor: refuse with
// the same error the server generator raises (createInvoiceCashEntry). The
// dialog resolves the proposal inside a try/catch and surfaces the refusal as
// a translated toast, so throwing here is a visible dead-end, not a crash.
const toSek = (amount: number): number => {
if (!isForeign) return amount
if (invoice.exchange_rate != null && invoice.exchange_rate > 0) {
return Math.round(amount * invoice.exchange_rate * 100) / 100
}
return amount
const sek = resolveSekAmountOrNull(amount, null, invoice.currency, invoice.exchange_rate)
if (sek === null) throw new InvoiceFxRateMissingError(invoice.currency)
return sek
}
// Build credit lines per VAT rate group. Free-text / blank rows carry no
+32 -7
View File
@@ -4,8 +4,12 @@
*
* No DB or Supabase dependency: all inputs are plain data.
*/
import { resolveSekAmount } from './currency-utils'
import { getRevenueAccount, getOutputVatAccount } from './invoice-entries'
import { resolveSekAmount, resolveSekAmountOrNull } from './currency-utils'
import {
getRevenueAccount,
getOutputVatAccount,
InvoiceFxRateMissingError,
} from './invoice-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
import { roundOre } from '@/lib/money'
@@ -112,11 +116,12 @@ function buildSendLines(
const desc = invoice.invoice_number ? `Försäljning faktura ${invoice.invoice_number}` : 'Försäljning faktura'
const toSek = (amount: number): number => {
if (!isForeign) return amount
if (invoice.exchange_rate != null && invoice.exchange_rate > 0) {
return Math.round(amount * invoice.exchange_rate * 100) / 100
}
return amount
const sek = resolveSekAmountOrNull(amount, null, invoice.currency, invoice.exchange_rate)
// Unreachable behind the fxUnconvertible bail below; kept as an assertion so
// removing that bail fails loudly instead of silently relabelling foreign
// amounts as kronor.
if (sek === null) throw new InvoiceFxRateMissingError(invoice.currency)
return sek
}
// Build credit lines per VAT rate group
@@ -130,6 +135,26 @@ function buildSendLines(
return []
}
// Item-driven legs have only `exchange_rate` to convert with: an InvoiceItem
// carries no per-item SEK column. Without a rate every proposed leg would be
// the raw foreign number relabelled as kronor, and because the 1510 debit is
// derived from the sum of the credits the grid would even read "Balanserar".
// Return no proposal (the same signal used just above for a text-only invoice)
// so the dialog hides the journal preview rather than displaying fabricated
// amounts. The send itself still routes through createInvoiceJournalEntry,
// which refuses this case outright with INVOICE_FX_RATE_MISSING.
//
// Why bail instead of throwing the way propose-payment-lines does:
// proposeSendLines runs inside a useMemo during SendInvoiceDialog's render,
// where a throw takes out the page instead of producing a toast. `editable` is
// SEK-only, so an empty proposal cannot disable the submit button either.
// Scoped to the item path on purpose: the invoice-level fallback below has a
// genuine second source (subtotal_sek / vat_amount_sek) and is left alone.
const fxUnconvertible = isForeign && !(invoice.exchange_rate != null && invoice.exchange_rate > 0)
if (accountingItems.length > 0 && fxUnconvertible) {
return []
}
if (accountingItems.length > 0) {
const hasPerLineVat = accountingItems.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
+63 -7
View File
@@ -26,6 +26,62 @@ import type {
const log = createLogger('supplier-invoice-entries')
/**
* Stable code for the "foreign-currency supplier invoice without a rate"
* refusal. Registered in lib/errors/structured-errors.ts so REST routes, the
* MCP server and getErrorMessage() all translate it the same way.
*/
export const SI_FX_RATE_MISSING = 'SI_FX_RATE_MISSING' as const
/**
* Raised when a booking path is asked to translate a foreign-currency supplier
* invoice that has no usable exchange rate.
*
* resolveSekAmount() answers that case by returning the RAW foreign amount (a
* legacy "assume SEK" fallback that is tolerable in read-only code but not
* when posting a verifikat): a 1 000 EUR invoice would post as 1 000 SEK, and
* because every leg is scaled by the same wrong factor the entry still
* balances, so no DB trigger fires and nothing errors. Under omvänd
* skattskyldighet that silently books 250,00 kr of fiktiv moms on 2614/2645
* instead of 2 875,00 kr at 11,50 SEK/EUR, understating both ruta 20 (or 21)
* and ruta 30 of the momsdeklaration by the same amount: an oriktig uppgift
* exposed to skattetillägg under SFL 49 kap 4 §.
*
* The in-house precedent for failing loudly instead is the
* `match_batch_allocate` RPC, which hard-fails with BATCH_FX_RATE_MISSING, and
* lib/reports/supplier-ledger.ts, which skips any FX invoice lacking a rate
* rather than faking a 1:1 conversion.
*/
export class SupplierInvoiceFxRateMissingError extends Error {
readonly code = SI_FX_RATE_MISSING
constructor(public readonly currency: string) {
super(
`Supplier invoice is in ${currency} but has no exchange rate on file; refusing to post it as if 1 ${currency} = 1 SEK.`
)
this.name = 'SupplierInvoiceFxRateMissingError'
}
}
/**
* Convert an invoice-currency amount to SEK for a journal entry line.
*
* SEK invoices short-circuit exactly as before, and so does any invoice with a
* legitimately supplied positive rate: the only new behaviour is the refusal
* above when a foreign invoice reaches a booking path with no rate at all.
* Every conversion in this file goes through here so no leg (expense, moms,
* fiktiv moms, basbelopp, 2440) can be posted at a fabricated 1:1 rate.
*/
function toSekOrThrow(
amount: number,
currency: string,
exchangeRate: number | null | undefined
): number {
if (currency && currency !== 'SEK' && !(exchangeRate != null && exchangeRate > 0)) {
throw new SupplierInvoiceFxRateMissingError(currency)
}
return resolveSekAmount(amount, null, currency, exchangeRate)
}
/**
* Build a BFL-compliant verifikation description with event type, counterparty, and suffix.
* Falls back to prefix + invoiceNumber + suffix if name is not provided (backward compat).
@@ -116,7 +172,7 @@ export async function createSupplierInvoiceRegistrationEntry(
const expenseBuckets = groupExpenseBuckets(
items,
(item) => resolveBookingAccount('expense', item, item.account_number),
(item) => resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate),
(item) => toSekOrThrow(item.line_total, invoice.currency, invoice.exchange_rate),
defaultDimensions
)
@@ -382,7 +438,7 @@ export async function createSupplierInvoiceCashEntry(
const expenseBuckets = groupExpenseBuckets(
items,
(item) => item.account_number,
(item) => resolveSekAmount(item.line_total, null, invoice.currency, effectiveRate),
(item) => toSekOrThrow(item.line_total, invoice.currency, effectiveRate),
defaultDimensions
)
@@ -530,7 +586,7 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
const expenseBuckets = groupExpenseBuckets(
items,
(item) => item.account_number,
(item) => resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate),
(item) => toSekOrThrow(item.line_total, invoice.currency, invoice.exchange_rate),
defaultDimensions
)
for (const bucket of expenseBuckets) {
@@ -618,7 +674,7 @@ export async function createSupplierCreditNoteEntry(
const expenseBuckets = groupExpenseBuckets(
items,
(item) => resolveBookingAccount('expense', item, item.account_number),
(item) => Math.abs(resolveSekAmount(item.line_total, null, creditNote.currency, creditNote.exchange_rate)),
(item) => Math.abs(toSekOrThrow(item.line_total, creditNote.currency, creditNote.exchange_rate)),
defaultDimensions
)
@@ -782,7 +838,7 @@ function groupVatByRate(
? Math.round((item.line_total ?? 0) * rate * 100) / 100
: 0
const sourceVat = storedVat > 0 ? storedVat : computedVat
let vatSek = resolveSekAmount(sourceVat, null, currency, exchangeRate)
let vatSek = toSekOrThrow(sourceVat, currency, exchangeRate)
if (useAbsoluteValues) vatSek = Math.abs(vatSek)
vatByRate.set(rate, (vatByRate.get(rate) || 0) + vatSek)
}
@@ -809,7 +865,7 @@ function groupBaseByRate(
const baseByRate = new Map<number, number>()
for (const item of items) {
const rate = resolveReverseChargeRate(item)
let baseSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
let baseSek = toSekOrThrow(item.line_total, currency, exchangeRate)
if (useAbsoluteValues) baseSek = Math.abs(baseSek)
baseByRate.set(rate, (baseByRate.get(rate) || 0) + baseSek)
}
@@ -832,7 +888,7 @@ function groupNonBasisBaseByRate(
for (const item of items) {
if (isReverseChargeBasisAccount(item.account_number)) continue
const rate = resolveReverseChargeRate(item)
let itemSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
let itemSek = toSekOrThrow(item.line_total, currency, exchangeRate)
if (useAbsoluteValues) itemSek = Math.abs(itemSek)
baseByRate.set(rate, (baseByRate.get(rate) || 0) + itemSek)
}
@@ -0,0 +1,95 @@
import { describe, it, expect, afterEach, vi } from 'vitest'
import { failureDescription, type ActionFailure } from '@/lib/browser/action-failure'
import { downloadFile } from '@/lib/browser/download-file'
import { postAction } from '@/lib/browser/post-action'
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
const COPY = {
timeout: 'Servern svarade inte i tid. Ingen betalfil sparades. Försök igen.',
network: 'Ingen kontakt med servern. Ingen betalfil sparades. Kontrollera din internetanslutning och försök igen.',
}
describe('failureDescription', () => {
it('names a timeout as a timeout', () => {
expect(failureDescription({ ok: false, reason: 'timeout' }, COPY)).toBe(COPY.timeout)
})
it('names a network failure as a network failure', () => {
// The union's own message for this arm is the generic "Något gick fel",
// which cannot be told apart from a server error. The caller's copy can.
const failure: ActionFailure = {
ok: false,
reason: 'network',
message: 'Något gick fel. Försök igen.',
}
expect(failureDescription(failure, COPY)).toBe(COPY.network)
expect(failureDescription(failure, COPY)).not.toBe(COPY.timeout)
})
it("keeps the server's own reason, which is more specific than any panel copy", () => {
const failure: ActionFailure = {
ok: false,
reason: 'server',
status: 400,
message: 'Bankgironummer saknas i företagsinställningar. Krävs för Bankgirot LB-fil.',
}
expect(failureDescription(failure, COPY)).toBe(
'Bankgironummer saknas i företagsinställningar. Krävs för Bankgirot LB-fil.',
)
})
it('gives the three reasons three different sentences', () => {
const said = new Set([
failureDescription({ ok: false, reason: 'timeout' }, COPY),
failureDescription({ ok: false, reason: 'network', message: 'x' }, COPY),
failureDescription({ ok: false, reason: 'server', status: 500, message: 'Serverfel' }, COPY),
])
expect(said.size).toBe(3)
})
})
describe('failure arms of downloadFile and postAction are interchangeable', () => {
// The panels feed both into one failureDescription call. If either union
// drifts, this stops compiling, which is the point.
it('accepts a downloadFile failure', async () => {
globalThis.fetch = (() => Promise.reject(new TypeError('fetch failed'))) as typeof fetch
const result = await downloadFile({ url: '/x', filename: 'f.txt', saveBlob: () => {} })
expect(result.ok).toBe(false)
if (!result.ok) expect(failureDescription(result, COPY)).toBe(COPY.network)
})
it('accepts a postAction failure', async () => {
globalThis.fetch = (() => Promise.reject(new TypeError('fetch failed'))) as typeof fetch
const result = await postAction({ url: '/x' })
expect(result.ok).toBe(false)
if (!result.ok) expect(failureDescription(result, COPY)).toBe(COPY.network)
})
it('still surfaces the salary route 400 verbatim, as the old inline handler did', async () => {
// The dominant real failure on the payment-file panels: the run is approved
// but company settings are incomplete. The route answers a plain-string
// envelope, not the structured one, and that sentence is the whole point of
// the toast: it names the field the user has to go and fill in. It must
// survive the move from the inline getErrorMessage call into downloadFile.
const message = 'Bankgironummer saknas i företagsinställningar. Krävs för Bankgirot LB-fil.'
globalThis.fetch = (() =>
Promise.resolve(
new Response(JSON.stringify({ error: message }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
}),
)) as typeof fetch
const saveBlob = vi.fn()
const result = await downloadFile({ url: '/x', filename: 'lon_2026-04.txt', saveBlob })
expect(saveBlob).not.toHaveBeenCalled()
expect(result.ok).toBe(false)
if (!result.ok) expect(failureDescription(result, COPY)).toBe(message)
})
})
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { copyToClipboard } from '@/lib/browser/copy-to-clipboard'
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('copyToClipboard', () => {
it('reports copied and forwards the exact text on success', async () => {
const writeText = vi.fn(async () => undefined)
vi.stubGlobal('navigator', { clipboard: { writeText } })
await expect(copyToClipboard('gnubok_sk_live_abc123')).resolves.toBe('copied')
expect(writeText).toHaveBeenCalledWith('gnubok_sk_live_abc123')
})
it('reports failed when the write is rejected, never copied', async () => {
// The regression guard: the old call sites either swallowed this rejection
// in an empty catch (no check mark, no message) or never awaited it at all
// (check mark shown regardless). Both told the user the secret was on the
// clipboard when it was not.
const writeText = vi.fn(async () => {
throw new DOMException('Write permission denied.', 'NotAllowedError')
})
vi.stubGlobal('navigator', { clipboard: { writeText } })
const result = await copyToClipboard('JBSWY3DPEHPK3PXP')
expect(result).toBe('failed')
expect(result).not.toBe('copied')
})
it('reports failed on a non-Error rejection', async () => {
vi.stubGlobal('navigator', {
clipboard: { writeText: vi.fn(() => Promise.reject('nope')) },
})
await expect(copyToClipboard('secret')).resolves.toBe('failed')
})
it('reports unavailable when the Clipboard API is missing (insecure context)', async () => {
vi.stubGlobal('navigator', {})
await expect(copyToClipboard('secret')).resolves.toBe('unavailable')
})
it('reports unavailable when clipboard exists but writeText does not', async () => {
vi.stubGlobal('navigator', { clipboard: { read: vi.fn() } })
await expect(copyToClipboard('secret')).resolves.toBe('unavailable')
})
it('is SSR-safe: reports unavailable with no navigator, and does not throw', async () => {
vi.stubGlobal('navigator', undefined)
await expect(copyToClipboard('secret')).resolves.toBe('unavailable')
})
})
+243
View File
@@ -0,0 +1,243 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
downloadFile,
saveBlobToDisk,
DOWNLOAD_TIMEOUT_MS,
OBJECT_URL_REVOKE_DELAY_MS,
} from '@/lib/browser/download-file'
const originalFetch = globalThis.fetch
const EXPORT_BODY = JSON.stringify({ version: 1, templates: [{ name: 'Inköp EU-varor' }] })
function jsonResponse(body: unknown, status = 200): Response {
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
/** A fetch that never resolves and rejects the way the platform does on abort. */
function hangingFetch() {
return vi.fn((_url: unknown, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const err = new Error('The operation was aborted due to timeout')
err.name = 'TimeoutError'
reject(err)
})
})
})
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
globalThis.fetch = originalFetch
})
describe('downloadFile', () => {
it('saves the file when the server answers 2xx', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(EXPORT_BODY))
const saveBlob = vi.fn()
const result = await downloadFile({
url: '/api/settings/booking-templates/export',
filename: 'bokforingsmallar.json',
saveBlob,
})
expect(result).toEqual({ ok: true, filename: 'bokforingsmallar.json' })
expect(saveBlob).toHaveBeenCalledTimes(1)
const [blob, filename] = saveBlob.mock.calls[0]
expect(filename).toBe('bokforingsmallar.json')
await expect((blob as Blob).text()).resolves.toBe(EXPORT_BODY)
})
it('writes NO file on a 500 and reports the envelope message', async () => {
// The regression guard. The old handler called res.blob() with no res.ok
// check, so this error envelope was saved to disk as bokforingsmallar.json
// and the download was reported as a success.
globalThis.fetch = vi.fn().mockResolvedValue(
jsonResponse(
{
error: {
code: 'INTERNAL_ERROR',
message: 'Kunde inte hämta mallarna. Försök igen.',
message_en: 'Could not load the templates. Please try again.',
},
},
500,
),
)
const saveBlob = vi.fn()
const result = await downloadFile({
url: '/api/settings/booking-templates/export',
filename: 'bokforingsmallar.json',
saveBlob,
})
expect(saveBlob).not.toHaveBeenCalled()
expect(result.ok).toBe(false)
expect(result).toEqual({
ok: false,
reason: 'server',
status: 500,
message: 'Kunde inte hämta mallarna. Försök igen.',
})
})
it('falls back to the status message when the error body is not JSON', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response('<html>502 Bad Gateway</html>', {
status: 502,
headers: { 'Content-Type': 'text/html' },
}),
)
const saveBlob = vi.fn()
const result = await downloadFile({ url: '/x', filename: 'f.json', saveBlob })
expect(saveBlob).not.toHaveBeenCalled()
expect(result).toEqual({
ok: false,
reason: 'server',
status: 502,
message: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.',
})
})
it('reports a server error in English for an English UI', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
jsonResponse({ error: { code: 'INTERNAL_ERROR', message: 'Ett oväntat serverfel uppstod.' } }, 500),
)
const saveBlob = vi.fn()
const result = await downloadFile({
url: '/x',
filename: 'f.json',
locale: 'en',
saveBlob,
})
expect(saveBlob).not.toHaveBeenCalled()
expect(result).toMatchObject({ ok: false, reason: 'server', message: 'Internal server error.' })
})
it('writes NO file when the request throws', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed'))
const saveBlob = vi.fn()
const result = await downloadFile({ url: '/x', filename: 'f.json', saveBlob })
expect(saveBlob).not.toHaveBeenCalled()
expect(result).toEqual({
ok: false,
reason: 'network',
message: 'Något gick fel. Försök igen.',
})
})
it('writes NO file when the body is truncated mid-read', async () => {
const res = new Response(EXPORT_BODY, { status: 200 })
vi.spyOn(res, 'blob').mockRejectedValue(new TypeError('network error'))
globalThis.fetch = vi.fn().mockResolvedValue(res)
const saveBlob = vi.fn()
const result = await downloadFile({ url: '/x', filename: 'f.json', saveBlob })
expect(saveBlob).not.toHaveBeenCalled()
expect(result).toMatchObject({ ok: false, reason: 'network' })
})
it('reports a hung request as a timeout, not as a generic failure', async () => {
globalThis.fetch = hangingFetch()
const saveBlob = vi.fn()
const result = await downloadFile({
url: '/x',
filename: 'f.json',
timeoutMs: 20,
saveBlob,
})
expect(saveBlob).not.toHaveBeenCalled()
expect(result).toEqual({ ok: false, reason: 'timeout' })
// The call site branches on this to say "took too long" instead of the
// generic "something went wrong".
expect(result).not.toMatchObject({ reason: 'network' })
})
it('passes the deadline to fetch as an abort signal', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(EXPORT_BODY))
globalThis.fetch = fetchMock
await downloadFile({ url: '/x', filename: 'f.json', saveBlob: vi.fn() })
const init = fetchMock.mock.calls[0][1] as RequestInit
expect(init.signal).toBeInstanceOf(AbortSignal)
expect(DOWNLOAD_TIMEOUT_MS).toBe(15_000)
})
})
describe('saveBlobToDisk', () => {
const originalCreateObjectURL = URL.createObjectURL
const originalRevokeObjectURL = URL.revokeObjectURL
function stubDom() {
const anchor = { href: '', download: '', rel: '', click: vi.fn(), remove: vi.fn() }
const body = { appendChild: vi.fn() }
;(globalThis as Record<string, unknown>).document = {
createElement: vi.fn(() => anchor),
body,
}
const createObjectURL = vi.fn(() => 'blob:test-url')
const revokeObjectURL = vi.fn()
URL.createObjectURL = createObjectURL as typeof URL.createObjectURL
URL.revokeObjectURL = revokeObjectURL as typeof URL.revokeObjectURL
return { anchor, body, revokeObjectURL }
}
afterEach(() => {
delete (globalThis as Record<string, unknown>).document
URL.createObjectURL = originalCreateObjectURL
URL.revokeObjectURL = originalRevokeObjectURL
vi.useRealTimers()
})
it('defers object-URL revocation so the browser can finish reading the blob', () => {
// Revoking synchronously in a finally right after a.click() can abort the
// save in Firefox/Safari: the click only STARTS the download.
vi.useFakeTimers()
const { anchor, revokeObjectURL } = stubDom()
saveBlobToDisk(new Blob(['x']), 'f.json')
expect(anchor.click).toHaveBeenCalledTimes(1)
expect(revokeObjectURL).not.toHaveBeenCalled()
vi.advanceTimersByTime(OBJECT_URL_REVOKE_DELAY_MS - 1)
expect(revokeObjectURL).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(revokeObjectURL).toHaveBeenCalledTimes(1)
expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-url')
})
it('revokes immediately when the click never happened', () => {
// Nothing is reading the URL if the DOM threw before the click, so the
// blob memory is released right away instead of leaking for 10 seconds.
vi.useFakeTimers()
const { body, revokeObjectURL } = stubDom()
body.appendChild.mockImplementation(() => {
throw new Error('detached document')
})
expect(() => saveBlobToDisk(new Blob(['x']), 'f.json')).toThrow('detached document')
expect(revokeObjectURL).toHaveBeenCalledTimes(1)
expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-url')
})
})
+157
View File
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { postAction, POST_ACTION_TIMEOUT_MS } from '@/lib/browser/post-action'
const originalFetch = globalThis.fetch
const MARK_PAID_URL = '/api/skatteverket/tax-payments/2026-04/mark-paid'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
/** A fetch that never resolves and rejects the way the platform does on abort. */
function hangingFetch() {
return vi.fn((_url: unknown, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const err = new Error('The operation was aborted due to timeout')
err.name = 'TimeoutError'
reject(err)
})
})
})
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
globalThis.fetch = originalFetch
})
describe('postAction', () => {
it('reports success on 2xx without reading the body', async () => {
const res = jsonResponse({ data: { ok: true } })
const jsonSpy = vi.spyOn(res, 'json')
globalThis.fetch = vi.fn().mockResolvedValue(res)
const result = await postAction({ url: MARK_PAID_URL })
expect(result).toEqual({ ok: true })
// The success shape is the route's business, not the button's.
expect(jsonSpy).not.toHaveBeenCalled()
})
it('sends POST', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: { ok: true } }))
globalThis.fetch = fetchMock
await postAction({ url: MARK_PAID_URL })
const init = fetchMock.mock.calls[0][1] as RequestInit
expect(init.method).toBe('POST')
})
it('reports the envelope message on 404', async () => {
// The regression guard for the missing catch: this used to be the only
// branch that spoke to the user at all.
globalThis.fetch = vi
.fn()
.mockResolvedValue(jsonResponse({ error: 'Ingen AGI för perioden 2026-04.' }, 404))
const result = await postAction({ url: MARK_PAID_URL })
expect(result).toEqual({
ok: false,
reason: 'server',
status: 404,
// The plain-string envelope this route emits is not recognized as a
// finished user sentence, so the status map answers: same text the old
// inline getErrorMessage(result, { statusCode }) produced.
message: 'Resursen kunde inte hittas.',
})
})
it('keeps a Swedish server message verbatim', async () => {
globalThis.fetch = vi
.fn()
.mockResolvedValue(jsonResponse({ error: 'Kunde inte markera som betald. Försök igen.' }, 500))
const result = await postAction({ url: MARK_PAID_URL })
expect(result).toMatchObject({
reason: 'server',
status: 500,
message: 'Kunde inte markera som betald. Försök igen.',
})
})
it('falls back to the status message when the error body is not JSON', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response('<html>502 Bad Gateway</html>', {
status: 502,
headers: { 'Content-Type': 'text/html' },
}),
)
const result = await postAction({ url: MARK_PAID_URL })
expect(result).toEqual({
ok: false,
reason: 'server',
status: 502,
message: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.',
})
})
it('reports a server error in English for an English UI', async () => {
globalThis.fetch = vi
.fn()
.mockResolvedValue(jsonResponse({ error: { code: 'INTERNAL_ERROR', message: 'Serverfel' } }, 500))
const result = await postAction({ url: MARK_PAID_URL, locale: 'en' })
expect(result).toMatchObject({ reason: 'server', message: 'Internal server error.' })
})
it('reports a thrown request as a network failure, not as success', async () => {
// The finding: with try/finally and no catch this rejection escaped as an
// unhandled rejection and the user was told nothing at all.
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed'))
const result = await postAction({ url: MARK_PAID_URL })
expect(result).toEqual({
ok: false,
reason: 'network',
message: 'Något gick fel. Försök igen.',
})
})
it('reports a hung request as a timeout, not as a generic failure', async () => {
globalThis.fetch = hangingFetch()
const result = await postAction({ url: MARK_PAID_URL, timeoutMs: 20 })
// A timeout on a write is ambiguous (the update may have landed), so the
// call site needs to tell it apart to say "reload and check".
expect(result).toEqual({ ok: false, reason: 'timeout' })
})
it('bounds the request with an abort signal on the default deadline', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: { ok: true } }))
globalThis.fetch = fetchMock
await postAction({ url: MARK_PAID_URL })
const init = fetchMock.mock.calls[0][1] as RequestInit
expect(init.signal).toBeInstanceOf(AbortSignal)
// Never shorter than the download deadline: aborting a mutation early
// reports a failure for a write that may have landed.
expect(POST_ACTION_TIMEOUT_MS).toBe(15_000)
})
})
+56
View File
@@ -0,0 +1,56 @@
/**
* The three ways a click that talks to the server can fail, and the one
* sentence the user gets for each.
*
* Shared by `downloadFile` (whose failure arms are structurally identical) and
* `postAction`, so a panel that has both a "generate the file" button and a
* "mark it paid" button describes both kinds of failure the same way.
*
* Exactly one sentence, never two: TOAST_LIMIT is 1
* (components/ui/use-toast.tsx), so a second toast emitted in the same tick
* evicts the first and only the last one is ever rendered. A handler that
* wants to report a failure must therefore report it once, completely.
*/
export type ActionFailure =
/** The deadline passed. Nothing was saved and nothing was reported back. */
| { ok: false; reason: 'timeout' }
/** The server answered non-2xx. `message` is the envelope's own message when it carries one. */
| { ok: false; reason: 'server'; status: number; message: string }
/** The request never completed (offline, DNS, connection reset, truncated body). */
| { ok: false; reason: 'network'; message: string }
export interface ActionFailureCopy {
/** Say the request took too long. "Something went wrong" is not actionable; "it timed out, try again" is. */
timeout: string
/** Say the browser never reached the server, so the user checks the connection instead of the data. */
network: string
}
/**
* Pick the single description for a failed click.
*
* A `server` failure keeps the server's own message: the route knows exactly
* why it refused (bankgiro missing from company settings, the salary run is
* not approved yet), and no copy the panel could supply comes close.
*
* The two client-side reasons take the caller's copy instead. `getErrorMessage`
* has nothing better than the generic "Något gick fel" for a fetch that never
* produced a response, and that sentence cannot tell a user whose request timed
* out (retry, and the artefact may already have been generated server-side)
* apart from one whose browser is offline (fix the connection first). The
* discriminated union already separates them, so the copy should too.
*/
export function failureDescription(
failure: ActionFailure,
copy: ActionFailureCopy,
): string {
switch (failure.reason) {
case 'timeout':
return copy.timeout
case 'network':
return copy.network
case 'server':
return failure.message
}
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Copy text to the clipboard and report honestly whether it worked.
*
* Why: `navigator.clipboard.writeText` rejects for ordinary, non-exceptional
* reasons (insecure context on plain http, a Permissions-Policy that blocks
* clipboard-write, the document having lost focus) and the API is absent
* entirely in older or hardened browsers. A copy button that swallows the
* rejection, or that never awaits it before flipping to a check mark, tells
* the user "copied" when nothing was copied. On a one-time secret (an API key
* shown once, a TOTP enrolment key) that turns a recoverable click into an
* unrecoverable one: the key is gone and every integration has to be
* re-pointed at a new one.
*
* Callers must render a visible failure state and keep the text on screen as
* selectable content. No clipboard means no programmatic fallback either, so
* manual selection is the only fallback that actually works.
*
* Call this as the first `await` in a click handler: a clipboard write issued
* after some other await runs outside the click's transient user activation
* and is rejected on that ground alone.
*/
export type CopyResult =
/** The write resolved: the text is on the clipboard. */
| 'copied'
/** No Clipboard API at all (SSR, pre-2018 browser, stripped navigator). */
| 'unavailable'
/** The API exists but the write was rejected (permissions, focus, http). */
| 'failed'
export async function copyToClipboard(text: string): Promise<CopyResult> {
const clipboard =
typeof navigator !== 'undefined'
? (navigator.clipboard as Clipboard | undefined)
: undefined
if (!clipboard || typeof clipboard.writeText !== 'function') {
return 'unavailable'
}
try {
await clipboard.writeText(text)
return 'copied'
} catch {
return 'failed'
}
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Fetch a server-generated file and save it to disk only if the server
* actually produced one.
*
* Why: the download boilerplate that repeats across the app reads
* `await res.blob()` straight after `fetch`, with no `res.ok` check. A failing
* route answers with a JSON error envelope, and `blob()` happily wraps that
* envelope. The anchor click then writes it to disk under the real file's
* name, so the user ends up holding `bokforingsmallar.json` (or an .sie, or a
* .zip) that contains `{"error":{"code":"INTERNAL_ERROR",...}}` while the UI
* reports nothing at all. That file looks like an export, gets filed as a
* backup, gets mailed to an accountant, and only fails much later when someone
* tries to import it.
*
* Content-Type cannot be used to tell the two apart: a JSON export and a JSON
* error envelope are both `application/json`, and a 500 from a route that
* streams a ZIP is still JSON. `res.ok` is the only honest discriminator, so
* the status is checked before the body is ever turned into a blob.
*
* The request is also bounded. An unbounded artefact fetch on a click leaves
* the button spinning forever when a serverless instance hangs, and the user's
* only recourse is to click again.
*
* The result is a discriminated union rather than a thrown error so the call
* site can pick exactly one truthful message. That matters here: TOAST_LIMIT
* is 1 (components/ui/use-toast.tsx), so two toasts emitted in the same tick
* leave the user seeing only the last one.
*/
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
/**
* Default deadline for an interactive artefact download.
*
* These payloads are small (a JSON export, a declaration file) and produced by
* a single indexed query, so the realistic worst case is a serverless cold
* start plus one eu-north-1 round trip: a few seconds. 15s leaves several times
* that headroom, so we never abort a request that was about to succeed, while
* still ending the spinner soon enough after the ~10s mark where the user has
* already concluded the app is dead. Callers that generate genuinely heavy
* artefacts (full-archive ZIPs) should pass a larger `timeoutMs`.
*/
export const DOWNLOAD_TIMEOUT_MS = 15_000
export type DownloadFileResult =
/** 2xx and a complete body: the file was handed to the browser. */
| { ok: true; filename: string }
/** The deadline passed. Nothing was saved; report it as a timeout, not as a generic failure. */
| { ok: false; reason: 'timeout' }
/** The server answered non-2xx. `message` is the envelope's own message when it carries one. */
| { ok: false; reason: 'server'; status: number; message: string }
/** The request never completed (offline, DNS, connection reset, truncated body). */
| { ok: false; reason: 'network'; message: string }
export interface DownloadFileOptions {
url: string
/** Name the file is saved under. */
filename: string
/** UI locale, so a server error is reported in the language the user reads. */
locale?: ErrorLocale
timeoutMs?: number
/** Seam for tests; defaults to the DOM anchor implementation below. */
saveBlob?: (blob: Blob, filename: string) => void
}
/**
* How long the object URL outlives the click before it is revoked.
*
* Revoking synchronously right after `a.click()` can abort the save in
* Firefox and Safari: the click only STARTS the download, and the browser may
* still be reading from the blob URL when it is revoked. 10 seconds is far
* beyond any realistic gap between the click and the browser opening its own
* handle on the blob, while still guaranteeing the URL (and the blob memory
* it pins) is released.
*/
export const OBJECT_URL_REVOKE_DELAY_MS = 10_000
/**
* Hand a blob to the browser as a download.
*
* The anchor is attached to the document before it is clicked: a detached
* anchor is ignored by some browsers, which turns the download into a silent
* no-op.
*/
export function saveBlobToDisk(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
let clicked = false
try {
const a = document.createElement('a')
a.href = url
a.download = filename
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
clicked = true
a.remove()
} finally {
if (clicked) {
// The download started: defer revocation so the browser can finish
// reading the blob (see OBJECT_URL_REVOKE_DELAY_MS).
setTimeout(() => URL.revokeObjectURL(url), OBJECT_URL_REVOKE_DELAY_MS)
} else {
// The click never happened (DOM threw): nothing is reading the URL,
// so release it immediately rather than leaking it for 10 seconds.
URL.revokeObjectURL(url)
}
}
}
export async function downloadFile({
url,
filename,
locale = 'sv',
timeoutMs = DOWNLOAD_TIMEOUT_MS,
saveBlob = saveBlobToDisk,
}: DownloadFileOptions): Promise<DownloadFileResult> {
let blob: Blob
try {
const res = await fetchWithTimeout(
url,
{ method: 'GET' },
{ timeoutMs, description: `download ${filename}` },
)
if (!res.ok) {
// Read the body only now that we know it is not the file. A body that is
// not JSON (an HTML error page, an empty 502) leaves `null`, and
// getErrorMessage falls back to the status map.
const body = await res.json().catch(() => null)
return {
ok: false,
reason: 'server',
status: res.status,
message: getErrorMessage(body, { statusCode: res.status, locale }),
}
}
blob = await res.blob()
} catch (err) {
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
}
// Only reached with a 2xx and a body that was read in full.
saveBlob(blob, filename)
return { ok: true, filename }
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Fire a state-changing POST from a click and report exactly why it failed.
*
* Why this lives next to `downloadFile`: the same try/finally-with-no-catch
* shape that silently swallowed download failures also sits on the mutation
* buttons beside them, and `downloadFile` cannot serve those (it exists to save
* a blob). The result union is deliberately the same one, so a panel maps a
* failed download and a failed mutation through a single `failureDescription`.
*
* The request is bounded for the same reason a download is: an unbounded POST
* on a click leaves the button spinning forever when a serverless instance
* hangs, and the user's only recourse is to click again, which is the one thing
* you least want on a mutation.
*
* The success body is deliberately not parsed. `res.ok` is the whole contract:
* validating a success shape would couple every button to its route's payload,
* and the server has already committed the write by the time it flushes
* headers, so a body that never finishes arriving would not change the outcome.
*/
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import type { ActionFailure } from './action-failure'
/**
* Deadline for an interactive mutation.
*
* Same realistic worst case as an artefact download: a serverless cold start
* plus one eu-north-1 round trip, a few seconds. It is deliberately no shorter
* than DOWNLOAD_TIMEOUT_MS, because aborting a mutation early reports a failure
* for a write that may well have landed, and that ambiguity is worse than a few
* extra seconds of spinner.
*/
export const POST_ACTION_TIMEOUT_MS = 15_000
export type PostActionResult = { ok: true } | ActionFailure
export interface PostActionOptions {
url: string
/** UI locale, so a server error is reported in the language the user reads. */
locale?: ErrorLocale
timeoutMs?: number
}
export async function postAction({
url,
locale = 'sv',
timeoutMs = POST_ACTION_TIMEOUT_MS,
}: PostActionOptions): Promise<PostActionResult> {
try {
const res = await fetchWithTimeout(
url,
{ method: 'POST' },
{ timeoutMs, description: `post ${url}` },
)
if (!res.ok) {
// A body that is not JSON (an HTML error page, an empty 502) leaves
// `null`, and getErrorMessage falls back to the status map.
const body = await res.json().catch(() => null)
return {
ok: false,
reason: 'server',
status: res.status,
message: getErrorMessage(body, { statusCode: res.status, locale }),
}
}
return { ok: true }
} catch (err) {
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
}
}
+4 -3
View File
@@ -22,9 +22,10 @@ export function parseStartMonthDay(value: string | null | undefined): number | n
/**
* Derive the first-year defaults from TIC's `registrationDate`.
* A company is treated as "first year" when registered less than 12 months
* ago: fits BFL's 6-18 month opening-period window comfortably. Returns
* both the toggle state and a seeded `first_year_start` (always the 1st of
* the registration month, the format the date inputs expect).
* ago: comfortably inside BFL's 18-month cap on a first räkenskapsår, which
* has no minimum length. Returns both the toggle state and a seeded
* `first_year_start` (always the 1st of the registration month, the format
* the date inputs expect).
*
* `now` exists for tests; production callers omit it.
*/
@@ -11,7 +11,7 @@ let results: Array<{ data?: unknown; error?: unknown; count?: number | null }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is']) {
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
@@ -29,7 +29,15 @@ function makeClient() {
}
}
import { lockPeriod, unlockPeriod, closePeriod, createNextPeriod, findNextPeriod } from '../period-service'
import {
lockPeriod,
unlockPeriod,
closePeriod,
createNextPeriod,
findNextPeriod,
resolvePeriodStatusForDate,
} from '../period-service'
import { getStructuredError } from '@/lib/errors/get-structured-error'
beforeEach(() => {
vi.clearAllMocks()
@@ -45,7 +53,8 @@ describe('lockPeriod', () => {
results = [
{ data: period, error: null }, // fetch
{ count: 0, data: null, error: null }, // uncategorized tx count check
{ count: 0, data: null, error: null }, // guard leg 1: untriaged count
{ data: [], error: null }, // guard leg 2: business-unbooked candidates
{ data: lockedPeriod, error: null }, // update
]
@@ -73,6 +82,522 @@ describe('lockPeriod', () => {
})
})
// ============================================================
// lockPeriod: unbooked-transaction guard
//
// These use a filter-aware client that records the filters the guard
// actually applies and evaluates them against in-memory tables, so the
// assertions turn on which rows the predicate matches rather than on a
// canned count handed back by the mock. Both anchoring locations that
// keep journal_entry_id NULL (transaction_voucher_links, the two payment
// tables) are modelled, because the guard must not block on those.
// ============================================================
type Row = Record<string, unknown>
interface FilterSpec {
col: string
op: 'eq' | 'is' | 'gte' | 'lte' | 'in'
val: unknown
}
/** The dead predicate this guard used to run, kept as a regression fixture. */
const OLD_DEAD_GUARD: FilterSpec[] = [
{ col: 'journal_entry_id', op: 'is', val: null },
{ col: 'is_business', op: 'eq', val: true },
]
function matchesAll(row: Row, specs: FilterSpec[]): boolean {
return specs.every((s) => {
const v = row[s.col]
if (s.op === 'gte') return String(v) >= String(s.val)
if (s.op === 'lte') return String(v) <= String(s.val)
if (s.op === 'in') return Array.isArray(s.val) && s.val.includes(v)
return v === s.val
})
}
const GUARD_TABLES = [
'transactions',
'transaction_voucher_links',
'invoice_payments',
'supplier_invoice_payments',
] as const
interface GuardStore {
transactions?: Row[]
transaction_voucher_links?: Row[]
invoice_payments?: Row[]
supplier_invoice_payments?: Row[]
/** Table name -> PostgREST error message to return instead of rows. */
failOn?: Partial<Record<(typeof GUARD_TABLES)[number], string>>
}
/**
* Client whose guard-table reads are predicate-evaluated against `store`.
* `fiscal_periods` still comes from the sequential `results` queue.
*
* `.range(from, to)` slices the matched rows exactly like PostgREST pages
* them, so a fetchAllRows caller is actually exercised page by page; a query
* that never calls `.range()` gets everything back at once (the old
* cap-oblivious behaviour a real PostgREST would truncate at 1000 rows).
*/
function makeGuardClient(store: GuardStore) {
const queries: Array<{
table: string
specs: FilterSpec[]
orderedBy: string | null
range: { from: number; to: number } | null
}> = []
function guardBuilder(table: (typeof GUARD_TABLES)[number]) {
const specs: FilterSpec[] = []
const record: (typeof queries)[number] = { table, specs, orderedBy: null, range: null }
queries.push(record)
let head = false
const b: Record<string, unknown> = {}
b.select = vi.fn((_cols?: string, opts?: { head?: boolean }) => {
head = opts?.head === true
return b
})
for (const op of ['eq', 'is', 'gte', 'lte', 'in'] as const) {
b[op] = vi.fn((col: string, val: unknown) => {
specs.push({ col, op, val })
return b
})
}
b.order = vi.fn((col: string) => {
record.orderedBy = col
return b
})
b.range = vi.fn((from: number, to: number) => {
record.range = { from, to }
return b
})
b.then = (resolve: (v: unknown) => void) => {
const failure = store.failOn?.[table]
if (failure) return resolve({ data: null, count: null, error: { message: failure } })
let matched = (store[table] ?? []).filter((r) => matchesAll(r, specs))
if (record.orderedBy) {
const col = record.orderedBy
matched = [...matched].sort((a, c) => String(a[col]).localeCompare(String(c[col])))
}
if (record.range) {
matched = matched.slice(record.range.from, record.range.to + 1)
}
return resolve(
head
? { count: matched.length, data: null, error: null }
: { data: matched, count: null, error: null },
)
}
return b
}
const client = {
from: vi.fn((table: string) =>
(GUARD_TABLES as readonly string[]).includes(table)
? guardBuilder(table as (typeof GUARD_TABLES)[number])
: makeBuilder(),
),
rpc: vi.fn(),
}
return { client, queries }
}
function openPeriod() {
return makeFiscalPeriod({
id: 'fp-1',
locked_at: null,
is_closed: false,
period_start: '2026-01-01',
period_end: '2026-12-31',
})
}
function tx(overrides: Row): Row {
return {
company_id: 'company-1',
date: '2026-03-15',
is_business: null,
is_ignored: false,
journal_entry_id: null,
...overrides,
}
}
/** Queue for a lock that is expected to succeed: fetch, then update. */
function expectLockToSucceed(period: object) {
results = [
{ data: period, error: null },
{ data: { ...period, locked_at: '2026-12-31T23:59:59Z' }, error: null },
]
}
describe('lockPeriod: unbooked transaction guard', () => {
it('refuses to lock a period that still holds untriaged bank transactions', async () => {
results = [{ data: openPeriod(), error: null }]
// Not yet triaged: is_business IS NULL, not ignored.
const { client } = makeGuardClient({
transactions: [
tx({ id: 't1' }),
tx({ id: 't2', date: '2026-07-01' }),
tx({ id: 't3', date: '2026-11-30' }),
],
})
await expect(lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow(
/3 banktransaktion\(er\)[\s\S]*3 ej hanterade/,
)
})
it('refuses to lock when a transaction is triaged as a business event but has no verifikat', async () => {
// The stronger BFL 5 kap 2 § case: the user has already confirmed this is
// the company's affärshändelse, and a lock would strand it.
results = [{ data: openPeriod(), error: null }]
const { client } = makeGuardClient({
transactions: [
tx({ id: 'b1', is_business: true, journal_entry_id: null }),
tx({ id: 'b2', is_business: true, journal_entry_id: null, date: '2026-09-09' }),
],
})
await expect(lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow(
/2 banktransaktion\(er\)[\s\S]*2 markerade som affärshändelse men utan verifikat/,
)
})
it('breaks the count down per reason when both kinds are present', async () => {
results = [{ data: openPeriod(), error: null }]
const { client } = makeGuardClient({
transactions: [
tx({ id: 'u1' }),
tx({ id: 'u2' }),
tx({ id: 'b1', is_business: true, journal_entry_id: null }),
],
})
let thrown: Error | undefined
try {
await lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')
} catch (e) {
thrown = e as Error
}
const message = thrown?.message ?? ''
expect(message).toContain('3 banktransaktion(er)')
expect(message).toContain('2 ej hanterade')
expect(message).toContain('1 markerade som affärshändelse men utan verifikat')
})
it('ignores untriaged transactions dated outside the period', async () => {
expectLockToSucceed(openPeriod())
const { client } = makeGuardClient({
transactions: [
tx({ id: 'before', date: '2025-12-31' }),
tx({ id: 'after', date: '2027-01-01' }),
tx({ id: 'biz-before', date: '2025-06-01', is_business: true }),
],
})
const result = await lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')
expect(result.locked_at).toBeTruthy()
})
it('locks a period whose transactions are all explicitly marked private', async () => {
expectLockToSucceed(openPeriod())
// is_business = false is privat uttag: triaged and deliberately excluded.
// Neither leg of the guard may count it, with or without a journal entry.
const { client } = makeGuardClient({
transactions: [
tx({ id: 'p1', is_business: false, journal_entry_id: 'je-1' }),
tx({ id: 'p2', is_business: false, journal_entry_id: null }),
],
})
const result = await lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')
expect(result.locked_at).toBeTruthy()
})
it('locks a clean period: booked, private and ignored rows do not block', async () => {
expectLockToSucceed(openPeriod())
const { client } = makeGuardClient({
transactions: [
// Booked the 1:1 way.
tx({ id: 'b1', is_business: true, journal_entry_id: 'je-1' }),
// Bulk-booked: anchored via transaction_voucher_links, journal_entry_id
// stays NULL (lib/transactions/is-booked.ts).
tx({ id: 'b2', is_business: true, journal_entry_id: null }),
// Multi-allocated across customer invoices.
tx({ id: 'b3', is_business: true, journal_entry_id: null }),
// Multi-allocated across supplier invoices.
tx({ id: 'b4', is_business: true, journal_entry_id: null }),
// Triaged as private.
tx({ id: 'p1', is_business: false, journal_entry_id: 'je-2' }),
// Explicitly suppressed: "never going to book it".
tx({ id: 'i1', is_business: null, is_ignored: true }),
],
transaction_voucher_links: [{ transaction_id: 'b2' }],
invoice_payments: [{ transaction_id: 'b3' }],
supplier_invoice_payments: [{ transaction_id: 'b4' }],
})
const result = await lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')
expect(result.locked_at).toBeTruthy()
})
it('paginates the business-unbooked candidate fetch past the 1000-row PostgREST page cap', async () => {
// Bulk-booked transactions keep journal_entry_id NULL, so >1000 candidates
// is realistic. A bare .select() would silently stop at 1000 rows; the
// guard must page through all 1500 and refuse with the full count.
results = [{ data: openPeriod(), error: null }]
const rows = Array.from({ length: 1500 }, (_, i) =>
tx({ id: `t${String(i).padStart(4, '0')}`, is_business: true, journal_entry_id: null }),
)
const { client, queries } = makeGuardClient({ transactions: rows })
await expect(lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow(
/1500 banktransaktion\(er\)[\s\S]*1500 markerade som affärshändelse men utan verifikat/,
)
// The candidate fetch (non-head transactions reads) is paginated with a
// stable unique order, per the fetch-all.ts ordering invariant.
const candidatePages = queries.filter((q) => q.table === 'transactions' && q.range !== null)
expect(candidatePages.length).toBeGreaterThanOrEqual(2)
for (const page of candidatePages) {
expect(page.orderedBy).toBe('id')
}
expect(candidatePages[0].range).toEqual({ from: 0, to: 999 })
expect(candidatePages[1].range).toEqual({ from: 1000, to: 1999 })
})
it('anchored rows beyond the first page still reduce the blocking count', async () => {
// 1200 candidates, of which the LAST 100 (sorted by id, i.e. entirely on
// page 2) are bulk-booked via transaction_voucher_links. Without
// pagination those rows were never fetched, so their anchoring never
// mattered; with it, 1200 - 100 = 1100 genuinely unbooked rows block.
results = [{ data: openPeriod(), error: null }]
const rows = Array.from({ length: 1200 }, (_, i) =>
tx({ id: `t${String(i).padStart(4, '0')}`, is_business: true, journal_entry_id: null }),
)
const anchoredIds = rows.slice(1100).map((r) => r.id as string)
const { client } = makeGuardClient({
transactions: rows,
transaction_voucher_links: anchoredIds.map((id) => ({ transaction_id: id })),
})
await expect(lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow(
/1100 banktransaktion\(er\)[\s\S]*1100 markerade som affärshändelse men utan verifikat/,
)
})
it('regression: the old journal_entry_id IS NULL + is_business = true guard let untriaged rows through', async () => {
results = [{ data: openPeriod(), error: null }]
const untriaged = [tx({ id: 't1' }), tx({ id: 't2' }), tx({ id: 't3' })]
// The old predicate matches none of them: triage is what sets is_business,
// so "unbooked AND is_business = true" can never describe an untriaged row.
// With that as the only guard, locking a period holding 40 untriaged
// transactions succeeded and the trigger then froze them in place.
expect(untriaged.filter((r) => matchesAll(r, OLD_DEAD_GUARD))).toHaveLength(0)
// The guard as it stands now does catch them.
const { client, queries } = makeGuardClient({ transactions: untriaged })
await expect(lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow(
/saknar bokföring/,
)
// Leg 1 is the canonical worklist predicate, not the old one.
const legOne = queries.find((q) => q.table === 'transactions')?.specs ?? []
expect(legOne).toEqual(
expect.arrayContaining([
{ col: 'is_business', op: 'is', val: null },
{ col: 'is_ignored', op: 'eq', val: false },
]),
)
expect(legOne).not.toContainEqual({ col: 'journal_entry_id', op: 'is', val: null })
})
it('refuses to lock when the untriaged count query errors, rather than waving the lock through', async () => {
results = [{ data: openPeriod(), error: null }]
const { client } = makeGuardClient({
transactions: [],
failOn: { transactions: 'connection reset' },
})
await expect(lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow(
/Kunde inte kontrollera obokförda banktransaktioner/,
)
})
it('refuses to lock when an anchor lookup errors: a half-run guard is no guard', async () => {
results = [{ data: openPeriod(), error: null }]
const { client } = makeGuardClient({
transactions: [tx({ id: 'b1', is_business: true, journal_entry_id: null })],
failOn: { transaction_voucher_links: 'statement timeout' },
})
await expect(lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow(
/Kunde inte kontrollera obokförda banktransaktioner/,
)
})
it('the infra failure message is NOT mapped to the unbooked-transactions code', async () => {
// An unreachable DB must not send an agent off remediating transactions.
results = [{ data: openPeriod(), error: null }]
const { client } = makeGuardClient({
transactions: [],
failOn: { transactions: 'connection reset' },
})
let thrown: Error | undefined
try {
await lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')
} catch (e) {
thrown = e as Error
}
expect(thrown?.message ?? '').not.toContain('saknar bokföring')
expect(getStructuredError(thrown as Error).code).not.toBe('PERIOD_HAS_UNBOOKED_TRANSACTIONS')
})
// Both legs must produce a message the two mappers recognise. The
// untriaged-only case is the trap: its breakdown clause never says
// "affärstransaktion", so the inferCode() regex has to be satisfied by the
// fixed part of the sentence.
it.each([
['untriaged only', [tx({ id: 't1' }), tx({ id: 't2' })]],
[
'business-unbooked only',
[
tx({ id: 'b1', is_business: true, journal_entry_id: null }),
tx({ id: 'b2', is_business: true, journal_entry_id: null }),
],
],
])('throws a message both error mappers still recognise (%s)', async (_label, rows) => {
results = [{ data: openPeriod(), error: null }]
const { client } = makeGuardClient({ transactions: rows as Row[] })
let thrown: Error | undefined
try {
await lockPeriod(client as never, 'company-1', 'user-1', 'fp-1')
} catch (e) {
thrown = e as Error
}
expect(thrown).toBeInstanceOf(Error)
const message = thrown?.message ?? ''
// Both lock routes: substring match -> PERIOD_HAS_UNBOOKED_TRANSACTIONS (400).
expect(message).toContain('saknar bokföring')
// MCP/agent surfaces: inferCode() in lib/errors/get-structured-error.ts.
expect(getStructuredError(thrown as Error).code).toBe('PERIOD_HAS_UNBOOKED_TRANSACTIONS')
// Actionable: how many, why, and where to go.
expect(message).toContain('2 banktransaktion(er)')
expect(message).toContain('Transaktioner')
})
})
// ============================================================
// resolvePeriodStatusForDate: the shared lock helper must fail CLOSED.
// A swallowed PostgREST error used to be reported as `open`, which every
// caller reads as "this date is writable".
// ============================================================
describe('resolvePeriodStatusForDate', () => {
it('reports a verified open period', async () => {
results = [
{ data: { bookkeeping_locked_through: null }, error: null },
{ data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null },
]
const status = await resolvePeriodStatusForDate(makeClient() as never, 'company-1', '2026-03-01')
expect(status).toEqual({ period_id: 'fp-1', status: 'open', lock_date: null })
})
it('keeps "no covering period" distinguishable: open with a null period_id and no failure flag', async () => {
results = [
{ data: { bookkeeping_locked_through: null }, error: null },
{ data: null, error: null },
]
const status = await resolvePeriodStatusForDate(makeClient() as never, 'company-1', '2026-03-01')
expect(status.status).toBe('open')
expect(status.period_id).toBeNull()
expect(status.lookup_failed).toBeUndefined()
})
it('fails closed when the company_settings lookup errors', async () => {
results = [{ data: null, error: { message: 'PGRST301 JWT expired' } }]
const status = await resolvePeriodStatusForDate(makeClient() as never, 'company-1', '2026-03-01')
expect(status.status).not.toBe('open')
expect(status).toEqual({
period_id: null,
status: 'locked',
lock_date: null,
lookup_failed: true,
})
})
it('fails closed when the fiscal_periods lookup errors', async () => {
results = [
{ data: { bookkeeping_locked_through: null }, error: null },
// e.g. two overlapping periods cover the date: .maybeSingle() errors.
{ data: null, error: { message: 'JSON object requested, multiple rows returned' } },
]
const status = await resolvePeriodStatusForDate(makeClient() as never, 'company-1', '2026-03-01')
expect(status.status).not.toBe('open')
expect(status.lookup_failed).toBe(true)
expect(status.period_id).toBeNull()
})
it('still resolves the company lock date layer without touching the period lookup verdict', async () => {
results = [
{ data: { bookkeeping_locked_through: '2026-06-30' }, error: null },
// Refinement lookup fails: verdict is already locked, so this is non-fatal.
{ data: null, error: { message: 'timeout' } },
]
const status = await resolvePeriodStatusForDate(makeClient() as never, 'company-1', '2026-03-01')
expect(status.status).toBe('locked')
expect(status.lock_date).toBe('2026-06-30')
expect(status.lookup_failed).toBeUndefined()
})
it('reports closed and locked periods unchanged', async () => {
results = [
{ data: { bookkeeping_locked_through: null }, error: null },
{ data: { id: 'fp-1', is_closed: true, locked_at: null }, error: null },
]
await expect(
resolvePeriodStatusForDate(makeClient() as never, 'company-1', '2026-03-01'),
).resolves.toEqual({ period_id: 'fp-1', status: 'closed', lock_date: null })
resultIdx = 0
results = [
{ data: { bookkeeping_locked_through: null }, error: null },
{ data: { id: 'fp-2', is_closed: false, locked_at: '2026-01-31T00:00:00Z' }, error: null },
]
await expect(
resolvePeriodStatusForDate(makeClient() as never, 'company-1', '2026-03-01'),
).resolves.toEqual({
period_id: 'fp-2',
status: 'locked',
lock_date: '2026-01-31T00:00:00Z',
})
})
})
describe('closePeriod', () => {
it('requires period is locked and has closing_entry_id', async () => {
const period = makeFiscalPeriod({
@@ -11,7 +11,7 @@ let results: Array<{ data?: unknown; error?: unknown; count?: number | null }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'neq', 'not', 'or', 'order', 'limit', 'is']) {
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'neq', 'not', 'or', 'order', 'limit', 'is', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
@@ -20,6 +20,176 @@ function makeBuilder() {
return b
}
// ============================================================
// Filter-aware mock, used by the balansdagen FX readiness tests
// ============================================================
type Row = Record<string, unknown>
interface SeededTables {
fiscal_periods?: Row[]
journal_entries?: Row[]
voucher_sequences?: Row[]
invoices?: Row[]
supplier_invoices?: Row[]
invoice_payments?: Row[]
supplier_invoice_payments?: Row[]
}
/**
* Resolves each query by actually applying its filters to a seeded table,
* unlike the positional queue above which hands back a fixed row set no matter
* what was asked for.
*
* The FX readiness check is entirely a question about WHICH rows a query
* selects: status at balansdagen vs status now, `entry_date` on the
* revaluation verifikat, presence of `exchange_rate`. A queue mock answers all
* of those identically and so cannot tell the old behaviour from the new one.
*/
function makeFilteringClient(tables: SeededTables, failTable?: string) {
return {
from: vi.fn((table: string) => {
const eqs: Array<[string, unknown]> = []
const neqs: Array<[string, unknown]> = []
const ins: Array<[string, unknown[]]> = []
const ltes: Array<[string, string]> = []
const notNull: string[] = []
let head = false
let orderColumn: string | null = null
let orderAscending = true
let limitN: number | null = null
let rangeFrom: number | null = null
let rangeTo: number | null = null
function matching(): Row[] {
if (failTable === table) throw new Error(`simulated query failure on ${table}`)
const out = (tables[table as keyof SeededTables] ?? []).filter(
(r) =>
eqs.every(([c, v]) => r[c] === v) &&
neqs.every(([c, v]) => r[c] !== v) &&
ins.every(([c, vs]) => vs.includes(r[c])) &&
ltes.every(([c, v]) => String(r[c] ?? '') <= v) &&
notNull.every((c) => r[c] != null)
)
if (orderColumn) {
const col = orderColumn
out.sort((a, b2) => {
const av = a[col] as string | number
const bv = b2[col] as string | number
if (av === bv) return 0
return (av < bv ? -1 : 1) * (orderAscending ? 1 : -1)
})
}
return limitN == null ? out : out.slice(0, limitN)
}
function resolve() {
const all = matching()
const paged = rangeFrom == null ? all : all.slice(rangeFrom, (rangeTo ?? 0) + 1)
return { data: head ? null : paged, error: null, count: all.length }
}
const b: Record<string, unknown> = {}
b.select = vi.fn((_cols?: string, opts?: { head?: boolean }) => {
head = opts?.head === true
return b
})
b.eq = vi.fn((c: string, v: unknown) => { eqs.push([c, v]); return b })
b.neq = vi.fn((c: string, v: unknown) => { neqs.push([c, v]); return b })
b.in = vi.fn((c: string, v: unknown[]) => { ins.push([c, v]); return b })
b.lte = vi.fn((c: string, v: string) => { ltes.push([c, v]); return b })
b.not = vi.fn((c: string, op: string, v: unknown) => {
if (op === 'is' && v === null) notNull.push(c)
return b
})
b.order = vi.fn((c: string, opts?: { ascending?: boolean }) => {
orderColumn = c
orderAscending = opts?.ascending !== false
return b
})
b.limit = vi.fn((n: number) => { limitN = n; return b })
b.range = vi.fn((from: number, to: number) => { rangeFrom = from; rangeTo = to; return b })
for (const m of ['gte', 'is', 'or', 'insert', 'update', 'delete']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn(async () => ({ data: matching()[0] ?? null, error: null }))
b.maybeSingle = vi.fn(async () => ({ data: matching()[0] ?? null, error: null }))
b.then = (done: (v: unknown) => void) => done(resolve())
return b
}),
rpc: vi.fn(async () => ({ data: [], error: null })),
}
}
const FX_PERIOD_END = '2024-12-31'
/**
* Baseline books that pass every non-FX readiness gate: one posted verifikat,
* no drafts, no voucher gaps, sequence counter reconciled.
*/
function fxBaseTables(extra: SeededTables = {}): SeededTables {
return {
fiscal_periods: [
makeFiscalPeriod({
id: 'fp-1',
company_id: 'company-1',
is_closed: false,
closing_entry_id: null,
period_end: FX_PERIOD_END,
}) as unknown as Row,
],
voucher_sequences: [
{ company_id: 'company-1', fiscal_period_id: 'fp-1', voucher_series: 'A', last_number: 10 },
],
journal_entries: [
{
company_id: 'company-1',
fiscal_period_id: 'fp-1',
voucher_series: 'A',
voucher_number: 10,
status: 'posted',
source_type: 'manual',
entry_date: '2024-06-01',
},
...(extra.journal_entries ?? []),
],
invoices: extra.invoices ?? [],
supplier_invoices: extra.supplier_invoices ?? [],
invoice_payments: extra.invoice_payments ?? [],
supplier_invoice_payments: extra.supplier_invoice_payments ?? [],
}
}
/** A posted currency_revaluation verifikat dated `entryDate`. */
function revaluationEntry(entryDate: string): Row {
return {
company_id: 'company-1',
fiscal_period_id: 'fp-1',
voucher_series: 'A',
voucher_number: 5,
status: 'posted',
source_type: 'currency_revaluation',
entry_date: entryDate,
}
}
/** An open EUR customer invoice. `exchange_rate: null` = never converted. */
function fxInvoice(overrides: Row = {}): Row {
return {
id: `inv-${Math.random().toString(36).slice(2, 10)}`,
company_id: 'company-1',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.2,
invoice_date: '2024-11-15',
total: 1000,
paid_amount: 0,
remaining_amount: 1000,
paid_at: null,
...overrides,
}
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
@@ -76,9 +246,7 @@ describe('validateYearEndReadiness', () => {
function noGapResults(period: ReturnType<typeof makeFiscalPeriod>, overrides: {
draftCount?: number
postedCount?: number
revalCount?: number
fxReceivables?: number
fxPayables?: number
balansdagenRevalCount?: number
} = {}) {
return [
{ data: period, error: null }, // fetch period (.single)
@@ -90,9 +258,9 @@ describe('validateYearEndReadiness', () => {
{ data: { voucher_number: 10 }, error: null }, // reconciliation: journal_entries max (.maybeSingle)
// trial balance mocked separately
{ data: null, error: null, count: overrides.postedCount ?? 5 }, // count posted (thenable)
{ data: null, error: null, count: overrides.revalCount ?? 0 }, // count revaluation (thenable)
{ data: null, error: null, count: overrides.fxReceivables ?? 0 }, // fx receivables (thenable)
{ data: null, error: null, count: overrides.fxPayables ?? 0 }, // fx payables (thenable)
{ data: null, error: null, count: overrides.balansdagenRevalCount ?? 0 }, // revaluation dated on balansdagen (thenable)
{ data: null, error: null }, // open FX receivables: no rows (fetchAllRows)
{ data: null, error: null }, // open FX payables: no rows (fetchAllRows)
]
}
@@ -398,6 +566,237 @@ describe('validateYearEndReadiness', () => {
})
})
describe('validateYearEndReadiness: open FX items at balansdagen (ÅRL 4 kap. 13 §)', () => {
beforeEach(() => {
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [],
isBalanced: true,
totalDebit: 10000,
totalCredit: 10000,
} as never)
vi.mocked(findNextPeriod).mockResolvedValue(null as never)
})
const revaluationWarning = (w: string) => w.includes('har inte omvärderats till balansdagskurs')
const missingRateWarning = (w: string) => w.includes('saknar valutakurs')
it('does not let one interim revaluation hide items still open on balansdagen', async () => {
// A single revaluation run in June. Twelve invoices were still outstanding
// on 31 December and none of them were valued at the balansdagen rate.
const supabase = makeFilteringClient(
fxBaseTables({
journal_entries: [revaluationEntry('2024-06-30')],
invoices: Array.from({ length: 12 }, (_, i) => fxInvoice({ id: `inv-${i}` })),
})
)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.warnings.filter(revaluationWarning)).toHaveLength(1)
expect(result.warnings.find(revaluationWarning)).toContain('12 post(er)')
expect(result.warnings.find(revaluationWarning)).toContain(FX_PERIOD_END)
// Advisory, not a blocker: executeYearEndClosing revalues in its step 2.
expect(result.ready).toBe(true)
})
it('counts items with no exchange rate at all and states the different remedy', async () => {
const supabase = makeFilteringClient(
fxBaseTables({
invoices: [
fxInvoice({ id: 'inv-a', exchange_rate: null }),
fxInvoice({ id: 'inv-b', exchange_rate: null }),
fxInvoice({ id: 'inv-c', exchange_rate: 0 }),
],
})
)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
// These are invisible to the revaluation, so they get their own warning
// with its own remedy, and they are never folded into the other count.
const missing = result.warnings.find(missingRateWarning)
expect(missing).toBeDefined()
expect(missing).toContain('3 post(er)')
expect(missing).toContain('Registrera kursen på fakturan')
expect(result.warnings.filter(revaluationWarning)).toHaveLength(0)
expect(result.ready).toBe(true)
})
it('separates the two states when both are present', async () => {
const supabase = makeFilteringClient(
fxBaseTables({
invoices: [
fxInvoice({ id: 'inv-rate-1' }),
fxInvoice({ id: 'inv-rate-2' }),
fxInvoice({ id: 'inv-norate', exchange_rate: null }),
],
})
)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.warnings.find(missingRateWarning)).toContain('1 post(er)')
expect(result.warnings.find(revaluationWarning)).toContain('2 post(er)')
})
it('still reports a clean company as ready with no FX warning', async () => {
const supabase = makeFilteringClient(fxBaseTables())
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(true)
expect(result.warnings.filter((w: string) => w.includes('valuta'))).toHaveLength(0)
})
it('counts an invoice paid after balansdagen: it was open on the balance sheet date', async () => {
// Status today is 'paid', status on 31 December was open. ÅRL 4 kap. 13 §
// values the item at balansdagen, so it must still be counted.
const supabase = makeFilteringClient(
fxBaseTables({
invoices: [
fxInvoice({
id: 'inv-late-paid',
status: 'paid',
invoice_date: '2024-10-01',
total: 5000,
paid_amount: 5000,
remaining_amount: 0,
paid_at: '2025-03-10',
}),
],
invoice_payments: [
{
id: 'pay-1',
company_id: 'company-1',
invoice_id: 'inv-late-paid',
amount: 5000,
payment_date: '2025-03-10',
},
],
})
)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.warnings.find(revaluationWarning)).toContain('1 post(er)')
})
it('counts a partially paid FX invoice: its unpaid remainder was open on balansdagen', async () => {
// payment-sync sets 'partially_paid' on partial settlements. The old
// status list ('sent'/'overdue'/'paid') never matched it, so the readiness
// check reported no exposure for a receivable whose 600 EUR remainder
// still had to be valued at balansdagskurs (ÅRL 4 kap. 13 §).
const supabase = makeFilteringClient(
fxBaseTables({
invoices: [
fxInvoice({
id: 'inv-partial',
status: 'partially_paid',
total: 1000,
paid_amount: 400,
remaining_amount: 600,
}),
],
invoice_payments: [
{
id: 'pay-partial',
company_id: 'company-1',
invoice_id: 'inv-partial',
amount: 400,
payment_date: '2024-12-01',
},
],
})
)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.warnings.find(revaluationWarning)).toContain('1 post(er)')
})
it('ignores an invoice issued after balansdagen even though it is open today', async () => {
// The mirror image: open now, did not exist on the balance sheet date.
const supabase = makeFilteringClient(
fxBaseTables({
invoices: [fxInvoice({ id: 'inv-next-year', invoice_date: '2025-02-01' })],
})
)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.warnings.filter((w: string) => w.includes('valuta'))).toHaveLength(0)
})
it('counts open FX payables as well as receivables', async () => {
const supabase = makeFilteringClient(
fxBaseTables({
supplier_invoices: [
{
id: 'si-1',
company_id: 'company-1',
status: 'approved',
currency: 'USD',
exchange_rate: 10.4,
invoice_date: '2024-09-01',
total: 2000,
paid_amount: 0,
remaining_amount: 2000,
paid_at: null,
},
{
id: 'si-2',
company_id: 'company-1',
status: 'registered',
currency: 'USD',
exchange_rate: null,
invoice_date: '2024-09-02',
total: 800,
paid_amount: 0,
remaining_amount: 800,
paid_at: null,
},
],
})
)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.warnings.find(revaluationWarning)).toContain('1 post(er)')
expect(result.warnings.find(missingRateWarning)).toContain('1 post(er)')
})
it('suppresses only the revaluation warning when a balansdagen revaluation exists', async () => {
const supabase = makeFilteringClient(
fxBaseTables({
journal_entries: [revaluationEntry(FX_PERIOD_END)],
invoices: [
fxInvoice({ id: 'inv-rate-1' }),
fxInvoice({ id: 'inv-rate-2' }),
fxInvoice({ id: 'inv-norate', exchange_rate: null }),
],
})
)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.warnings.filter(revaluationWarning)).toHaveLength(0)
// The posted revaluation provably did not include the unconverted row:
// previewCurrencyRevaluation partitions it out. So it stays warned about.
expect(result.warnings.find(missingRateWarning)).toContain('1 post(er)')
})
it('says the check did not run rather than reporting no exposure when it fails', async () => {
const supabase = makeFilteringClient(fxBaseTables(), 'invoices')
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(
result.warnings.some((w: string) => w.includes('kunde inte genomföras'))
).toBe(true)
expect(result.ready).toBe(true)
})
})
describe('previewYearEndClosing', () => {
it('calculates net result from class 3-8 accounts', async () => {
results = [
+261 -16
View File
@@ -1,8 +1,141 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { createLogger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import type { FiscalPeriod, PeriodStatus } from '@/types'
const log = createLogger('period-service')
/**
* The two ways a bank transaction can still owe the period a verifikation.
*
* Triage is the act of answering "is this an affärshändelse for the company?".
* The three answers, and what each means for a period lock:
*
* is_business IS NULL, is_ignored = false -> NOT TRIAGED. Nobody has looked
* at it. Counted as `untriaged`; blocks.
* is_business = true -> TRIAGED AS A BUSINESS EVENT. It
* owes a verifikation. Blocks unless it already has one (see below);
* counted as `businessUnbooked` when it does not.
* is_business = false -> TRIAGED AND EXCLUDED (privat
* uttag / not the company's affär). Never blocks: there is nothing to
* bokföra, and holding a lock hostage to a private coffee is over-
* blocking, not compliance.
* is_ignored = true -> TRIAGED AND EXCLUDED, the user's
* explicit "hide this, never going to book it" (migration
* 20260529190000). Never blocks. The column is NOT NULL DEFAULT false,
* so `.eq('is_ignored', false)` cannot silently drop rows.
*
* "Already has a verifikation" is deliberately NOT `journal_entry_id IS NOT
* NULL`. That column only covers the 1:1 case; bulk-booked (N tx -> 1 JE, via
* transaction_voucher_links) and multi-allocated (invoice_payments /
* supplier_invoice_payments) transactions stay NULL while being anchored to a
* real verifikat. See lib/transactions/is-booked.ts and its Postgres mirror
* public.is_transaction_booked(uuid); this helper reproduces all three
* locations so the guard does not block on already-booked rows.
*/
export interface UnbookedInPeriod {
/** Never triaged: is_business IS NULL AND is_ignored = false. */
untriaged: number
/** Triaged as a business event but anchored to no verifikat at all. */
businessUnbooked: number
}
/** PostgREST rejects very long URLs, so `.in()` lists are chunked. */
const ANCHOR_LOOKUP_CHUNK = 200
/**
* Count the bank transactions in [periodStart, periodEnd] that a period lock
* would strand. Throws on any query failure so the caller can fail closed:
* never return 0 for a check that did not actually run.
*/
export async function countUnbookedInPeriod(
supabase: SupabaseClient,
companyId: string,
periodStart: string,
periodEnd: string,
): Promise<UnbookedInPeriod> {
// Leg 1: never triaged. This is the canonical "att bokföra" predicate from
// lib/worklist/categories.ts, so the number here reconciles with the
// "N st att bokföra" badge instead of being a second, unexplainable count.
// Served by the partial index idx_transactions_company_unbooked.
const { count: untriaged, error: untriagedError } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.is('is_business', null)
.eq('is_ignored', false)
.gte('date', periodStart)
.lte('date', periodEnd)
if (untriagedError) {
throw new Error(`untriaged transaction count failed: ${untriagedError.message}`)
}
// Leg 2: triaged as a business event, but no verifikat anywhere. The user has
// already confirmed this is the company's affärshändelse, so a lock strands
// it just as hard as an untriaged one, only with less excuse. Fetch the ids
// and subtract the ones anchored via the non-denormalized locations.
//
// Paginated via fetchAllRows with a stable id order: PostgREST silently caps
// a bare select at 1000 rows, and this candidate set is NOT bounded in
// practice, because bulk-booked transactions keep journal_entry_id NULL
// (anchored only via transaction_voucher_links). An unpaginated read would
// drop every candidate past row 1000, under-counting businessUnbooked and
// letting a period lock while genuinely unbooked affärshändelser are
// stranded in it (BFL 5 kap 2 §).
let candidates: Array<{ id?: string }>
try {
candidates = await fetchAllRows<{ id?: string }>(({ from, to }) =>
supabase
.from('transactions')
.select('id')
.eq('company_id', companyId)
.eq('is_business', true)
.eq('is_ignored', false)
.is('journal_entry_id', null)
.gte('date', periodStart)
.lte('date', periodEnd)
.order('id', { ascending: true })
.range(from, to)
)
} catch (err) {
throw new Error(
`business transaction lookup failed: ${err instanceof Error ? err.message : String(err)}`
)
}
const candidateIds = candidates
.map((row) => row.id)
.filter((id): id is string => typeof id === 'string')
if (candidateIds.length === 0) {
return { untriaged: untriaged ?? 0, businessUnbooked: 0 }
}
const anchored = new Set<string>()
for (const table of ['transaction_voucher_links', 'invoice_payments', 'supplier_invoice_payments'] as const) {
for (let i = 0; i < candidateIds.length; i += ANCHOR_LOOKUP_CHUNK) {
const chunk = candidateIds.slice(i, i + ANCHOR_LOOKUP_CHUNK)
const { data, error } = await supabase
.from(table)
.select('transaction_id')
.in('transaction_id', chunk)
if (error) {
throw new Error(`${table} anchor lookup failed: ${error.message}`)
}
for (const row of data ?? []) {
const id = (row as { transaction_id?: string | null }).transaction_id
if (id) anchored.add(id)
}
}
}
return {
untriaged: untriaged ?? 0,
businessUnbooked: candidateIds.filter((id) => !anchored.has(id)).length,
}
}
/**
* Lock a fiscal period: prevents new journal entries from being posted.
* Requires: period exists, belongs to company, not already locked/closed.
@@ -34,19 +167,68 @@ export async function lockPeriod(
throw new Error('Period is already locked')
}
// Check for uncategorized business transactions in this period
const { count: unbookedCount } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_business', true)
.gte('date', period.period_start)
.lte('date', period.period_end)
if (unbookedCount && unbookedCount > 0) {
// Refuse to lock while the period still holds bank transactions that would be
// stranded. BFL 5 kap 2 § requires every affärshändelse to be bokförd in the
// period it belongs to; once locked_at is set the enforce_period_lock trigger
// makes these rows unbookable in place, leaving affärshändelser that can only
// be entered by unlocking again or via a rättelse.
//
// Hard block, not an advisory warning with a bypass. There is no legitimate
// "lock anyway" case: locking is a voluntary internal control with no legal
// deadline, while the affärshändelser it would strand do have one (BFL 5 kap
// 2 §: kontant senast nästa arbetsdag, övrigt "så snart det kan ske").
// Every escape hatch the user could want is already an exit from the
// predicate and is named in the message: book it, mark it privat, or mark it
// ignored. Locking first and unlocking later is strictly worse, because the
// unlock lands in the immutable audit_log as a control override.
let unbooked: UnbookedInPeriod
try {
unbooked = await countUnbookedInPeriod(
supabase,
companyId,
period.period_start,
period.period_end,
)
} catch (err) {
// Fail closed. A guard that cannot run must not wave the lock through:
// that is exactly how the previous dead predicate went unnoticed.
log.error('unbooked-transaction guard failed, refusing to lock', {
companyId,
fiscalPeriodId,
reason: err instanceof Error ? err.message : String(err),
})
throw new Error(
`Kan inte låsa period: ${unbookedCount} affärstransaktion(er) saknar bokföring. Bokför alla transaktioner innan perioden låses.`
'Kunde inte kontrollera obokförda banktransaktioner i perioden. Perioden lämnas olåst. Försök igen.'
)
}
const blockingCount = unbooked.untriaged + unbooked.businessUnbooked
if (blockingCount > 0) {
// Message wording is load-bearing for two separate matchers; keep both
// phrases when editing:
// - "saknar bokföring" -> both lock routes map this to the
// PERIOD_HAS_UNBOOKED_TRANSACTIONS envelope (400) instead of a 500
// (app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts and
// app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts)
// - /Kan inte låsa period:.*affärstransaktion/ -> inferCode() in
// lib/errors/get-structured-error.ts derives the same code for the
// MCP/agent surfaces, which have no HTTP envelope to read. The word
// "affärstransaktion" therefore has to appear unconditionally, not
// only in the breakdown clause that the untriaged-only case omits.
// The infra message above deliberately matches neither: an unreachable DB
// must not send an agent off remediating transactions.
const breakdown = [
unbooked.untriaged > 0 ? `${unbooked.untriaged} ej hanterade` : null,
unbooked.businessUnbooked > 0
? `${unbooked.businessUnbooked} markerade som affärshändelse men utan verifikat`
: null,
]
.filter(Boolean)
.join(', ')
throw new Error(
`Kan inte låsa period: ${blockingCount} banktransaktion(er) i perioden saknar bokföring ` +
`(${breakdown}). Alla affärstransaktioner måste vara bokförda innan perioden låses. ` +
`Gå till Transaktioner, bokför dem eller markera dem som privata eller ignorerade, och lås perioden därefter.`
)
}
@@ -435,9 +617,54 @@ export interface PeriodStatusForDate {
/**
* For `locked` status: either the period's `locked_at` timestamp (ISO) or the
* company-wide `bookkeeping_locked_through` date (ISO), whichever applies.
* `null` for open/closed.
* `null` for open/closed, and `null` when `lookup_failed` is true.
*/
lock_date: string | null
/**
* True when the lock lookup itself failed (PostgREST error, an overlapping
* fiscal_periods pair breaking `.maybeSingle()`, a network blip). `status`
* is then reported as `'locked'`, because a period whose state we could not
* read must never be presented as writable.
*
* Keep the three cases distinguishable, callers treat them differently:
* { status: 'open', period_id: <id> } -> verified open
* { status: 'open', period_id: null } -> verified: no
* covering period exists at all. Not an error: the engine's
* ensure-period helper creates one on write.
* { status: 'locked', period_id: null, lookup_failed } -> unverified.
* Nothing is known about the date. Retryable; a genuinely locked
* period always carries a real period_id or lock_date.
*
* Deliberately an additive optional field rather than a fourth
* `PeriodStatusValue`: the union is consumed as an exhaustive
* `Record<PeriodStatusValue, string>` in lib/agent/intents/bokslut-step.ts,
* so widening it would break unrelated callers at compile time.
*/
lookup_failed?: boolean
}
/**
* Fail-closed verdict for a lock lookup that could not be completed.
*
* Reported as `locked` on purpose. The alternative (`open`) is the bug this
* exists to prevent: every caller of resolvePeriodStatusForDate uses the
* result to decide whether a write into a period is allowed, so a swallowed
* query error used to read as "go ahead" on the storno, MCP staging, agent
* draft and pending-operation commit paths.
*/
function periodLookupFailed(
companyId: string,
date: string,
stage: 'company_settings' | 'fiscal_periods',
error: { message?: string } | null,
): PeriodStatusForDate {
log.error('period status lookup failed, failing closed', {
companyId,
date,
stage,
reason: error?.message,
})
return { period_id: null, status: 'locked', lock_date: null, lookup_failed: true }
}
/**
@@ -451,6 +678,11 @@ export interface PeriodStatusForDate {
* Returned shape is the canonical `period_status` envelope threaded into MCP
* tool responses so agents and widgets can disable writes without round-trips.
*
* Fails CLOSED: if either lookup errors we report `locked` with
* `lookup_failed: true` rather than `open`. Never reintroduce a bare
* `const { data } = await ...` here; dropping the `error` is what turned this
* shared helper into a fail-open on every write path that consults it.
*
* Mirrors lib/api/v1/check-period-lock.ts (used by the v1 REST surface). The
* two helpers share the same query pattern; if either changes, update both.
*/
@@ -460,14 +692,21 @@ export async function resolvePeriodStatusForDate(
date: string,
): Promise<PeriodStatusForDate> {
// Layer 1: company-wide lock date.
const { data: settings } = await supabase
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('bookkeeping_locked_through')
.eq('company_id', companyId)
.maybeSingle()
if (settingsError) {
// Unknown company lock date: the date could be behind it. Fail closed.
return periodLookupFailed(companyId, date, 'company_settings', settingsError)
}
const lockThrough = settings?.bookkeeping_locked_through ?? null
if (lockThrough && date <= lockThrough) {
// Find the covering period if any: useful for widget greying.
// Find the covering period if any: useful for widget greying. An error
// here is deliberately non-fatal: the verdict is already `locked` on the
// company lock date alone, so a failed refinement can only cost the
// period_id, never flip the answer to open.
const { data: period } = await supabase
.from('fiscal_periods')
.select('id')
@@ -479,7 +718,7 @@ export async function resolvePeriodStatusForDate(
}
// Layer 2: fiscal period status.
const { data: period } = await supabase
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, is_closed, locked_at')
.eq('company_id', companyId)
@@ -487,6 +726,12 @@ export async function resolvePeriodStatusForDate(
.gte('period_end', date)
.maybeSingle()
if (periodError) {
// Distinct from `!period` below: that is "verified, no covering period
// exists"; this is "we do not know what period covers this date".
return periodLookupFailed(companyId, date, 'fiscal_periods', periodError)
}
if (!period) {
// No covering period: treated as open at this layer; the engine's own
// ensure-period helper will create one. Agents should still warn the user.
+212 -24
View File
@@ -6,6 +6,13 @@ import { createLogger } from '@/lib/logger'
const log = createLogger('year-end-service')
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
fetchPaymentsAsOf,
outstandingAsOf,
todayIsoDate,
type PaymentsAsOf,
} from '@/lib/reports/reskontra-payments'
import { lockPeriod, closePeriod, createNextPeriod, findNextPeriod } from './period-service'
import { generateResultAppropriation } from './result-appropriation-service'
import {
@@ -218,38 +225,62 @@ export async function validateYearEndReadiness(
warnings.push('Inga bokförda verifikationer i perioden')
}
// Check: foreign currency items exist but haven't been revalued
const { count: revalCount } = await supabase
// Check: foreign-currency items open on balansdagen (ÅRL 4 kap. 13 §).
//
// Only a revaluation dated ON balansdagen discharges the duty. The previous
// gate accepted any currency_revaluation verifikat anywhere in the period,
// so one interim run (a June month-end revaluation) silenced the check for
// every item still outstanding on 31 December: "we already did one, so stop
// looking". An interim revaluation says nothing about the balansdagen value.
const { count: balansdagenRevalCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('source_type', 'currency_revaluation')
.eq('status', 'posted')
.eq('entry_date', period.period_end)
if ((revalCount ?? 0) === 0) {
// Check if there are any open foreign currency items
const { count: fxReceivables } = await supabase
.from('invoices')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
// Both states below are warnings, never blockers: nothing in the bokslut
// rules makes an unvalued FX item a bar to closing, and executeYearEndClosing
// runs the revaluation itself in step 2. Escalating would block a close that
// the very next step performs.
try {
const openFx = await countOpenFxItemsAtBalansdagen(supabase, companyId, period.period_end)
const { count: fxPayables } = await supabase
.from('supplier_invoices')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
if (((fxReceivables ?? 0) + (fxPayables ?? 0)) > 0) {
// Loudest case first. A row with no exchange_rate is invisible to the
// revaluation: previewCurrencyRevaluation partitions it into
// `unconvertedFx` and drops it, so re-running the year-end will never
// value it. It needs a rate on the invoice, and these rows are typically
// the largest unmeasured exposure precisely because nothing warns on them.
// The old query's `.not('exchange_rate','is',null)`, inherited from the
// revaluation code, excluded exactly this case: a company whose only open
// FX items were unconverted got no warning at all.
if (openFx.unconverted > 0) {
warnings.push(
'Öppna poster i utländsk valuta har inte omvärderats (ÅRL 4:13)'
`${openFx.unconverted} post(er) i utländsk valuta var öppna på balansdagen ${period.period_end} men saknar valutakurs: de kan inte värderas till balansdagskurs (ÅRL 4 kap. 13 §) och utelämnas ur omvärderingen. Registrera kursen på fakturan.`
)
}
// Separate state, separate remedy: these can be valued, they just have not
// been yet. Suppressed only by a revaluation dated on balansdagen.
if (openFx.revaluable > 0 && (balansdagenRevalCount ?? 0) === 0) {
warnings.push(
`${openFx.revaluable} post(er) i utländsk valuta var öppna på balansdagen ${period.period_end} och har inte omvärderats till balansdagskurs (ÅRL 4 kap. 13 §)`
)
}
} catch (err) {
// A failed lookup must not read as "no FX exposure". Say the check did not
// run rather than let silence pass for a clean bill of health.
log.error('year-end: could not measure open FX items at balansdagen', err as Error, {
operation: 'year_end.fx_readiness',
companyId,
entityType: 'fiscal_period',
entityId: fiscalPeriodId,
})
warnings.push(
'Kontrollen av öppna poster i utländsk valuta (ÅRL 4 kap. 13 §) kunde inte genomföras: stäm av dem manuellt innan bokslut'
)
}
// Check: continuity_verified flag from prior year-end
@@ -656,9 +687,10 @@ export async function executeYearEndClosing(
)
} catch (err) {
resultAppropriationFailed = true
// alert:true marks this for out-of-band alerting (the log sink / Sentry
// integration filters on it): a silent accounting failure must not wait
// for a manual audit. The new period now opens with 2099 still carrying the
// alert:true routes this to the observability sink (lib/observability) in
// addition to the log line: a silent accounting failure must not wait for
// a manual audit. The sink is a no-op until a provider is configured, so
// today this reaches the JSON log only. The new period now opens with 2099 still carrying the
// prior result; resultAppropriationFailed below drives a UI warning and the
// catch-up script (scripts/repair-result-appropriation.ts) posts the fix.
log.error('year-end: result appropriation omföring failed (non-fatal)', err as Error, {
@@ -798,6 +830,162 @@ export async function generateOpeningBalances(
return openingEntry
}
/**
* Open foreign-currency items as they stood on balansdagen, split by whether
* the ÅRL 4 kap. 13 § valuation can reach them at all. The two states have
* different remedies, so they are counted apart rather than summed.
*/
interface OpenFxAtBalansdagen {
/** Carries a usable exchange_rate: revaluable to the balansdagen rate. */
revaluable: number
/**
* No exchange_rate on file, so there is no original SEK value to revalue
* from. Counted rather than dropped, the same contract `unconverted_fx_count`
* carries on the reskontra reports (lib/reports/supplier-ledger.ts): an
* excluded row has to show up as a number somewhere.
*/
unconverted: number
}
/** The subset of an invoice row this check reads. */
interface FxLedgerRow {
id: string
status: string | null
currency: string | null
exchange_rate: number | string | null
total: number | string | null
paid_amount: number | string | null
remaining_amount: number | string | null
paid_at: string | null
}
/** A stored rate is usable only when present and strictly positive. */
function hasUsableFxRate(rate: number | string | null | undefined): boolean {
return rate != null && Number(rate) > 0
}
/**
* Count the foreign-currency receivables and payables that were still open on
* balansdagen.
*
* Measured AS OF balansdagen, not as of now. ÅRL 4 kap. 13 § values monetary
* items at the balance-sheet date, so an invoice settled in March of the
* following year was still an open FX item on 31 December and still had to be
* valued there; the live `status` column only says what is open today. For a
* historical balansdagen the population is therefore widened to invoices dated
* on or before it (including ones settled since) and the outstanding amount is
* recomputed from the payment history, the same reconstruction the reskontra
* reports use (lib/reports/ar-ledger.ts, lib/reports/supplier-ledger.ts). For
* today or a future balansdagen the stored open-invoice state IS the as-of
* state, so no reconstruction is attempted.
*
* Limits, inherited from `outstandingAsOf` and identical to what the reskontra
* reports already accept: an invoice with no payment rows and no `paid_at`
* cannot be dated, so its live outstanding is assumed to have stood at
* balansdagen; and an invoice cancelled or credited since is treated as never
* having been open, because neither event carries a reliable date. The status
* population is kept identical to what the revaluation engine actually acts on
* (`getOpenForeignCurrencyReceivables` / `...Payables`), so the warning never
* points at rows for which no remedy exists.
*/
async function countOpenFxItemsAtBalansdagen(
supabase: SupabaseClient,
companyId: string,
balansdagen: string
): Promise<OpenFxAtBalansdagen> {
const isHistorical = balansdagen < todayIsoDate()
const columns =
'id, status, currency, exchange_rate, total, paid_amount, remaining_amount, paid_at'
const receivables = await fetchAllRows<FxLedgerRow>(
({ from, to }) => {
const base = supabase
.from('invoices')
.select(columns)
.eq('company_id', companyId)
.neq('currency', 'SEK')
// 'partially_paid' is a live status for customer invoices (payment-sync
// sets it on partial settlements); the unpaid remainder was open on
// balansdagen just like a 'sent' invoice. Kept in lockstep with
// getOpenForeignCurrencyReceivables so this warning never points at
// rows the revaluation engine cannot see.
const scoped = isHistorical
? base
.in('status', ['sent', 'overdue', 'partially_paid', 'paid'])
.lte('invoice_date', balansdagen)
: base.in('status', ['sent', 'overdue', 'partially_paid'])
// Stable total order for correct paging (see fetch-all.ts).
return scoped.order('id', { ascending: true }).range(from, to)
},
{ dedupeBy: (r) => r.id }
)
const payables = await fetchAllRows<FxLedgerRow>(
({ from, to }) => {
const base = supabase
.from('supplier_invoices')
.select(columns)
.eq('company_id', companyId)
.neq('currency', 'SEK')
const scoped = isHistorical
? base
.in('status', ['registered', 'approved', 'overdue', 'partially_paid', 'paid'])
.lte('invoice_date', balansdagen)
: base.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
return scoped.order('id', { ascending: true }).range(from, to)
},
{ dedupeBy: (r) => r.id }
)
// Payment history is only needed to walk a since-settled invoice back to what
// it owed on balansdagen, and only when there is something to walk back.
const receivablePayments =
isHistorical && receivables.length > 0
? await fetchPaymentsAsOf(supabase, 'invoice_payments', 'invoice_id', companyId, balansdagen)
: null
const payablePayments =
isHistorical && payables.length > 0
? await fetchPaymentsAsOf(
supabase,
'supplier_invoice_payments',
'supplier_invoice_id',
companyId,
balansdagen
)
: null
const counts: OpenFxAtBalansdagen = { revaluable: 0, unconverted: 0 }
function tally(
rows: FxLedgerRow[],
payments: PaymentsAsOf | null,
liveOutstanding: (row: FxLedgerRow) => number
): void {
for (const row of rows) {
const total = Number(row.total) || 0
const live = liveOutstanding(row)
const outstanding = payments
? outstandingAsOf(row, total, live, payments, balansdagen)
: live
// Settled on or before balansdagen: nothing was open to value.
if (outstanding <= 0) continue
if (hasUsableFxRate(row.exchange_rate)) counts.revaluable += 1
else counts.unconverted += 1
}
}
// Each side uses its own reskontra's definition of outstanding so these
// counts reconcile with what the user sees there: kundreskontran derives it
// from paid_amount, leverantörsreskontran reads the maintained
// remaining_amount.
tally(receivables, receivablePayments, (r) =>
roundOre((Number(r.total) || 0) - (Number(r.paid_amount) || 0))
)
tally(payables, payablePayments, (r) => Number(r.remaining_amount) || 0)
return counts
}
/**
* Best-effort reversal used by executeYearEndClosing's rollback paths.
*
@@ -55,8 +55,16 @@ vi.mock('@/lib/auth/api-keys', () => ({
import {
uploadDocument,
createNewVersion,
deleteDocument,
verifyIntegrity,
validateDocumentMagicBytes,
buildDocumentStoragePath,
isCompanyScopedDocumentPath,
companyScopedDocumentPath,
legacyDocumentPath,
documentStoragePathCandidates,
downloadDocumentObject,
createDocumentSignedUrl,
_resetBucketVerified,
} from '../document-service'
@@ -257,6 +265,51 @@ describe('uploadDocument', () => {
})
)
})
it('writes to the company-scoped key, not the legacy uploader-scoped key', async () => {
results = [{ data: makeDocumentAttachment({ id: 'doc-1' }), error: null }]
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
const supabase = makeClient({ upload })
await uploadDocument(supabase as never, 'user-1', 'company-1', {
name: 'kvitto.pdf',
buffer: pdfBuffer(),
type: 'application/pdf',
})
const key = upload.mock.calls[0]![0] as string
expect(key).toMatch(/^documents\/company-1\/user-1\/\d+_kvitto\.pdf$/)
// The legacy layout put userId directly under `documents/`, which left
// company_id out of the RLS-visible path entirely.
expect(key).not.toMatch(/^documents\/user-1\//)
})
it('cleans up the uploaded object via the service-role client when the record insert fails', async () => {
// The documents bucket is WORM (no DELETE policy): a caller-bound
// remove() is silently blocked by RLS, so the failed-upload cleanup must
// run on the service-role client or the object is orphaned forever.
results = [{ data: null, error: { message: 'insert exploded' } }]
const serviceRemove = vi.fn().mockResolvedValue({ data: [], error: null })
serviceClientOverride = makeClient({ remove: serviceRemove })
const callerRemove = vi.fn().mockResolvedValue({ data: [], error: null })
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
const supabase = makeClient({ upload, remove: callerRemove })
await expect(
uploadDocument(supabase as never, 'user-1', 'company-1', {
name: 'kvitto.pdf',
buffer: pdfBuffer(),
type: 'application/pdf',
}),
).rejects.toThrow(/Failed to create document record/)
const uploadedKey = upload.mock.calls[0]![0] as string
expect(serviceRemove).toHaveBeenCalledWith([uploadedKey])
expect(callerRemove).not.toHaveBeenCalled()
})
})
describe('createNewVersion', () => {
@@ -275,8 +328,9 @@ describe('createNewVersion', () => {
})
results = [
{ data: current, error: null }, // fetch current
{ data: newVersion, error: null }, // insert new version
{ data: { company_id: 'company-1' }, error: null }, // resolve owning company
{ data: current, error: null }, // create_document_version RPC
{ data: newVersion, error: null }, // fetch new version row
]
const supabase = makeClient()
@@ -290,6 +344,279 @@ describe('createNewVersion', () => {
expect(result.original_id).toBe('doc-1')
expect(result.is_current_version).toBe(true)
})
it('writes the new version under the ORIGINAL document company prefix', async () => {
results = [
{ data: { company_id: 'company-1' }, error: null },
{ data: 'doc-2', error: null },
{ data: makeDocumentAttachment({ id: 'doc-2', version: 2 }), error: null },
]
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
const supabase = makeClient({ upload })
await createNewVersion(supabase as never, 'user-1', 'doc-1', {
name: 'test-v2.pdf',
buffer: pdfBuffer('new content'),
type: 'application/pdf',
})
expect(upload.mock.calls[0]![0]).toMatch(
/^documents\/company-1\/user-1\/\d+_test-v2\.pdf$/,
)
})
it('refuses to upload when the original document cannot be resolved', async () => {
results = [{ data: null, error: null }]
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
const supabase = makeClient({ upload })
await expect(
createNewVersion(supabase as never, 'user-1', 'doc-missing', {
name: 'test-v2.pdf',
buffer: pdfBuffer('new content'),
type: 'application/pdf',
}),
).rejects.toThrow(/original document not found/)
expect(upload).not.toHaveBeenCalled()
})
it('cleans up the uploaded object via the service-role client when the version RPC fails', async () => {
results = [
{ data: { company_id: 'company-1' }, error: null }, // resolve owning company
{ data: null, error: { message: 'rpc exploded' } }, // create_document_version RPC
]
const serviceRemove = vi.fn().mockResolvedValue({ data: [], error: null })
serviceClientOverride = makeClient({ remove: serviceRemove })
const callerRemove = vi.fn().mockResolvedValue({ data: [], error: null })
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
const supabase = makeClient({ upload, remove: callerRemove })
await expect(
createNewVersion(supabase as never, 'user-1', 'doc-1', {
name: 'test-v2.pdf',
buffer: pdfBuffer('new content'),
type: 'application/pdf',
}),
).rejects.toThrow(/Failed to create new version: rpc exploded/)
// WORM bucket: the cleanup must go through the service-role client, not
// the caller-bound client (whose remove() RLS silently blocks).
const uploadedKey = upload.mock.calls[0]![0] as string
expect(serviceRemove).toHaveBeenCalledWith([uploadedKey])
expect(callerRemove).not.toHaveBeenCalled()
})
})
describe('storage key layout helpers', () => {
const company = '11111111-1111-4111-8111-111111111111'
const user = '22222222-2222-4222-8222-222222222222'
it('builds a company-scoped key and sanitizes the filename', () => {
// sanitizeFileName replaces non-ASCII with '_', collapses runs, and trims
// leading/trailing underscores: "Kvitto å ä ö" ends up as "Kvitto".
expect(buildDocumentStoragePath(company, user, 'Kvitto å ä ö.pdf', 1700000000000)).toBe(
`documents/${company}/${user}/1700000000000_Kvitto.pdf`,
)
expect(buildDocumentStoragePath(company, user, 'faktura 2026-05.pdf', 1700000000000)).toBe(
`documents/${company}/${user}/1700000000000_faktura_2026-05.pdf`,
)
})
it('recognises company-scoped keys', () => {
expect(isCompanyScopedDocumentPath(`documents/${company}/${user}/1_a.pdf`, company)).toBe(true)
expect(isCompanyScopedDocumentPath(`documents/${user}/1_a.pdf`, company)).toBe(false)
})
it('translates legacy to company-scoped and back', () => {
const legacy = `documents/${user}/1_a.pdf`
const scoped = `documents/${company}/${user}/1_a.pdf`
expect(companyScopedDocumentPath(legacy, company)).toBe(scoped)
expect(legacyDocumentPath(scoped, company)).toBe(legacy)
// Already in the target layout: no translation offered.
expect(companyScopedDocumentPath(scoped, company)).toBeNull()
expect(legacyDocumentPath(legacy, company)).toBeNull()
})
it('offers no alternate for keys outside the documents/ root', () => {
// The MCP audit-package tool writes `{userId}/audit-packages/...`, which
// is not a document_attachments key and must never be rewritten.
const foreign = `${user}/audit-packages/1_archive.zip`
expect(companyScopedDocumentPath(foreign, company)).toBeNull()
expect(documentStoragePathCandidates(foreign, company)).toEqual([foreign])
})
it('lists the stored pointer first, then the alternate layout', () => {
const legacy = `documents/${user}/1_a.pdf`
const scoped = `documents/${company}/${user}/1_a.pdf`
expect(documentStoragePathCandidates(legacy, company)).toEqual([legacy, scoped])
expect(documentStoragePathCandidates(scoped, company)).toEqual([scoped, legacy])
// No companyId: nothing to derive, stored pointer only.
expect(documentStoragePathCandidates(legacy, null)).toEqual([legacy])
})
})
describe('dual-layout read helpers', () => {
const company = 'company-1'
const user = 'user-1'
const legacy = `documents/${user}/1_a.pdf`
const scoped = `documents/${company}/${user}/1_a.pdf`
it('downloadDocumentObject falls back to the company-scoped key', async () => {
const download = vi.fn(async (path: string) =>
path === scoped
? { data: new Blob(['ok']), error: null }
: { data: null, error: { message: 'Object not found' } },
)
const supabase = makeClient({ download })
const result = await downloadDocumentObject(supabase as never, legacy, company)
expect(result.blob).not.toBeNull()
expect(result.resolvedPath).toBe(scoped)
expect(download).toHaveBeenCalledTimes(2)
expect(download.mock.calls[0]![0]).toBe(legacy)
})
it('downloadDocumentObject falls back to the legacy key', async () => {
const download = vi.fn(async (path: string) =>
path === legacy
? { data: new Blob(['ok']), error: null }
: { data: null, error: { message: 'Object not found' } },
)
const supabase = makeClient({ download })
const result = await downloadDocumentObject(supabase as never, scoped, company)
expect(result.resolvedPath).toBe(legacy)
})
it('downloadDocumentObject reports the stored-pointer error when both fail', async () => {
const download = vi.fn(async (path: string) => ({
data: null,
error: { message: `missing:${path}` },
}))
const supabase = makeClient({ download })
const result = await downloadDocumentObject(supabase as never, legacy, company)
expect(result.blob).toBeNull()
expect(result.resolvedPath).toBeNull()
expect(result.error?.message).toBe(`missing:${legacy}`)
})
it('createDocumentSignedUrl falls back to the alternate layout', async () => {
const createSignedUrl = vi.fn(async (path: string) =>
path === scoped
? { data: { signedUrl: 'https://example.com/signed' }, error: null }
: { data: null, error: { message: 'Object not found' } },
)
const supabase = makeClient({ createSignedUrl })
const result = await createDocumentSignedUrl(supabase as never, legacy, company, 3600)
expect(result.signedUrl).toBe('https://example.com/signed')
expect(result.resolvedPath).toBe(scoped)
})
})
describe('deleteDocument', () => {
it('removes BOTH key layouts via the service-role client so no readable orphan copy survives', async () => {
const company = 'company-1'
const legacy = 'documents/user-1/1_a.pdf'
results = [
{
data: {
id: 'doc-1',
file_name: 'a.pdf',
storage_path: legacy,
journal_entry_id: null,
user_id: 'user-1',
},
error: null,
},
{ data: null, error: null }, // delete
]
const serviceRemove = vi.fn().mockResolvedValue({ data: [], error: null })
serviceClientOverride = makeClient({ remove: serviceRemove })
const callerRemove = vi.fn().mockResolvedValue({ data: [], error: null })
const supabase = makeClient({ remove: callerRemove })
const result = await deleteDocument(supabase as never, company, 'doc-1')
expect(result.ok).toBe(true)
expect(serviceRemove).toHaveBeenCalledWith([legacy, `documents/${company}/user-1/1_a.pdf`])
// The documents bucket is WORM (no DELETE policy on storage.objects): a
// caller-bound remove() is silently blocked by RLS and reports success
// without deleting, so it must never be used for the removal.
expect(callerRemove).not.toHaveBeenCalled()
})
it('does not touch storage when the company-filtered fetch finds nothing (authz-first)', async () => {
results = [{ data: null, error: null }]
const serviceRemove = vi.fn().mockResolvedValue({ data: [], error: null })
serviceClientOverride = makeClient({ remove: serviceRemove })
const result = await deleteDocument(makeClient() as never, 'company-1', 'doc-1')
expect(result).toMatchObject({ ok: false, reason: 'not_found', status: 404 })
expect(serviceRemove).not.toHaveBeenCalled()
})
it('refuses to delete a document linked to a journal entry (BFL 7 kap 2§)', async () => {
results = [
{
data: {
id: 'doc-1',
file_name: 'a.pdf',
storage_path: 'documents/user-1/1_a.pdf',
journal_entry_id: 'entry-1',
user_id: 'user-1',
},
error: null,
},
]
const serviceRemove = vi.fn().mockResolvedValue({ data: [], error: null })
serviceClientOverride = makeClient({ remove: serviceRemove })
const callerRemove = vi.fn().mockResolvedValue({ data: [], error: null })
const supabase = makeClient({ remove: callerRemove })
const result = await deleteDocument(supabase as never, 'company-1', 'doc-1')
expect(result).toMatchObject({ ok: false, reason: 'linked_to_entry', status: 409 })
expect(callerRemove).not.toHaveBeenCalled()
expect(serviceRemove).not.toHaveBeenCalled()
})
it('keeps the storage objects when the DB row delete is blocked by the trigger', async () => {
results = [
{
data: {
id: 'doc-1',
file_name: 'a.pdf',
storage_path: 'documents/user-1/1_a.pdf',
journal_entry_id: null,
user_id: 'user-1',
},
error: null,
},
{ data: null, error: { message: 'blocked by Bokföringslagen retention trigger' } },
]
const serviceRemove = vi.fn().mockResolvedValue({ data: [], error: null })
serviceClientOverride = makeClient({ remove: serviceRemove })
const result = await deleteDocument(makeClient() as never, 'company-1', 'doc-1')
expect(result).toMatchObject({ ok: false, reason: 'linked_to_entry', status: 409 })
expect(serviceRemove).not.toHaveBeenCalled()
})
})
describe('verifyIntegrity', () => {
+200 -14
View File
@@ -32,6 +32,141 @@ function sanitizeFileName(name: string): string {
return sanitizedBase + sanitizedExt
}
/**
* Storage key layout for the `documents` bucket.
*
* NEW (company-scoped, written since 20260726092000):
* documents/{companyId}/{userId}/{timestamp}_{filename}
*
* LEGACY (uploader-scoped, written before that migration):
* documents/{userId}/{timestamp}_{filename}
*
* The legacy layout carried no company_id, so the storage RLS policy could
* only scope on auth.uid(): an ex-member kept direct Storage access to every
* document they had uploaded even after their company_members row was
* deleted. The company-scoped layout lets the policy check
* public.user_company_ids() instead.
*
* Both layouts coexist until the Phase B backfill
* (scripts/backfill-document-storage-paths.ts) has re-homed every legacy
* object. Read paths must therefore tolerate both: use
* documentStoragePathCandidates() (or the downloadDocumentObject /
* createDocumentSignedUrl helpers below) rather than trusting the stored
* pointer to be the only key that resolves.
*/
export const DOCUMENTS_BUCKET = 'documents'
const DOCUMENTS_PATH_ROOT = 'documents'
/** Build a company-scoped storage key for a new upload. */
export function buildDocumentStoragePath(
companyId: string,
userId: string,
fileName: string,
timestamp: number = Date.now()
): string {
return `${DOCUMENTS_PATH_ROOT}/${companyId}/${userId}/${timestamp}_${sanitizeFileName(fileName)}`
}
/** True when the key already sits under the company-scoped prefix. */
export function isCompanyScopedDocumentPath(storagePath: string, companyId: string): boolean {
return storagePath.startsWith(`${DOCUMENTS_PATH_ROOT}/${companyId}/`)
}
/**
* Translate a legacy `documents/{userId}/...` key into its company-scoped
* equivalent. Returns null when the key is not in the legacy layout (already
* company-scoped, or one of the non-document shapes the bucket also holds,
* e.g. the MCP audit-package `{userId}/audit-packages/...` keys).
*/
export function companyScopedDocumentPath(
storagePath: string,
companyId: string
): string | null {
if (isCompanyScopedDocumentPath(storagePath, companyId)) return null
const prefix = `${DOCUMENTS_PATH_ROOT}/`
if (!storagePath.startsWith(prefix)) return null
return `${prefix}${companyId}/${storagePath.slice(prefix.length)}`
}
/**
* Translate a company-scoped key back into its legacy `documents/{userId}/...`
* equivalent. Returns null when the key is not company-scoped.
*/
export function legacyDocumentPath(storagePath: string, companyId: string): string | null {
const prefix = `${DOCUMENTS_PATH_ROOT}/${companyId}/`
if (!storagePath.startsWith(prefix)) return null
return `${DOCUMENTS_PATH_ROOT}/${storagePath.slice(prefix.length)}`
}
/**
* Every key that could hold the bytes for a document row, most likely first.
* The stored pointer always wins; the alternate layout is the fallback for
* the window in which the Phase B backfill has moved (or not yet moved) an
* object relative to the DB pointer.
*/
export function documentStoragePathCandidates(
storagePath: string,
companyId: string | null | undefined
): string[] {
const candidates = [storagePath]
if (companyId) {
const alternate =
companyScopedDocumentPath(storagePath, companyId) ??
legacyDocumentPath(storagePath, companyId)
if (alternate && alternate !== storagePath) candidates.push(alternate)
}
return candidates
}
type StorageErrorLike = { message?: string } | null
/**
* Download a document object, tolerating both key layouts.
*
* Returns the resolved key alongside the blob so callers can log or repair
* a stale `document_attachments.storage_path` pointer. When every candidate
* fails, the FIRST error is returned: it refers to the stored pointer, which
* is the actionable one.
*/
export async function downloadDocumentObject(
supabase: SupabaseClient,
storagePath: string,
companyId: string | null | undefined
): Promise<{ blob: Blob | null; error: StorageErrorLike; resolvedPath: string | null }> {
let firstError: StorageErrorLike = null
for (const candidate of documentStoragePathCandidates(storagePath, companyId)) {
const { data, error } = await supabase.storage.from(DOCUMENTS_BUCKET).download(candidate)
if (!error && data) {
return { blob: data as Blob, error: null, resolvedPath: candidate }
}
firstError ??= (error as StorageErrorLike) ?? { message: 'download returned no data' }
}
return { blob: null, error: firstError, resolvedPath: null }
}
/**
* Create a signed URL for a document object, tolerating both key layouts.
* Same fallback contract as downloadDocumentObject().
*/
export async function createDocumentSignedUrl(
supabase: SupabaseClient,
storagePath: string,
companyId: string | null | undefined,
expiresInSeconds: number
): Promise<{ signedUrl: string | null; error: StorageErrorLike; resolvedPath: string | null }> {
let firstError: StorageErrorLike = null
for (const candidate of documentStoragePathCandidates(storagePath, companyId)) {
const { data, error } = await supabase.storage
.from(DOCUMENTS_BUCKET)
.createSignedUrl(candidate, expiresInSeconds)
if (!error && data?.signedUrl) {
return { signedUrl: data.signedUrl, error: null, resolvedPath: candidate }
}
firstError ??= (error as StorageErrorLike) ?? { message: 'createSignedUrl returned no data' }
}
return { signedUrl: null, error: firstError, resolvedPath: null }
}
export const MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 // 10 MB
export const ALLOWED_DOCUMENT_TYPES = [
'application/pdf',
@@ -220,9 +355,9 @@ export async function uploadDocument(
// Compute SHA-256 hash
const sha256Hash = await computeSHA256(file.buffer)
// Generate storage path
const timestamp = Date.now()
const storagePath = `documents/${userId}/${timestamp}_${sanitizeFileName(file.name)}`
// Company-scoped storage key: the tenant id must be IN the key so the
// storage RLS policy can revoke access when a membership is removed.
const storagePath = buildDocumentStoragePath(companyId, userId, file.name)
// Upload to Supabase Storage
const { error: uploadError } = await supabase.storage
@@ -259,8 +394,16 @@ export async function uploadDocument(
.single()
if (error) {
// Clean up uploaded file on record creation failure
await supabase.storage.from('documents').remove([storagePath])
// Clean up the just-uploaded object on record creation failure. The
// documents bucket is WORM by design: storage.objects has NO DELETE
// policy, so remove() on the caller's cookie-bound client is silently
// blocked by RLS (it reports success without deleting anything) and the
// object would linger as an orphan. Only the service role can actually
// remove it. Authorization: the key was built by this very call for the
// caller's own failed upload, and no DB row references it.
await createServiceClientNoCookies()
.storage.from(DOCUMENTS_BUCKET)
.remove([storagePath])
throw new Error(`Failed to create document record: ${error.message}`)
}
@@ -298,9 +441,27 @@ export async function createNewVersion(
// Compute SHA-256 hash
const sha256Hash = await computeSHA256(file.buffer)
// The new version must land under the SAME company prefix as the document
// it supersedes. The caller (POST /api/documents/:id/versions) does not
// pass a companyId, so resolve it from the original row: the read goes
// through the user-scoped client, so RLS already blocks a cross-tenant id,
// and create_document_version re-checks membership server-side.
const { data: original, error: originalError } = await supabase
.from('document_attachments')
.select('company_id')
.eq('id', originalId)
.maybeSingle()
if (originalError || !original?.company_id) {
throw new Error('Failed to create new version: original document not found')
}
// Upload new file to Storage
const timestamp = Date.now()
const storagePath = `documents/${userId}/${timestamp}_${sanitizeFileName(file.name)}`
const storagePath = buildDocumentStoragePath(
original.company_id as string,
userId,
file.name
)
const { error: uploadError } = await supabase.storage
.from('documents')
@@ -325,8 +486,15 @@ export async function createNewVersion(
})
if (rpcError) {
// Clean up uploaded file on RPC failure
await supabase.storage.from('documents').remove([storagePath])
// Clean up the uploaded file on RPC failure. Service-role client for the
// same reason as in uploadDocument: the WORM bucket has no DELETE policy,
// so a caller-bound remove() is silently blocked by RLS and the object
// would be orphaned. The original-document fetch above (user-scoped, RLS)
// plus the failed RPC are the authorization context; the key was created
// by this call and nothing references it.
await createServiceClientNoCookies()
.storage.from(DOCUMENTS_BUCKET)
.remove([storagePath])
throw new Error(`Failed to create new version: ${rpcError.message}`)
}
@@ -452,7 +620,21 @@ export async function deleteDocument(
}
if (doc.storage_path) {
await supabase.storage.from('documents').remove([doc.storage_path])
// Remove BOTH key layouts. During the Phase B backfill a document can
// briefly exist under the legacy and the company-scoped key at once;
// removing only the stored pointer would leave a readable orphan copy of
// a document the user asked to erase.
//
// The removal runs on the service-role client: the documents bucket is
// WORM by design (storage.objects has no DELETE policy), so the caller's
// cookie-bound client is silently blocked by RLS and remove() reports
// success while both objects survive, readable by every company member
// under the company-scoped SELECT policy. Authorization already happened
// above: the company-filtered row fetch plus the row delete that just
// succeeded (with block_document_deletion() as the DB-level backstop).
await createServiceClientNoCookies()
.storage.from(DOCUMENTS_BUCKET)
.remove(documentStoragePathCandidates(doc.storage_path, companyId))
}
await eventBus.emit({
@@ -491,11 +673,15 @@ export async function verifyIntegrity(
// Download via the service-role client: the storage SELECT policy only
// covers the uploader's own folder (documents/{uid}/...), so a caller-bound
// client cannot read colleague-uploaded files. The company-filtered row
// fetch above (RLS on document_attachments) is the authorization.
// fetch above (RLS on document_attachments) is the authorization. The
// helper tolerates the legacy and the company-scoped key layout while the
// Phase B backfill is in flight.
const serviceClient = createServiceClientNoCookies()
const { data: fileData, error: downloadError } = await serviceClient.storage
.from('documents')
.download(doc.storage_path)
const { blob: fileData, error: downloadError } = await downloadDocumentObject(
serviceClient,
doc.storage_path,
companyId
)
if (downloadError || !fileData) {
throw new Error(`Failed to download document: ${downloadError?.message}`)
@@ -1,111 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { makeTaxCode } from '@/tests/helpers'
// ============================================================
// Mock: separate client (no .then) from query builder (thenable)
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown; count?: number | null }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
}
}
import { getTaxCodeByCode, calculateMomsFromTaxCodes } from '../tax-code-service'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('getTaxCodeByCode', () => {
it('prefers user code over system code', async () => {
const userCode = makeTaxCode({
id: 'tc-user',
user_id: 'user-1',
code: 'MP1',
description: 'Custom 25% moms',
rate: 25,
})
results = [{ data: userCode, error: null }]
const supabase = makeClient()
const result = await getTaxCodeByCode(supabase as never, 'user-1', 'MP1')
expect(result).not.toBeNull()
expect(result!.user_id).toBe('user-1')
expect(result!.description).toBe('Custom 25% moms')
})
it('returns null when code does not exist', async () => {
results = [{ data: null, error: { code: 'PGRST116' } }]
const supabase = makeClient()
const result = await getTaxCodeByCode(supabase as never, 'user-1', 'NONEXISTENT')
expect(result).toBeNull()
})
})
describe('calculateMomsFromTaxCodes', () => {
it('aggregates correctly to moms boxes', async () => {
const mp1 = makeTaxCode({
code: 'MP1',
rate: 25,
moms_basis_boxes: ['05'],
moms_tax_boxes: ['10'],
moms_input_boxes: [],
is_output_vat: true,
})
const ip1 = makeTaxCode({
code: 'IP1',
rate: 25,
moms_basis_boxes: [],
moms_tax_boxes: [],
moms_input_boxes: ['48'],
is_output_vat: false,
})
const lines = [
{ tax_code: 'MP1', debit_amount: 0, credit_amount: 10000, journal_entry_id: 'je1', journal_entries: {} },
{ tax_code: 'MP1', debit_amount: 0, credit_amount: 5000, journal_entry_id: 'je2', journal_entries: {} },
{ tax_code: 'IP1', debit_amount: 2500, credit_amount: 0, journal_entry_id: 'je3', journal_entries: {} },
]
results = [
// 0: journal lines query (thenable: no .single())
{ data: lines, error: null },
// 1: getTaxCodes query (thenable: no .single())
{ data: [mp1, ip1], error: null },
]
const supabase = makeClient()
const result = await calculateMomsFromTaxCodes(supabase as never, 'user-1', '2024-01-01', '2024-12-31')
expect(result.length).toBeGreaterThan(0)
// Results should be sorted by box
for (let i = 1; i < result.length; i++) {
expect(result[i].box >= result[i - 1].box).toBe(true)
}
// Check that we have the expected boxes
const boxes = result.map((r) => r.box)
expect(boxes).toContain('05')
expect(boxes).toContain('10')
expect(boxes).toContain('48')
})
})
-165
View File
@@ -1,165 +0,0 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { TaxCode } from '@/types'
/**
* Tax Code Service
*
* Manages decoupled tax codes for momsdeklaration.
* Tax codes map journal entry lines to specific moms rutor (boxes)
* on the Swedish VAT declaration form.
*/
/**
* Get all active tax codes for a user (including system codes)
*/
export async function getTaxCodes(supabase: SupabaseClient, userId: string): Promise<TaxCode[]> {
const { data, error } = await supabase
.from('tax_codes')
.select('*')
.or(`company_id.eq.${userId},company_id.is.null`)
.order('code')
if (error) {
throw new Error(`Failed to fetch tax codes: ${error.message}`)
}
return (data as TaxCode[]) || []
}
/**
* Get a single tax code by code string
*/
export async function getTaxCodeByCode(
supabase: SupabaseClient,
userId: string,
code: string
): Promise<TaxCode | null> {
// Prefer user-specific code over system code
const { data, error } = await supabase
.from('tax_codes')
.select('*')
.eq('code', code)
.or(`company_id.eq.${userId},company_id.is.null`)
.order('user_id', { ascending: false, nullsFirst: false })
.limit(1)
.single()
if (error) {
return null
}
return data as TaxCode
}
/**
* Moms box result from tax code aggregation
*/
export interface MomsBoxResult {
/** Ruta number (e.g. '05', '10', '48') */
box: string
/** Sum of amounts for this box */
amount: number
}
/**
* Calculate momsdeklaration from journal entry lines grouped by tax_code,
* then mapped via the tax_codes table to moms boxes.
*
* This is the new, tax-code-driven approach that replaces the hardcoded
* category-based VAT calculation.
*/
export async function calculateMomsFromTaxCodes(
supabase: SupabaseClient,
userId: string,
periodStart: string,
periodEnd: string
): Promise<MomsBoxResult[]> {
// Fetch journal entry lines with tax_code in the period
const { data: lines, error: linesError } = await supabase
.from('journal_entry_lines')
.select(`
tax_code,
debit_amount,
credit_amount,
journal_entry_id,
journal_entries!inner (
user_id,
entry_date,
status,
fiscal_period_id
)
`)
.not('tax_code', 'is', null)
.eq('journal_entries.company_id', userId)
.eq('journal_entries.status', 'posted')
.gte('journal_entries.entry_date', periodStart)
.lte('journal_entries.entry_date', periodEnd)
if (linesError) {
throw new Error(`Failed to fetch journal lines: ${linesError.message}`)
}
// Fetch all tax codes for lookup
const taxCodes = await getTaxCodes(supabase, userId)
const taxCodeMap = new Map<string, TaxCode>()
for (const tc of taxCodes) {
// User codes take precedence over system codes
if (!taxCodeMap.has(tc.code) || tc.user_id) {
taxCodeMap.set(tc.code, tc)
}
}
// Aggregate amounts by moms box
const boxTotals = new Map<string, number>()
for (const line of lines || []) {
if (!line.tax_code) continue
const taxCode = taxCodeMap.get(line.tax_code)
if (!taxCode) continue
const netAmount = Number(line.debit_amount || 0) - Number(line.credit_amount || 0)
const absAmount = Math.abs(netAmount)
// For output VAT: debit_amount goes to basis boxes, tax amount to tax boxes
// For input VAT: the amount goes to input boxes
const allBoxes = [
...taxCode.moms_basis_boxes,
...taxCode.moms_tax_boxes,
...taxCode.moms_input_boxes,
]
for (const box of allBoxes) {
const current = boxTotals.get(box) || 0
boxTotals.set(box, current + absAmount)
}
}
// Convert to result array
const results: MomsBoxResult[] = []
for (const [box, amount] of boxTotals) {
results.push({
box,
amount: Math.round(amount * 100) / 100,
})
}
return results.sort((a, b) => a.box.localeCompare(b.box))
}
/**
* Seed tax codes for a user by calling the database function
*/
export async function seedTaxCodes(supabase: SupabaseClient, userId: string): Promise<void> {
const { error } = await supabase.rpc('seed_tax_codes_for_user', {
p_user_id: userId,
})
if (error) {
throw new Error(`Failed to seed tax codes: ${error.message}`)
}
}
@@ -0,0 +1,239 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
const mockFetchExchangeRate = vi.fn()
vi.mock('@/lib/currency/riksbanken', async () => {
const actual =
await vi.importActual<typeof import('@/lib/currency/riksbanken')>('@/lib/currency/riksbanken')
return { ...actual, fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args) }
})
import {
resolveSupplierInvoiceExchangeRate,
supplierInvoiceSekAmounts,
} from '@/lib/currency/supplier-invoice-rate'
// The resolver only ever hands the client through to fetchExchangeRate, so a
// sentinel is enough to prove it was passed (that is what turns the shared
// exchange_rates table into a read-through cache).
const supabase = { sentinel: true } as unknown as SupabaseClient
describe('resolveSupplierInvoiceExchangeRate', () => {
beforeEach(() => {
mockFetchExchangeRate.mockReset()
})
it('treats an omitted currency as SEK at rate 1 with no stored exchange_rate', async () => {
const result = await resolveSupplierInvoiceExchangeRate(supabase, {
invoiceDate: '2026-03-02',
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.rate).toEqual({
currency: 'SEK',
rate: 1,
exchangeRate: null,
exchangeRateDate: null,
source: 'sek',
})
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
})
it('ignores a rate supplied alongside SEK: 1 SEK is 1 SEK, not a kurs', async () => {
const result = await resolveSupplierInvoiceExchangeRate(supabase, {
currency: 'SEK',
invoiceDate: '2026-03-02',
suppliedRate: 11.5,
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.rate.rate).toBe(1)
expect(result.rate.exchangeRate).toBeNull()
})
it('trusts a positive caller-supplied rate on a foreign invoice', async () => {
const result = await resolveSupplierInvoiceExchangeRate(supabase, {
currency: 'EUR',
invoiceDate: '2026-03-02',
suppliedRate: 11.37,
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.rate.rate).toBe(11.37)
expect(result.rate.exchangeRate).toBe(11.37)
// We cannot vouch for the source of a hand-entered rate, so no
// observation date is claimed.
expect(result.rate.exchangeRateDate).toBeNull()
expect(result.rate.source).toBe('supplied')
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
})
it('fetches for the invoice date, passing the supabase client', async () => {
mockFetchExchangeRate.mockResolvedValue({ currency: 'USD', rate: 10.4, date: '2026-02-27' })
const result = await resolveSupplierInvoiceExchangeRate(supabase, {
currency: 'USD',
invoiceDate: '2026-03-02',
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.rate.rate).toBe(10.4)
expect(result.rate.exchangeRateDate).toBe('2026-02-27')
expect(result.rate.source).toBe('fetched')
const [currencyArg, dateArg, clientArg] = mockFetchExchangeRate.mock.calls[0]
expect(currencyArg).toBe('USD')
expect((dateArg as Date).toISOString().slice(0, 10)).toBe('2026-03-02')
expect(clientArg).toBe(supabase)
})
it('refuses when Riksbanken yields nothing rather than guessing a rate', async () => {
mockFetchExchangeRate.mockResolvedValue(null)
const result = await resolveSupplierInvoiceExchangeRate(supabase, {
currency: 'GBP',
invoiceDate: '2026-03-02',
})
expect(result).toEqual({ ok: false, currency: 'GBP', invoiceDate: '2026-03-02' })
})
it('refuses when the fetch throws instead of returning null', async () => {
mockFetchExchangeRate.mockRejectedValue(new Error('boom'))
const result = await resolveSupplierInvoiceExchangeRate(supabase, {
currency: 'NOK',
invoiceDate: '2026-03-02',
})
expect(result.ok).toBe(false)
})
it('refuses a currency with no Riksbanken series instead of storing it unconverted', async () => {
const result = await resolveSupplierInvoiceExchangeRate(supabase, {
currency: 'JPY',
invoiceDate: '2026-03-02',
})
expect(result.ok).toBe(false)
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
})
it('falls back to a supplied rate for an unsupported currency', async () => {
const result = await resolveSupplierInvoiceExchangeRate(supabase, {
currency: 'JPY',
invoiceDate: '2026-03-02',
suppliedRate: 0.072,
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.rate.rate).toBe(0.072)
})
it('refuses an implausibly large supplied rate instead of storing it', async () => {
// Same bound as lib/bookkeeping/invoice-payment-lines.ts and the
// match_batch_allocate RPC (0 < rate < 100000): a rate that far out is as
// unusable as NULL. Refused, not silently replaced by a fetched rate: the
// caller stated a rate and must get it bounced back for correction.
const result = await resolveSupplierInvoiceExchangeRate(supabase, {
currency: 'EUR',
invoiceDate: '2026-03-02',
suppliedRate: 100000,
})
expect(result).toEqual({ ok: false, currency: 'EUR', invoiceDate: '2026-03-02' })
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
})
it('still trusts a large supplied rate just below the plausibility bound', async () => {
const result = await resolveSupplierInvoiceExchangeRate(supabase, {
currency: 'EUR',
invoiceDate: '2026-03-02',
suppliedRate: 99999.9999,
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.rate.rate).toBe(99999.9999)
expect(result.rate.source).toBe('supplied')
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
})
it('rejects a non-positive supplied rate and falls through to the fetch', async () => {
mockFetchExchangeRate.mockResolvedValue({ currency: 'EUR', rate: 11.1, date: '2026-03-02' })
const result = await resolveSupplierInvoiceExchangeRate(supabase, {
currency: 'EUR',
invoiceDate: '2026-03-02',
suppliedRate: 0,
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.rate.rate).toBe(11.1)
expect(result.rate.source).toBe('fetched')
})
it('refuses a malformed invoice date without calling out to Riksbanken', async () => {
const result = await resolveSupplierInvoiceExchangeRate(supabase, {
currency: 'EUR',
invoiceDate: 'not-a-date',
})
expect(result.ok).toBe(false)
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
})
})
describe('supplierInvoiceSekAmounts', () => {
const sekRate = {
currency: 'SEK',
rate: 1,
exchangeRate: null,
exchangeRateDate: null,
source: 'sek' as const,
}
it('mirrors the invoice amounts for a SEK invoice', () => {
expect(
supplierInvoiceSekAmounts(sekRate, { subtotal: 1000, vatAmount: 250, total: 1250 }),
).toEqual({ subtotal_sek: 1000, vat_amount_sek: 250, total_sek: 1250 })
})
it('translates every column at the same rate', () => {
expect(
supplierInvoiceSekAmounts(
{
currency: 'EUR',
rate: 11.5,
exchangeRate: 11.5,
exchangeRateDate: '2026-03-02',
source: 'fetched',
},
{ subtotal: 1000, vatAmount: 250, total: 1250 },
),
).toEqual({ subtotal_sek: 11500, vat_amount_sek: 2875, total_sek: 14375 })
})
it('rounds to öre with roundOre, not the naive half-value form', () => {
// 0.1 * 10.05 = 1.0049999999999999 in IEEE-754; the naive
// Math.round(x*100)/100 drops it to 1.00.
const result = supplierInvoiceSekAmounts(
{
currency: 'USD',
rate: 10.05,
exchangeRate: 10.05,
exchangeRateDate: null,
source: 'supplied',
},
{ subtotal: 0.1, vatAmount: 0.1, total: 0.2 },
)
expect(result.subtotal_sek).toBe(1.01)
expect(result.total_sek).toBe(2.01)
})
})
+236
View File
@@ -0,0 +1,236 @@
/**
* Exchange-rate resolution for supplier-invoice WRITE paths.
*
* Every surface that creates a `supplier_invoices` row goes through here:
*
* - POST /api/supplier-invoices (cookie session)
* - POST /api/v1/companies/{id}/supplier-invoices (API key / agents)
* - POST /api/extensions/ext/invoice-inbox/items/{id}/convert (inbox)
*
* Sharing ONE resolver is what keeps them in agreement: the currency policy,
* the SEK column arithmetic and the "no rate" refusal are defined once, so a
* surface cannot drift into storing a NULL rate again.
*
* Why this exists
* ---------------
* All three sites used to take `body.exchange_rate ?? null` with no fetch and
* no validation, so a foreign-currency invoice was persisted with
* `exchange_rate = NULL` whenever the caller simply omitted the field (an
* agent, an MCP call, or the web form when its client-side rate lookup
* silently failed). Nothing repaired the row afterwards, and the booking path
* (lib/bookkeeping/supplier-invoice-entries.ts) now REFUSES such an invoice
* with SI_FX_RATE_MISSING rather than posting it at a fabricated 1:1 rate,
* which under omvänd skattskyldighet would understate the fiktiv moms on
* 2614/2645 and therefore rutorna 20-24 + 30-32 of the momsdeklaration.
*
* So the rate has to be resolved at CREATE time, where the user still has the
* invoice in front of them.
*
* Legal basis for the rate itself
* -------------------------------
* ML 8 kap 21-23 § allows two sources for translating a foreign-currency
* beskattningsunderlag to SEK: the Nasdaq OMX Stockholm mid-rate published by
* Riksbanken, or the latest published ECB rate; one source, used
* consistently. `fetchExchangeRate` reads Riksbanken's SWEA series, so we stay
* on the first of the two for every invoice.
*
* Rate date: we use the invoice date, which is also the date the registration
* verifikat is posted on and the date the period-lock check runs against, so
* the money and the verifikat can never be anchored on two different days.
* ML 8 kap 21 § points at the taxable event (leveransdatum) when that differs
* from the invoice date; `supplier_invoices.delivery_date` would be the
* stricter anchor in that case. Deliberately NOT used here: it would split the
* anchor from the verifikat date for a minority of rows. Flagged as an open
* refinement rather than changed silently.
*
* Failure semantics
* -----------------
* `fetchExchangeRate` already degrades gracefully (persistent `exchange_rates`
* cache first, 7-day look-back for weekends/holidays, then the most recent
* cached observation on or before the date) and returns null ONLY when there
* is no observation to be had. It never invents a number. When it returns
* null we report `ok: false` and the caller rejects the create: storing NULL
* would only relocate the same failure to the booking step, where the user no
* longer has the invoice in hand.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { roundOre } from '@/lib/money'
import type { Currency } from '@/types'
/** Currencies Riksbanken's SWEA series cover (mirrors lib/currency/riksbanken.ts). */
const SUPPORTED_CURRENCIES: ReadonlySet<string> = new Set([
'SEK',
'EUR',
'USD',
'GBP',
'NOK',
'DKK',
])
/**
* Upper plausibility bound for a caller-supplied rate. Same value as the
* module-local MAX_PLAUSIBLE_FX_RATE in lib/bookkeeping/invoice-payment-lines.ts
* (which mirrors the match_batch_allocate RPC's 0 < rate < 100000 guard): a
* rate that far out is as unusable as NULL, and accepting it here would store
* total_sek at an absurd multiple that the booking path then posts verbatim.
*/
const MAX_PLAUSIBLE_FX_RATE = 100000
export interface ResolvedSupplierInvoiceRate {
/** Normalised ISO code actually used ('SEK' when the caller omitted one). */
currency: string
/**
* Multiplier from invoice currency to SEK. Always 1 for a SEK invoice, so
* `*_sek` columns equal their invoice-currency counterparts instead of
* staying NULL.
*/
rate: number
/**
* Value for `supplier_invoices.exchange_rate`. NULL for a SEK invoice: the
* column means "rate this foreign invoice was translated at", and 1 SEK =
* 1 SEK is not a rate. Never NULL for a foreign invoice.
*/
exchangeRate: number | null
/**
* Value for `supplier_invoices.exchange_rate_date`: the observation date of
* the rate actually used, which is what makes the SEK amounts verifiable
* under BFL 5 kap. Null for SEK, and null for a caller-supplied rate whose
* source we cannot vouch for.
*/
exchangeRateDate: string | null
/** Where the rate came from. Diagnostics only; never affects the arithmetic. */
source: 'sek' | 'supplied' | 'fetched'
}
export type SupplierInvoiceRateResult =
| { ok: true; rate: ResolvedSupplierInvoiceRate }
| { ok: false; currency: string; invoiceDate: string }
export interface SupplierInvoiceRateInput {
/** `body.currency`; undefined/null means SEK (matches the column default). */
currency?: string | null
/** ISO `YYYY-MM-DD` invoice date; the rate is fetched for this day. */
invoiceDate: string
/** `body.exchange_rate` when the caller supplied one. */
suppliedRate?: number | null
}
/**
* Resolve the exchange rate for a supplier invoice about to be written.
*
* Order:
* 1. SEK invoice → rate 1, no stored exchange_rate.
* 2. Caller supplied a positive rate below MAX_PLAUSIBLE_FX_RATE → trust it
* (the web form pre-fills it from /api/currency/rate, and a user who read
* the rate off the invoice must be able to override us). A rate at or
* above the bound is refused, not silently replaced by a fetch.
* 3. Otherwise → fetch from Riksbanken for the invoice date, WITH
* the supabase client so the shared `exchange_rates` cache is consulted
* and populated. Same call shape as lib/transactions/ingest.ts.
* 4. Fetch produced nothing → `{ ok: false }`; the caller must refuse the
* create (SI_FX_RATE_MISSING).
*
* Never throws: a network failure inside `fetchExchangeRate` is already caught
* there and surfaces as null, and anything unexpected is folded into the same
* `ok: false` refusal rather than a 500.
*/
export async function resolveSupplierInvoiceExchangeRate(
supabase: SupabaseClient,
input: SupplierInvoiceRateInput,
): Promise<SupplierInvoiceRateResult> {
const currency = input.currency || 'SEK'
if (currency === 'SEK') {
return {
ok: true,
rate: {
currency,
rate: 1,
exchangeRate: null,
exchangeRateDate: null,
source: 'sek',
},
}
}
const supplied = input.suppliedRate
if (supplied != null && Number.isFinite(supplied) && supplied > 0) {
// Implausibly large rates are refused outright rather than silently
// replaced by a fetched one: the caller explicitly stated a rate, so a
// fat-fingered 100000+ must bounce back for correction, not be swapped
// for a number the user never saw. Non-positive/absent values keep the
// existing fall-through-to-fetch behaviour (the web form's empty field).
if (supplied >= MAX_PLAUSIBLE_FX_RATE) {
return { ok: false, currency, invoiceDate: input.invoiceDate }
}
return {
ok: true,
rate: {
currency,
rate: supplied,
exchangeRate: supplied,
exchangeRateDate: null,
source: 'supplied',
},
}
}
// Unknown ISO code: no Riksbanken series exists, so there is nothing to
// fetch. Refuse rather than let the row through unconverted.
if (!SUPPORTED_CURRENCIES.has(currency)) {
return { ok: false, currency, invoiceDate: input.invoiceDate }
}
const parsedDate = new Date(input.invoiceDate)
if (Number.isNaN(parsedDate.getTime())) {
return { ok: false, currency, invoiceDate: input.invoiceDate }
}
let fetched: Awaited<ReturnType<typeof fetchExchangeRate>> = null
try {
fetched = await fetchExchangeRate(currency as Currency, parsedDate, supabase)
} catch {
// fetchExchangeRate swallows its own network errors, but a caller-supplied
// mock or a future refactor could throw: treat it as "no rate", never as
// a reason to guess one.
fetched = null
}
if (!fetched || !Number.isFinite(fetched.rate) || fetched.rate <= 0) {
return { ok: false, currency, invoiceDate: input.invoiceDate }
}
return {
ok: true,
rate: {
currency,
rate: fetched.rate,
exchangeRate: fetched.rate,
exchangeRateDate: fetched.date ?? null,
source: 'fetched',
},
}
}
/**
* The three SEK columns on `supplier_invoices`, computed from one resolved
* rate so every write path produces identical numbers.
*
* A SEK invoice resolves to rate 1, so `total_sek === total`. That is the
* whole of the second fix: the old `exchangeRate ? … : null` guard left
* `total_sek` NULL for every ordinary Swedish supplier invoice, because a SEK
* invoice legitimately has no exchange rate. Readers that report in SEK (the
* KPI "Största leverantörer" panel among them) then saw nothing at all.
*/
export function supplierInvoiceSekAmounts(
rate: ResolvedSupplierInvoiceRate,
amounts: { subtotal: number; vatAmount: number; total: number },
): { subtotal_sek: number; vat_amount_sek: number; total_sek: number } {
return {
subtotal_sek: roundOre(amounts.subtotal * rate.rate),
vat_amount_sek: roundOre(amounts.vatAmount * rate.rate),
total_sek: roundOre(amounts.total * rate.rate),
}
}
@@ -0,0 +1,120 @@
/**
* Tests for lib/customers/protect-personal-number.ts.
*
* customers.personal_number holds AES-256-GCM ciphertext (migration
* 20260726110000), and every customer read surface (list, detail, export)
* funnels rows through maskStoredCustomerPersonalNumber / maskCustomerRow.
* The load-bearing properties:
*
* 1. No caller ever receives the full personnummer or the raw ciphertext:
* only the '********-1234' display mask.
* 2. The helpers NEVER throw. A single corrupted/foreign-key ciphertext row
* used to propagate ERR_CRYPTO out of maskCustomerRow, which would 500
* the entire GET /api/customers list for the company. A decrypt failure
* must degrade to a placeholder mask for that one row instead.
*/
import { describe, it, expect } from 'vitest'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
import {
encryptCustomerPersonalNumber,
maskCustomerRow,
maskStoredCustomerPersonalNumber,
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
} from '../protect-personal-number'
// Synthetic personnummer, never a real one.
const PERSONAL_NUMBER = '19900101-1234'
const MASKED = '********-1234'
// Hex of the right shape for the ciphertext CHECK (76-255 lowercase hex) that
// is NOT a valid ciphertext: the GCM auth tag can never verify.
const GARBAGE_HEX = 'ab'.repeat(40)
describe('maskStoredCustomerPersonalNumber', () => {
it('decrypts stored ciphertext and returns only the display mask', () => {
const stored = encryptPersonnummer(PERSONAL_NUMBER)
expect(maskStoredCustomerPersonalNumber(stored)).toBe(MASKED)
})
it('masks a legacy plaintext value without attempting a decrypt', () => {
expect(maskStoredCustomerPersonalNumber('19900101-1234')).toBe(MASKED)
expect(maskStoredCustomerPersonalNumber('900101-1234')).toBe(MASKED)
expect(maskStoredCustomerPersonalNumber('9001011234')).toBe(MASKED)
})
it('returns null for empty values', () => {
expect(maskStoredCustomerPersonalNumber(null)).toBeNull()
expect(maskStoredCustomerPersonalNumber(undefined)).toBeNull()
expect(maskStoredCustomerPersonalNumber('')).toBeNull()
})
it('returns the placeholder mask instead of throwing on undecryptable input', () => {
// Garbage that passes the DB ciphertext CHECK shape but fails GCM auth.
expect(maskStoredCustomerPersonalNumber(GARBAGE_HEX)).toBe(
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
)
// Real ciphertext tampered with (auth tag mismatch).
const stored = encryptPersonnummer(PERSONAL_NUMBER)
const tampered = (stored[0] === 'a' ? 'b' : 'a') + stored.slice(1)
expect(maskStoredCustomerPersonalNumber(tampered)).toBe(
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
)
// Not even hex-shaped.
expect(maskStoredCustomerPersonalNumber('not-a-ciphertext')).toBe(
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
)
})
it('placeholder mask carries no digits and cannot pass personnummer validation', () => {
// It must never read as a real suffix, and a client that blindly PATCHes
// it back must fail plaintext validation rather than store or clear
// anything (null here would let a round-trip DELETE the stored value).
expect(UNDECRYPTABLE_PERSONAL_NUMBER_MASK).not.toMatch(/\d/)
expect(UNDECRYPTABLE_PERSONAL_NUMBER_MASK).not.toMatch(/^(\d{6}|\d{8})[-+]?\d{4}$/)
})
})
describe('maskCustomerRow', () => {
it('replaces the stored value with the mask and leaves other fields alone', () => {
const stored = encryptPersonnummer(PERSONAL_NUMBER)
const row = { id: 'c1', name: 'Anna Andersson', personal_number: stored }
expect(maskCustomerRow(row)).toEqual({
id: 'c1',
name: 'Anna Andersson',
personal_number: MASKED,
})
})
it('never throws on a corrupt row: the list endpoint must stay 200', () => {
// GET /api/customers maps every row through maskCustomerRow; one bad row
// must not take down the whole roster.
const row = { id: 'c1', name: 'Anna Andersson', personal_number: GARBAGE_HEX }
expect(() => maskCustomerRow(row)).not.toThrow()
expect(maskCustomerRow(row).personal_number).toBe(UNDECRYPTABLE_PERSONAL_NUMBER_MASK)
})
it('passes through rows without a personal number', () => {
const withNull: { id: string; personal_number: string | null } = {
id: 'c1',
personal_number: null,
}
const withoutKey: { id: string; personal_number?: string | null } = { id: 'c1' }
expect(maskCustomerRow(withNull).personal_number).toBeNull()
expect(maskCustomerRow(withoutKey).personal_number).toBeNull()
})
})
describe('encryptCustomerPersonalNumber', () => {
it('round-trips through the mask helper', () => {
const stored = encryptCustomerPersonalNumber(PERSONAL_NUMBER)
expect(stored).not.toBeNull()
expect(stored).not.toBe(PERSONAL_NUMBER)
expect(maskStoredCustomerPersonalNumber(stored)).toBe(MASKED)
})
it('maps empty input to null', () => {
expect(encryptCustomerPersonalNumber(null)).toBeNull()
expect(encryptCustomerPersonalNumber(undefined)).toBeNull()
expect(encryptCustomerPersonalNumber('')).toBeNull()
})
})
+34 -1
View File
@@ -1,16 +1,49 @@
import { createLogger } from '@/lib/logger'
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
import { maskCustomerPersonalNumber } from '@/lib/customers/mask-personal-number'
const log = createLogger('customers/protect-personal-number')
/**
* Placeholder returned when a stored personal_number cannot be decrypted
* (corrupted ciphertext, a value written under a different
* PERSONNUMMER_ENCRYPTION_KEY, or pre-encryption garbage on a self-hosted DB).
*
* Shape rationale: it must be recognizably a mask (never mistakable for a real
* suffix, so no fabricated digits) and it must NOT be null. Returning null
* would render as "no personnummer", and worse: a client that reads the
* customer and PATCHes the whole object back would send personal_number: null,
* which the update route treats as "clear the column", destroying the stored
* ciphertext. The placeholder fails the route's plaintext validation instead,
* so a blind round-trip errors loudly rather than deleting data.
*/
export const UNDECRYPTABLE_PERSONAL_NUMBER_MASK = '********-????'
export function encryptCustomerPersonalNumber(value: string | null | undefined): string | null {
return value ? encryptPersonnummer(value) : null
}
/**
* Decrypt-and-mask a stored customers.personal_number for display.
*
* Never throws: every customer read surface (list, detail, export) maps rows
* through this, so a single row with an undecryptable value must not 500 the
* whole endpoint. On decrypt failure it logs at error level (the value itself
* is never logged) and returns UNDECRYPTABLE_PERSONAL_NUMBER_MASK.
*/
export function maskStoredCustomerPersonalNumber(value: string | null | undefined): string | null {
if (!value) return null
if (/^(\d{6}|\d{8})[-+]?\d{4}$/.test(value)) {
return maskCustomerPersonalNumber(value)
}
return maskCustomerPersonalNumber(decryptPersonnummer(value))
try {
return maskCustomerPersonalNumber(decryptPersonnummer(value))
} catch (err) {
log.error('customer personal_number decrypt failed; returning placeholder mask', {
reason: err instanceof Error ? err.message : String(err),
})
return UNDECRYPTABLE_PERSONAL_NUMBER_MASK
}
}
export function maskCustomerRow<T extends { personal_number?: string | null }>(row: T): T {
@@ -0,0 +1,192 @@
import { describe, it, expect, vi } from 'vitest'
import {
linkDocuments,
formatFailedDocumentNames,
type DocumentLinkTarget,
} from '../link-documents'
function okResponse(): Response {
return new Response(JSON.stringify({ data: { id: 'doc' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
function errorResponse(status: number, code?: string, message?: string): Response {
return new Response(
JSON.stringify(code ? { error: { code, message } } : {}),
{ status, headers: { 'Content-Type': 'application/json' } },
)
}
const targets: DocumentLinkTarget[] = [
{ documentId: 'doc-1', fileName: 'kvitto-1.pdf' },
{ documentId: 'doc-2', fileName: 'kvitto-2.pdf' },
{ documentId: 'doc-3', fileName: 'kvitto-3.pdf' },
]
describe('linkDocuments', () => {
it('links every document when all calls succeed', async () => {
const fetchImpl = vi.fn().mockResolvedValue(okResponse())
const result = await linkDocuments(targets, 'entry-1', { fetchImpl })
expect(result.linked).toEqual(['doc-1', 'doc-2', 'doc-3'])
expect(result.failed).toEqual([])
expect(result.allLinked).toBe(true)
expect(result.firstLinkedId).toBe('doc-1')
expect(fetchImpl).toHaveBeenCalledTimes(3)
})
it('POSTs to the link endpoint with the journal entry id', async () => {
const fetchImpl = vi.fn().mockResolvedValue(okResponse())
await linkDocuments([{ documentId: 'doc-1', fileName: 'a.pdf' }], 'entry-9', { fetchImpl })
const [url, init] = fetchImpl.mock.calls[0]
expect(url).toBe('/api/documents/doc-1/link')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body)).toEqual({ journal_entry_id: 'entry-9' })
})
it('passes inbox_item_id and transaction_id through only when set', async () => {
const fetchImpl = vi.fn().mockResolvedValue(okResponse())
await linkDocuments(
[{ documentId: 'doc-1', inboxItemId: 'inbox-1', transactionId: 'tx-1' }],
'entry-1',
{ fetchImpl },
)
expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({
journal_entry_id: 'entry-1',
inbox_item_id: 'inbox-1',
transaction_id: 'tx-1',
})
})
it('reports the specific documents that failed when some succeed', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(okResponse())
.mockResolvedValueOnce(errorResponse(500, 'DOC_LINK_FAILED', 'Kunde inte bifoga underlag'))
.mockResolvedValueOnce(okResponse())
const result = await linkDocuments(targets, 'entry-1', { fetchImpl })
expect(result.linked).toEqual(['doc-1', 'doc-3'])
expect(result.allLinked).toBe(false)
expect(result.failed).toHaveLength(1)
expect(result.failed[0]).toEqual({
documentId: 'doc-2',
fileName: 'kvitto-2.pdf',
status: 500,
code: 'DOC_LINK_FAILED',
reason: 'Kunde inte bifoga underlag',
})
// The caller must be able to name the missing underlag to the user.
expect(formatFailedDocumentNames(result.failed)).toBe('kvitto-2.pdf')
})
it('reports a non-ok response as a failure (the old empty-catch hid these)', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(errorResponse(403, 'PERIOD_LOCKED', 'Bokföringen är låst'))
const result = await linkDocuments([targets[0]], 'entry-1', { fetchImpl })
expect(result.linked).toEqual([])
expect(result.allLinked).toBe(false)
expect(result.failed[0].status).toBe(403)
expect(result.failed[0].code).toBe('PERIOD_LOCKED')
})
it('handles a non-ok response with a non-JSON body', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(new Response('<html>502</html>', { status: 502 }))
const result = await linkDocuments([targets[0]], 'entry-1', { fetchImpl })
expect(result.allLinked).toBe(false)
expect(result.failed[0]).toEqual({
documentId: 'doc-1',
fileName: 'kvitto-1.pdf',
status: 502,
code: null,
reason: null,
})
})
it('reports every document when all links fail', async () => {
const fetchImpl = vi.fn().mockResolvedValue(errorResponse(500, 'DOC_LINK_FAILED'))
const result = await linkDocuments(targets, 'entry-1', { fetchImpl })
expect(result.linked).toEqual([])
expect(result.failed).toHaveLength(3)
expect(result.firstLinkedId).toBeNull()
expect(result.allLinked).toBe(false)
expect(formatFailedDocumentNames(result.failed)).toBe(
'kvitto-1.pdf, kvitto-2.pdf, kvitto-3.pdf',
)
})
it('reports a thrown network error instead of swallowing it', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(okResponse())
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockResolvedValueOnce(okResponse())
const result = await linkDocuments(targets, 'entry-1', { fetchImpl })
expect(result.linked).toEqual(['doc-1', 'doc-3'])
expect(result.failed).toEqual([
{
documentId: 'doc-2',
fileName: 'kvitto-2.pdf',
status: 0,
code: null,
reason: 'Failed to fetch',
},
])
})
it('never rejects, even when every call throws', async () => {
const fetchImpl = vi.fn().mockRejectedValue(new Error('offline'))
await expect(linkDocuments(targets, 'entry-1', { fetchImpl })).resolves.toMatchObject({
linked: [],
allLinked: false,
})
})
it('treats an empty target list as nothing to do', async () => {
const fetchImpl = vi.fn()
const result = await linkDocuments([], 'entry-1', { fetchImpl })
expect(result).toEqual({ linked: [], failed: [], firstLinkedId: null, allLinked: true })
expect(fetchImpl).not.toHaveBeenCalled()
})
it('encodes the document id in the URL', async () => {
const fetchImpl = vi.fn().mockResolvedValue(okResponse())
await linkDocuments([{ documentId: 'a/b c' }], 'entry-1', { fetchImpl })
expect(fetchImpl.mock.calls[0][0]).toBe('/api/documents/a%2Fb%20c/link')
})
})
describe('formatFailedDocumentNames', () => {
it('falls back to the document id when the file name is missing', () => {
expect(
formatFailedDocumentNames([
{ documentId: 'doc-1', fileName: null, status: 500, code: null, reason: null },
{ documentId: 'doc-2', fileName: 'kvitto.pdf', status: 0, code: null, reason: null },
]),
).toBe('doc-1, kvitto.pdf')
})
})
+140
View File
@@ -0,0 +1,140 @@
/**
* Link underlag (receipts, invoices, other räkenskapsinformation) to a
* journal entry via POST /api/documents/{id}/link.
*
* Why this is a shared helper and not an inline loop per dialog:
*
* BFL 5 kap 7 § requires a verifikation to carry a reference to the underlag
* it rests on, and BFL 7 kap requires that underlag to be archived with the
* verifikat for 7 years. By the time this runs the verifikat is already
* committed (the voucher number is assigned atomically), so a failed link
* cannot be rolled back: it leaves a verifikat whose underlag is missing from
* the archive. That is a compliance defect, and the only way it becomes
* recoverable is if the user is told about it. Callers must therefore never
* report plain success while `failed` is non-empty, and must never discard the
* user's files on a failure.
*
* The contract: this function never throws and never swallows. Every target
* ends up in exactly one of `linked` or `failed`, and a failure carries enough
* (file name, HTTP status, error code) to name the specific documents that did
* not attach.
*/
export interface DocumentLinkTarget {
/** documents.id: the row POST /api/documents/{id}/link is addressed at. */
documentId: string
/** Shown to the user when this document fails to attach. */
fileName?: string | null
/** Inbox item consumed by this link (the "choose from inbox" flow). */
inboxItemId?: string | null
/** Bank transaction the document should also be pinned to. */
transactionId?: string | null
}
export interface DocumentLinkFailure {
documentId: string
fileName: string | null
/** HTTP status, or 0 when the request never produced a response (offline). */
status: number
/** Structured error code from the API envelope, when the server sent one. */
code: string | null
/** Best-effort human-readable reason (envelope message or thrown message). */
reason: string | null
}
export interface LinkDocumentsResult {
/** documentIds that attached, in input order. */
linked: string[]
/** Everything that did not attach. Empty means the underlag is on the books. */
failed: DocumentLinkFailure[]
/** First document that attached: the "first linked doc wins" pin source. */
firstLinkedId: string | null
/** True when nothing failed (also true for an empty target list). */
allLinked: boolean
}
export interface LinkDocumentsOptions {
/** Injectable fetch, for tests. Defaults to the global fetch. */
fetchImpl?: typeof fetch
}
interface ErrorEnvelope {
error?: { code?: string; message?: string; message_en?: string }
}
async function readErrorEnvelope(res: Response): Promise<{ code: string | null; reason: string | null }> {
try {
const body = (await res.json()) as ErrorEnvelope | null
const err = body?.error
return { code: err?.code ?? null, reason: err?.message ?? err?.message_en ?? null }
} catch {
// Non-JSON body (proxy error page, empty 502): the status alone is the
// signal. Still a failure, never a silent success.
return { code: null, reason: null }
}
}
/**
* Link every target to `journalEntryId`, sequentially (input order is the
* "first linked doc wins" order the link route relies on for its transaction
* pin). Resolves with a per-document verdict; never rejects.
*/
export async function linkDocuments(
targets: DocumentLinkTarget[],
journalEntryId: string,
options: LinkDocumentsOptions = {},
): Promise<LinkDocumentsResult> {
// Wrapped rather than assigned so the global fetch keeps its receiver.
const doFetch: typeof fetch =
options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init))
const linked: string[] = []
const failed: DocumentLinkFailure[] = []
for (const target of targets) {
const fileName = target.fileName?.trim() ? target.fileName.trim() : null
try {
const res = await doFetch(`/api/documents/${encodeURIComponent(target.documentId)}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
journal_entry_id: journalEntryId,
...(target.inboxItemId ? { inbox_item_id: target.inboxItemId } : {}),
...(target.transactionId ? { transaction_id: target.transactionId } : {}),
}),
})
if (res.ok) {
linked.push(target.documentId)
continue
}
// A 4xx/5xx here is the exact case the old inline loops missed: the
// request completed, so nothing threw, but the underlag never attached.
const { code, reason } = await readErrorEnvelope(res)
failed.push({ documentId: target.documentId, fileName, status: res.status, code, reason })
} catch (err) {
failed.push({
documentId: target.documentId,
fileName,
status: 0,
code: null,
reason: err instanceof Error ? err.message : null,
})
}
}
return {
linked,
failed,
firstLinkedId: linked[0] ?? null,
allLinked: failed.length === 0,
}
}
/**
* Comma-separated file names for the "these did not attach" message. Falls
* back to the document id so a nameless document is still identifiable: the
* user must always be able to tell WHICH underlag is missing.
*/
export function formatFailedDocumentNames(failures: DocumentLinkFailure[]): string {
return failures.map((f) => f.fileName ?? f.documentId).join(', ')
}
+155 -5
View File
@@ -3,7 +3,11 @@ import {
generateReminderEmailHtml,
generateReminderEmailText,
generateReminderEmailSubject,
calculateReminderAmounts,
formatReminderTotalDue,
REMINDER_FEE_CURRENCY,
} from '../reminder-templates'
import { formatCurrency } from '@/lib/utils'
import { makeCustomer, makeInvoice, makeCompanySettings } from '@/tests/helpers'
const company = makeCompanySettings({ company_name: 'Acme AB' })
@@ -16,6 +20,14 @@ const invoice = makeInvoice({
total: 10_000,
})
const eurInvoice = makeInvoice({
invoice_number: 'F2026011',
invoice_date: '2026-04-15',
due_date: '2026-05-01',
currency: 'EUR',
total: 1_000,
})
const baseData = {
invoice,
customer,
@@ -25,6 +37,8 @@ const baseData = {
actionUrl: 'https://example.com/invoice-action/abc',
}
const eurBaseData = { ...baseData, invoice: eurInvoice }
describe('reminder email templates: surcharges', () => {
it('renders dröjsmålsränta + påminnelseavgift in HTML when set', () => {
const html = generateReminderEmailHtml({
@@ -34,7 +48,6 @@ describe('reminder email templates: surcharges', () => {
interestFromDate: '2026-05-01',
interestDays: 30,
reminderFee: 60,
totalDue: 10_146.3,
})
expect(html).toContain('Ursprungligt belopp:')
expect(html).toContain('Dröjsmålsränta')
@@ -52,7 +65,6 @@ describe('reminder email templates: surcharges', () => {
interestFromDate: '2026-05-01',
interestDays: 0,
reminderFee: 0,
totalDue: 10_000,
})
expect(html).not.toContain('Dröjsmålsränta')
expect(html).not.toContain('Påminnelseavgift:')
@@ -67,7 +79,6 @@ describe('reminder email templates: surcharges', () => {
interestFromDate: '2026-05-01',
interestDays: 30,
reminderFee: 60,
totalDue: 10_146.3,
})
expect(text).toContain('Ursprungligt belopp')
expect(text).toContain('Dröjsmålsränta')
@@ -83,7 +94,6 @@ describe('reminder email templates: surcharges', () => {
interestFromDate: '2026-05-01',
interestDays: 30,
reminderFee: 60,
totalDue: 10_146.3,
})
expect(subject).toContain('F2026010')
expect(subject).toContain('inkl. dröjsmålsränta')
@@ -97,9 +107,149 @@ describe('reminder email templates: surcharges', () => {
interestFromDate: '2026-05-01',
interestDays: 0,
reminderFee: 0,
totalDue: 10_000,
})
expect(subject).not.toContain('inkl. dröjsmålsränta')
expect(subject).toContain('F2026010')
})
})
describe('calculateReminderAmounts', () => {
it('folds the fee into the total for a SEK invoice', () => {
expect(
calculateReminderAmounts({
invoiceTotal: 10_000,
interestAmount: 86.3,
reminderFee: 60,
currency: 'SEK',
}),
).toEqual({ currency: 'SEK', totalDue: 10_146.3, feeDueSeparately: 0 })
})
it('keeps the SEK fee out of a foreign-currency total', () => {
expect(
calculateReminderAmounts({
invoiceTotal: 1_000,
interestAmount: 10,
reminderFee: 60,
currency: 'EUR',
}),
).toEqual({ currency: 'EUR', totalDue: 1_010, feeDueSeparately: 60 })
})
it('treats a missing currency as SEK', () => {
expect(
calculateReminderAmounts({
invoiceTotal: 100,
interestAmount: 0,
reminderFee: 60,
currency: null,
}),
).toEqual({ currency: 'SEK', totalDue: 160, feeDueSeparately: 0 })
})
it('rounds with Math.round(x * 100) / 100, not toFixed', () => {
const amounts = calculateReminderAmounts({
invoiceTotal: 1_000.005,
interestAmount: 0.011,
reminderFee: 0,
currency: 'SEK',
})
expect(amounts.totalDue).toBe(1_000.02)
})
it('renders a split total as two amounts, never one mixed scalar', () => {
const amounts = calculateReminderAmounts({
invoiceTotal: 1_000,
interestAmount: 10,
reminderFee: 60,
currency: 'EUR',
})
expect(formatReminderTotalDue(amounts)).toBe(
`${formatCurrency(1_010, 'EUR')} + ${formatCurrency(60, 'SEK')}`,
)
})
})
describe('reminder email templates: statutory fee currency (Lag 1981:739)', () => {
const eurSurcharges = {
interestAmount: 10,
interestRate: 0.105,
interestFromDate: '2026-05-01',
interestDays: 30,
reminderFee: 60,
}
it('the fee is a SEK statute, not the invoice currency', () => {
expect(REMINDER_FEE_CURRENCY).toBe('SEK')
})
it('never labels the 60 kr fee with the invoice currency in HTML', () => {
const html = generateReminderEmailHtml({ ...eurBaseData, ...eurSurcharges })
expect(
html,
'the påminnelseavgift is capped at 60 kr by Lag 1981:739; rendering it as 60 EUR demands roughly 690 kr',
).not.toContain(formatCurrency(60, 'EUR'))
expect(html).toContain(formatCurrency(60, 'SEK'))
})
it('never sums a SEK fee into a EUR total in HTML', () => {
const html = generateReminderEmailHtml({ ...eurBaseData, ...eurSurcharges })
// 1000 EUR invoice + 10 EUR interest = 1010 EUR, plus 60 kr alongside.
expect(html).toContain(formatCurrency(1_010, 'EUR'))
expect(html).not.toContain(formatCurrency(1_070, 'EUR'))
expect(html).toContain('Lag 1981:739')
})
it('never labels the fee with the invoice currency in plain text', () => {
const text = generateReminderEmailText({ ...eurBaseData, ...eurSurcharges })
expect(text).toContain(`Påminnelseavgift: ${formatCurrency(60, 'SEK')}`)
expect(text).not.toContain(formatCurrency(60, 'EUR'))
expect(text).toContain(
`Att betala: ${formatCurrency(1_010, 'EUR')} + ${formatCurrency(60, 'SEK')}`,
)
})
it('never quotes a mixed-currency amount in the subject', () => {
const subject = generateReminderEmailSubject({ ...eurBaseData, ...eurSurcharges })
expect(subject).not.toContain(formatCurrency(1_070, 'EUR'))
expect(subject).toContain(
`${formatCurrency(1_010, 'EUR')} + ${formatCurrency(60, 'SEK')}`,
)
})
it('leaves a SEK invoice untouched: one currency, one total, no split note', () => {
const data = {
...baseData,
interestAmount: 86.3,
interestRate: 0.105,
interestFromDate: '2026-05-01',
interestDays: 30,
reminderFee: 60,
}
const html = generateReminderEmailHtml(data)
const text = generateReminderEmailText(data)
const subject = generateReminderEmailSubject(data)
expect(html).toContain(formatCurrency(60, 'SEK'))
expect(html).toContain(formatCurrency(10_146.3, 'SEK'))
expect(html).not.toContain('Lag 1981:739')
expect(text).toContain(`Påminnelseavgift: ${formatCurrency(60, 'SEK')}`)
expect(text).toContain(`Att betala: ${formatCurrency(10_146.3, 'SEK')}`)
expect(text).not.toContain(' + ')
expect(subject).toContain(formatCurrency(10_146.3, 'SEK'))
})
it('does not append a SEK note when the fee is disabled on a EUR invoice', () => {
const html = generateReminderEmailHtml({
...eurBaseData,
...eurSurcharges,
reminderFee: 0,
})
expect(html).not.toContain('Lag 1981:739')
expect(html).toContain(formatCurrency(1_010, 'EUR'))
})
})
+115 -12
View File
@@ -11,14 +11,88 @@ export interface ReminderEmailData {
// Dröjsmålsränta + lagstadgad påminnelseavgift (Räntelagen §6, Lag 1981:739).
// When the reminder is generated by the cron, these are computed and persisted
// on the invoice_reminders row and then passed in here.
//
// interestAmount is a share of the invoice total, so it carries the INVOICE
// currency. reminderFee is a fixed statutory krona amount and always carries
// SEK (see REMINDER_FEE_CURRENCY). The two are deliberately not summed here:
// the amount to pay is derived per currency by calculateReminderAmounts().
interestAmount: number
interestRate: number // annual rate as decimal (0.105 = 10.5%)
interestFromDate: string
interestDays: number
reminderFee: number
totalDue: number // invoice.total + interestAmount + reminderFee
reminderFee: number // always SEK, never the invoice currency
}
/**
* The lagstadgad påminnelseavgift is a Swedish-krona statute: Lag (1981:739) om
* ersättning för inkassokostnader m.m. 2 §, with the amount fixed at 60 kr by
* förordning (1981:1057) 1 §. There is no conversion rule for a foreign-currency
* claim, and Accounted books the fee 1510/3990 in SEK regardless of the invoice
* currency (lib/bookkeeping/reminder-fee-entries.ts).
*
* So the fee is always quoted in SEK. Rendering "60" with the invoice's currency
* code would demand roughly ten times the statutory amount from a EUR customer
* and would not match the receivable recorded in the books.
*/
export const REMINDER_FEE_CURRENCY = 'SEK'
export interface ReminderAmounts {
/** Invoice currency (uppercased ISO code). Carries the invoice total + interest. */
currency: string
/** invoice.total + dröjsmålsränta, plus the fee only when the invoice is itself in SEK. */
totalDue: number
/**
* The påminnelseavgift in SEK when the invoice is in another currency, so it
* has to be demanded alongside rather than inside `totalDue`. 0 when the fee
* is already folded into `totalDue`.
*/
feeDueSeparately: number
}
/**
* Split the amount to pay per currency. Never returns a single scalar that mixes
* a foreign invoice currency with the SEK statutory fee.
*/
export function calculateReminderAmounts(input: {
invoiceTotal: number
interestAmount: number
reminderFee: number
currency?: string | null
}): ReminderAmounts {
const currency = (input.currency || REMINDER_FEE_CURRENCY).toUpperCase()
const feeSharesInvoiceCurrency = currency === REMINDER_FEE_CURRENCY
const fee = input.reminderFee > 0 ? Math.round(input.reminderFee * 100) / 100 : 0
const totalDue =
Math.round(
(input.invoiceTotal + input.interestAmount + (feeSharesInvoiceCurrency ? fee : 0)) * 100,
) / 100
return {
currency,
totalDue,
feeDueSeparately: feeSharesInvoiceCurrency ? 0 : fee,
}
}
/**
* Render the amount to pay. When the statutory fee is in a different currency
* than the invoice it is appended as its own amount ("1 010,00 € + 60 kr")
* instead of being folded into a misleading single figure.
*/
export function formatReminderTotalDue(amounts: ReminderAmounts): string {
const base = formatCurrency(amounts.totalDue, amounts.currency)
if (amounts.feeDueSeparately <= 0) {
return base
}
return `${base} + ${formatCurrency(amounts.feeDueSeparately, REMINDER_FEE_CURRENCY)}`
}
/** Shown whenever a SEK fee rides along with a foreign-currency invoice. */
export const REMINDER_FEE_CURRENCY_NOTE =
'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.'
// Reminder level configurations
const REMINDER_CONFIG = {
1: {
@@ -53,7 +127,6 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
interestRate,
interestDays,
reminderFee,
totalDue,
} = data
const config = REMINDER_CONFIG[reminderLevel]
const interestRatePercent = (interestRate * 100).toLocaleString('sv-SE', {
@@ -63,6 +136,13 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
const hasInterest = interestAmount > 0
const hasFee = reminderFee > 0
const hasSurcharges = hasInterest || hasFee
const amounts = calculateReminderAmounts({
invoiceTotal: invoice.total,
interestAmount,
reminderFee,
currency: invoice.currency,
})
const feeInOtherCurrency = amounts.feeDueSeparately > 0
// Different styling based on urgency
const headerColor = reminderLevel === 3 ? '#dc2626' : reminderLevel === 2 ? '#ea580c' : '#2563eb'
@@ -159,7 +239,7 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
${hasFee ? `
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Påminnelseavgift:</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${formatCurrency(reminderFee, invoice.currency)}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${formatCurrency(reminderFee, REMINDER_FEE_CURRENCY)}</td>
</tr>
` : ''}
${hasSurcharges ? `
@@ -170,9 +250,16 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">Att betala:</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: ${headerColor};">
${formatCurrency(totalDue, invoice.currency)}
${formatReminderTotalDue(amounts)}
</td>
</tr>
${feeInOtherCurrency ? `
<tr>
<td colspan="2" style="padding: 10px 0 0 0; color: #666; font-size: 12px; line-height: 1.5;">
${REMINDER_FEE_CURRENCY_NOTE}
</td>
</tr>
` : ''}
</table>
</div>
@@ -266,7 +353,6 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
interestRate,
interestDays,
reminderFee,
totalDue,
} = data
const config = REMINDER_CONFIG[reminderLevel]
const interestRatePercent = (interestRate * 100).toLocaleString('sv-SE', {
@@ -275,6 +361,12 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
})
const hasInterest = interestAmount > 0
const hasFee = reminderFee > 0
const amounts = calculateReminderAmounts({
invoiceTotal: invoice.total,
interestAmount,
reminderFee,
currency: invoice.currency,
})
let text = `${config.title.toUpperCase()}\n`
text += `Faktura ${invoice.invoice_number} förföll för ${daysOverdue} dagar sedan\n`
@@ -304,10 +396,14 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
text += `Dröjsmålsränta (${interestRatePercent}% per år, ${interestDays} dagar): ${formatCurrency(interestAmount, invoice.currency)}\n`
}
if (hasFee) {
text += `Påminnelseavgift: ${formatCurrency(reminderFee, invoice.currency)}\n`
text += `Påminnelseavgift: ${formatCurrency(reminderFee, REMINDER_FEE_CURRENCY)}\n`
}
text += `Att betala: ${formatCurrency(totalDue, invoice.currency)}\n`
text += `-`.repeat(30) + `\n\n`
text += `Att betala: ${formatReminderTotalDue(amounts)}\n`
text += `-`.repeat(30) + `\n`
if (amounts.feeDueSeparately > 0) {
text += `${REMINDER_FEE_CURRENCY_NOTE}\n`
}
text += `\n`
text += `Betalningsinformation:\n`
if (company.bank_name) text += `Bank: ${company.bank_name}\n`
@@ -342,14 +438,21 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
* the email.
*/
export function generateReminderEmailSubject(data: ReminderEmailData): string {
const { invoice, reminderLevel, totalDue, interestAmount, reminderFee } = data
const { invoice, reminderLevel, interestAmount, reminderFee } = data
const config = REMINDER_CONFIG[reminderLevel]
const hasSurcharges = interestAmount > 0 || reminderFee > 0
const amount = hasSurcharges ? totalDue : invoice.total
// A SEK fee on a foreign-currency invoice renders as "1 010,00 € + 60 kr":
// two amounts in two currencies, never one mixed scalar.
const amounts = calculateReminderAmounts({
invoiceTotal: invoice.total,
interestAmount,
reminderFee,
currency: invoice.currency,
})
const suffix = hasSurcharges ? ' (inkl. dröjsmålsränta)' : ''
return `${config.title}: Faktura ${invoice.invoice_number} - ${formatCurrency(amount, invoice.currency)}${suffix}`
return `${config.title}: Faktura ${invoice.invoice_number} - ${formatReminderTotalDue(amounts)}${suffix}`
}
/**
@@ -332,3 +332,132 @@ describe('getErrorMessage: existing patterns still work', () => {
expect(msg).toBe('Något gick fel. Försök igen.')
})
})
/**
* Client handlers must hand getErrorMessage() the PARSED RESPONSE BODY plus the
* HTTP status, never `new Error(body.error)`.
*
* `withRouteContext` answers any thrown error with the canonical envelope
* `{ error: { code, message, message_en } }`, so `body.error` is an OBJECT on
* that path. `new Error(object)` stringifies it to "[object Object]", which
* matches no known pattern and no Swedish heuristic, so the route's own reason
* is discarded and the user is told "Något gick fel". The same call site also
* loses the HTTP status, so the status map cannot rescue it either.
*
* These cases pin the contract for all three response shapes a route can
* produce: nested envelope, the deprecated bare `{ error: 'string' }`, and no
* parseable body at all.
*/
describe('getErrorMessage: API response body vs new Error(body.error)', () => {
// Exactly what withRouteContext -> errorResponse() -> buildResponse() emits.
const envelope = {
error: {
code: 'TARGET_PERIOD_LOCKED',
message:
'Räkenskapsperioden för det valda datumet är låst (t.o.m. 2026-03-31). Lås upp perioden för att flytta verifikationen dit.',
message_en: 'The fiscal period for the selected date is locked.',
requestId: 'req_00000000-0000-4000-8000-000000000000',
details: { lockDate: '2026-03-31' },
},
}
it('the parsed body plus statusCode resolves the envelope reason', () => {
const msg = getErrorMessage(envelope, { statusCode: 409 })
expect(msg).toContain('låst')
expect(msg).toContain('2026-03-31')
expect(msg).not.toBe('Något gick fel. Försök igen.')
})
it('the inner error object alone also resolves (forwarded body.error)', () => {
const msg = getErrorMessage(envelope.error, { statusCode: 409 })
expect(msg).toContain('låst')
expect(msg).not.toBe('Något gick fel. Försök igen.')
})
it('new Error(body.error) stringifies the envelope to "[object Object]"', () => {
// The defect in one line: the Error constructor calls String() on the object.
expect(new Error(envelope.error as unknown as string).message).toBe('[object Object]')
})
it('new Error(body.error) discards the reason and yields the generic fallback', () => {
const thrown = new Error(envelope.error as unknown as string)
expect(getErrorMessage(thrown)).toBe('Något gick fel. Försök igen.')
})
it('new Error(body.error) is not rescued by passing statusCode either', () => {
// The status map fires, but the specific reason is already gone: the user
// is told "a conflict occurred", not which period is locked.
const thrown = new Error(envelope.error as unknown as string)
const msg = getErrorMessage(thrown, { statusCode: 409 })
expect(msg).toBe('En konflikt uppstod. Ladda om sidan och försök igen.')
expect(msg).not.toContain('2026-03-31')
})
it('the same call handles the deprecated bare { error: string } shape', () => {
const body = { error: 'Du har endast läsbehörighet i detta företag.' }
expect(getErrorMessage(body, { statusCode: 403 })).toBe(
'Du har endast läsbehörighet i detta företag.',
)
})
it('the same call handles an unparseable body via statusCode', () => {
// `await response.json().catch(() => null)` on an HTML 403 page.
expect(getErrorMessage(null, { statusCode: 403 })).toBe(
'Du har inte behörighet att utföra denna åtgärd.',
)
})
it('English locale gets message_en from the envelope, not the Swedish prose', () => {
const msg = getErrorMessage(envelope, { statusCode: 409, locale: 'en' })
expect(msg).not.toContain('Räkenskapsperioden')
expect(msg).not.toBe('Something went wrong. Please try again.')
})
})
/**
* Hand-rolled `{ error: '<Swedish sentence>' }` routes (the extension routes,
* /api/team/accept, and friends) only reach the toast verbatim when
* isSwedishUserMessage() recognizes the sentence. Several real route strings
* ("hittades inte", "är redan bokförd", "kan inte matchas", "en låst eller
* saknad räkenskapsperiod") matched none of the original patterns, so even the
* correct call-site treatment (body plus statusCode) collapsed them to the
* status-map sentence, or, where the status has no map entry (423), to the
* generic fallback. These pin the added patterns: hittades / redan / kan inte /
* låst.
*/
describe('getErrorMessage: Swedish heuristic covers real route sentences', () => {
it('"hittades inte" passes through verbatim instead of the 404 map sentence', () => {
const body = { error: 'Skattekonto-transaktionen hittades inte.', code: 'TRANSACTION_NOT_FOUND' }
expect(getErrorMessage(body, { statusCode: 404 })).toBe(
'Skattekonto-transaktionen hittades inte.',
)
})
it('"är redan bokförd" passes through verbatim instead of the 409 map sentence', () => {
const body = { error: 'Transaktionen är redan bokförd.', code: 'ALREADY_BOOKED' }
expect(getErrorMessage(body, { statusCode: 409 })).toBe('Transaktionen är redan bokförd.')
})
it('"kan inte" passes through verbatim', () => {
const body = { error: 'Verifikatet är makulerat och kan inte matchas.', code: 'INVALID_CANDIDATE' }
expect(getErrorMessage(body, { statusCode: 422 })).toBe(
'Verifikatet är makulerat och kan inte matchas.',
)
})
it('"låst eller saknad räkenskapsperiod" survives a status (423) that has no map entry', () => {
const body = {
error: 'Datumet 2026-01-15 ligger i en låst eller saknad räkenskapsperiod. Lås upp perioden eller hoppa över raden.',
code: 'PERIOD_LOCKED',
}
const msg = getErrorMessage(body, { statusCode: 423 })
expect(msg).toContain('låst eller saknad räkenskapsperiod')
expect(msg).not.toBe('Något gick fel. Försök igen.')
})
it('an English body still falls to the status map, not passthrough', () => {
expect(getErrorMessage({ error: 'Extension context required' }, { statusCode: 500 })).toBe(
'Ett oväntat serverfel uppstod. Försök igen senare.',
)
})
})
+18
View File
@@ -167,6 +167,10 @@ function tryMatchKnownError(message: string): string | null {
function isSwedishUserMessage(message: string): boolean {
const swedishPatterns = [
/kunde inte/i,
/kan inte/i,
/hittades/i,
/redan/i,
/låst/i,
/försök igen/i,
/ogiltigt?/i,
/saknas/i,
@@ -377,6 +381,20 @@ export function getErrorMessage(
return 'En valutaomvärdering finns redan för denna period.'
}
if (structured.code === 'FX_CLOSING_RATE_UNAVAILABLE') {
// Name the currency and the date: the user needs to know exactly which
// rate is missing to judge whether to wait or pick another closing
// date. Nothing was posted, so this is never a partial-state message.
const details = structured.details as { missingRates?: unknown } | undefined
const missing = Array.isArray(details?.missingRates)
? (details.missingRates as { currency?: unknown; date?: unknown }[])
.filter((m) => typeof m?.currency === 'string' && typeof m?.date === 'string')
.map((m) => `${m.currency as string} per ${m.date as string}`)
: []
const what = missing.length > 0 ? missing.join(', ') : 'balansdagen'
return `Ingen valutakurs från Riksbanken finns för ${what}. Valutaomvärderingen har inte bokförts: en uppskattad kurs får inte bokföras mot 3960/7960. Försök igen när kursen är publicerad.`
}
if (structured.code === 'INVALID_MAPPING_RESULT') {
return 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.'
}
+137 -14
View File
@@ -466,6 +466,34 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
message_en:
'Could not retrieve an exchange rate from Riksbanken for the payment date. Provide the rate manually from your bank statement (manual_exchange_rate field).',
},
MATCH_INVOICE_BOOKING_RATE_MISSING: {
httpStatus: 400,
message_sv:
'Fakturan är utställd i utländsk valuta men saknar växelkurs. Utan kursen går det inte att räkna fram kursvinst eller kursförlust. Komplettera fakturans växelkurs (exchange_rate) och försök igen.',
message_en:
'The foreign-currency invoice has no usable booking exchange rate on file (invoice.exchange_rate is missing, zero, or out of range), so the FX gain/loss (BAS 3960/7960) on settlement cannot be computed. Same guard as BATCH_FX_RATE_MISSING in match_batch_allocate.',
remediation: {
description:
'Set invoice.exchange_rate to the rate the receivable (1510) was booked at, then retry the match. On an invoice that is not yet booked, POST /api/invoices/{id}/refresh-exchange-rate fetches the taxable-event rate from Riksbanken and fills it in. On an already-booked invoice that endpoint refuses (INVOICE_FX_REFRESH_BOOKED): the SEK amounts are in a verifikat and only storno or inline rättelse may change them.',
},
},
// The BANK ROW itself has no SEK value: transactions.currency is foreign and
// both amount_sek and exchange_rate are empty (the shape a row gets when
// Riksbanken was unreachable at ingest, see lib/transactions/ingest.ts).
// Journal entry lines are always SEK, so the raw foreign number must never
// stand in for one: a 500 USD receipt would be allocated as 500 SEK. Same
// refusal as the match_batch_allocate RPC's BATCH_FX_RATE_MISSING.
MATCH_INVOICE_TX_FX_RATE_MISSING: {
httpStatus: 400,
message_sv:
'Banktransaktionen är i utländsk valuta men saknar både SEK-belopp och växelkurs. Komplettera transaktionens växelkurs innan du matchar: utan den kan beloppet inte räknas om till kronor.',
message_en:
'The bank transaction is in a foreign currency but has neither a SEK amount nor an exchange rate on file. Set the transaction exchange rate before matching; without it the amount cannot be translated to SEK.',
remediation: {
description:
'Set amount_sek (or exchange_rate) on the transaction for its value date, then retry the match.',
},
},
MATCH_INVOICE_ALREADY_PAID: {
httpStatus: 409,
message_sv: 'Fakturan har redan slutbetalats av en annan förfrågan.',
@@ -877,6 +905,23 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Fakturan kunde inte bokföras.',
message_en: 'Failed to book the invoice.',
},
// Raised by lib/bookkeeping/invoice-entries.ts when a foreign-currency
// customer invoice reaches a booking path with no exchange rate. Items carry
// no per-item SEK column, so the rate is the only honest source; booking 1:1
// would still balance (the 1510 debit is derived from the credits) while
// understating ruta 05 and ruta 10 of the momsdeklaration. Sales-side twin of
// SI_FX_RATE_MISSING.
INVOICE_FX_RATE_MISSING: {
httpStatus: 400,
message_sv:
'Fakturan är i utländsk valuta men saknar växelkurs. Ange fakturans växelkurs innan den bokförs: utan kurs kan beloppen inte räknas om till kronor och momsen blir fel.',
message_en:
'The invoice is in a foreign currency but has no exchange rate on file. Set the invoice exchange rate before booking; without it the amounts cannot be translated to SEK and the output VAT would be understated.',
remediation: {
description:
'Set exchange_rate on the invoice (the rate at the taxable-event date) and retry. On an unbooked invoice, POST /api/invoices/{id}/refresh-exchange-rate fetches it from Riksbanken.',
},
},
INVOICE_SEND_EMAIL_NOT_CONFIGURED: {
httpStatus: 503,
message_sv:
@@ -1015,6 +1060,46 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Fakturan kunde inte hittas.',
message_en: 'Invoice not found.',
},
// POST /api/invoices/{id}/refresh-exchange-rate: the repair path for a
// foreign-currency invoice whose SEK conversion is missing or was stamped
// from the wrong day's rate. It works on sent invoices (PATCH does not), but
// stops at the verifikat.
INVOICE_FX_REFRESH_BOOKED: {
httpStatus: 409,
message_sv:
'Fakturan är redan bokförd, så växelkursen kan inte räknas om här. Beloppen i kronor sitter i verifikatet och får bara ändras genom rättelse: makulera med storno och bokför om, eller rätta verifikatet inifrån (BFL 5 kap. 5 §).',
message_en:
'The invoice already has a verifikat, so its SEK conversion cannot be re-rated here. The SEK amounts are posted entries and may only be changed through one of the two sanctioned rättelse tracks (BFL 5 kap 5 §): storno + correcting entry, or the inline rättelse RPCs on an open unlocked period.',
remediation: {
description:
'Reverse the invoice verifikat (gnubok_reverse_journal_entry) and rebook it with the correct rate, or correct it inline via gnubok_correct_entry while the period is still open and unlocked. Never update invoice.exchange_rate behind a posted entry.',
tool: 'gnubok_reverse_journal_entry',
},
},
INVOICE_FX_REFRESH_PERIOD_LOCKED: {
httpStatus: 409,
message_sv:
'Räkenskapsperioden för fakturadatumet är låst eller stängd, så växelkursen kan inte uppdateras. Öppna perioden eller rätta med storno i en öppen period.',
message_en:
'The fiscal period covering the invoice date is locked or closed, so the exchange rate cannot be updated. details.period_status carries the verdict; lookup_failed: true means the lock state could not be read and the request was refused fail-closed.',
remediation: {
description:
'Unlock the period via gnubok_unlock_period (only if the status is "locked", not "closed"), then retry. Past a close, the correction belongs in an open period as a storno.',
tool: 'gnubok_unlock_period',
},
},
INVOICE_FX_REFRESH_RATE_UNAVAILABLE: {
httpStatus: 502,
message_sv:
'Kunde inte hämta växelkursen från Riksbanken för leverans-/fakturadatumet. Fakturan är oförändrad: en gissad kurs får inte bokföras. Försök igen om en stund.',
message_en:
'No Riksbanken observation could be retrieved for the taxable-event date (delivery_date, falling back to invoice_date) and no cached rate was available either. The invoice was left unchanged rather than converted at an invented rate.',
retryable: true,
remediation: {
description:
'Retry once Riksbanken responds. The permitted rate sources are the Nasdaq OMX mid-rate published by Riksbanken or the latest ECB rate (ML 8 kap 21-23 §); never substitute an estimate.',
},
},
INVOICE_FINALIZE_NOT_DRAFT: {
httpStatus: 409,
message_sv: 'Endast onumrerade utkast kan skapas. Fakturan har redan ett nummer eller är inte ett utkast.',
@@ -1109,6 +1194,21 @@ const SUPPLIER_INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Leverantörsfakturan kunde inte bokföras.',
message_en: 'Failed to book the supplier invoice.',
},
// Raised by lib/bookkeeping/supplier-invoice-entries.ts when a
// foreign-currency invoice reaches a booking path with no exchange rate.
// Booking it 1:1 would balance but understate the fiktiv moms on 2614/2645
// and therefore rutorna 20-24 + 30-32 of the momsdeklaration.
SI_FX_RATE_MISSING: {
httpStatus: 400,
message_sv:
'Leverantörsfakturan är i utländsk valuta men saknar växelkurs. Ange fakturans växelkurs innan den bokförs: utan kurs kan beloppen inte räknas om till kronor och momsen blir fel.',
message_en:
'The supplier invoice is in a foreign currency but has no exchange rate on file. Set the invoice exchange rate before booking; without it the amounts cannot be translated to SEK and the reverse-charge VAT would be understated.',
remediation: {
description:
'Set exchange_rate on the supplier invoice (the rate at the invoice date) and retry the booking.',
},
},
PO_THREE_WAY_MATCH_FAILED: {
httpStatus: 422,
message_sv:
@@ -1165,20 +1265,15 @@ const PERIOD: Record<string, StructuredErrorEntry> = {
message_sv: 'Ett stängt räkenskapsår kan inte låsas upp.',
message_en: 'A closed fiscal year cannot be unlocked.',
},
// Forward-chaining a new räkenskapsår is blocked while a prior period is
// still fully open (not locked, not closed, not covered by the company-wide
// lock-through date). BFL 6 kap allows löpande bokföring of the new year in
// parallel with bokslut, but the prior year must at least be locked so
// nothing is back-posted into a year you've moved on from. The blocking
// periods (id + name + dates) are attached to the response `details` so the
// UI can offer to lock them inline. See app/api/bookkeeping/fiscal-periods.
PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS: {
httpStatus: 409,
message_sv:
'Du måste låsa föregående räkenskapsår innan du kan skapa ett nytt.',
message_en:
'Cannot create a new fiscal year while a prior period is still open; lock it first.',
},
// Retired 2026-07-26: PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS. Creating the
// next räkenskapsår while a prior one is still fully open is no longer an
// error at all: BFL 5 kap 2 § forces the new year's affärshändelser to be
// booked within weeks (which needs a räkenskapsår covering them) while BFL
// 6 kap gives the prior year six months to be finished, so running both in
// parallel is the mandated state, not an edge case. The detection now rides
// along as a non-blocking `warnings: [{ code: 'PRIOR_FISCAL_YEAR_STILL_OPEN',
// message }]` on the 200 (app/api/bookkeeping/fiscal-periods), which needs no
// registry entry: warnings are not thrown errors. Do not re-add this code.
}
const YEAR_END: Record<string, StructuredErrorEntry> = {
@@ -1241,6 +1336,18 @@ const FX: Record<string, StructuredErrorEntry> = {
message_sv: 'Valutaomvärderingen misslyckades.',
message_en: 'Currency revaluation failed.',
},
FX_CLOSING_RATE_UNAVAILABLE: {
httpStatus: 502,
message_sv:
'Ingen valutakurs från Riksbanken finns för balansdagen. Valutaomvärderingen har inte bokförts: en uppskattad kurs får inte bokföras mot 3960/7960 (ÅRL 4 kap. 13 §). Försök igen när kursen är publicerad.',
message_en:
'No Riksbanken observation is available for the closing date. The revaluation was refused rather than posted from an estimated rate; details.missingRates lists each currency and date.',
retryable: true,
remediation: {
description:
'Retry once Riksbanken has published the closing-date rate, or run the revaluation for a closing date that has a published observation.',
},
},
}
const REPORT: Record<string, StructuredErrorEntry> = {
@@ -1414,6 +1521,11 @@ const SIE_IMPORT: Record<string, StructuredErrorEntry> = {
message_sv: 'SIE-importen kunde inte ersättas.',
message_en: 'Failed to replace SIE import.',
},
SIE_REPLACE_FORBIDDEN: {
httpStatus: 403,
message_sv: 'Endast ägare eller administratörer kan ersätta en SIE-import.',
message_en: 'Only company owners and admins can replace an SIE import.',
},
SIE_UNDO_FAILED: {
httpStatus: 400,
message_sv: 'SIE-importen kunde inte ångras.',
@@ -2552,6 +2664,10 @@ const MATCH_BATCH: Record<string, StructuredErrorEntry> = {
'Fakturan i annan valuta saknar växelkurs. Komplettera fakturans exchange_rate innan du fördelar.',
message_en:
'The foreign-currency invoice has no exchange rate on file. Complete invoice.exchange_rate before allocating.',
remediation: {
description:
'POST /api/invoices/{id}/refresh-exchange-rate fetches the taxable-event rate from Riksbanken and fills in exchange_rate plus the *_sek columns, then retry the allocation. It refuses with INVOICE_FX_REFRESH_BOOKED once the invoice has a verifikat: from there the correction is a storno or an inline rättelse, never an update behind the posted entry.',
},
},
BATCH_FX_DEVIATION_TOO_LARGE: {
httpStatus: 400,
@@ -2634,6 +2750,13 @@ const BULK_BOOK: Record<string, StructuredErrorEntry> = {
message_en:
'Bulk booking supports only single-currency batches. Select transactions in one currency at a time.',
},
BULK_BOOK_FOREIGN_CURRENCY: {
httpStatus: 400,
message_sv:
'Samlingsbokföring stödjer endast transaktioner i SEK. Bokför transaktioner i utländsk valuta enskilt, så att beloppet räknas om till kronor med rätt växelkurs.',
message_en:
'Bulk booking supports only SEK transactions. Book foreign-currency transactions individually so the amount is converted to kronor at the correct exchange rate.',
},
BULK_BOOK_INVALID_PAYLOAD: {
httpStatus: 400,
message_sv:
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool, runAsServiceRole, withUserContext } from '@/tests/pg/setup'
import { seedCompany, insertAuthUser, insertCompanyMember } from '@/tests/pg/fixtures'
// Covers the Fortnox re-sync flow:
// 1. The partial unique index `sie_imports_company_id_file_hash_active_idx`
@@ -14,6 +14,18 @@ import { seedCompany } from '@/tests/pg/fixtures'
// pointer if it came from the import, and resets voucher_sequences
// so the next re-import restarts the series. User-created entries
// (source_type='manual', 'bank_transaction', etc.) are left intact.
// 3. Since 20260727120000 the RPC takes a third argument, p_user_id, and
// gates on owner/admin membership. Before that migration the function was
// SECURITY DEFINER with EXECUTE held by `anon` and no authorization check
// at all, while it set gnubok.allow_delete: an unauthenticated caller
// could hard-delete any tenant's imported verifikationer.
// 4. p_user_id is honored ONLY when auth.role() = 'service_role' (the
// cookieless server client); every other caller is pinned to its own
// auth.uid(). A plain COALESCE(p_user_id, auth.uid()) would have let any
// authenticated PostgREST caller pass an owner's UUID and impersonate
// them into the gate. These tests therefore run the RPC under a simulated
// service-role context (runAsServiceRole), which is how the app's
// rpcClientForBulkDelete path presents.
async function insertSIEImport(params: {
companyId: string
@@ -124,6 +136,20 @@ async function insertDocumentAttachment(params: {
return id
}
// Every call goes through the 3-arg shape with an explicit actor, under the
// service-role context: auth.uid() is NULL there, so the owner/admin gate can
// only resolve through p_user_id, which the function honors for service_role
// alone. Commits on success (the assertions below read persisted state over
// the plain pool); a raise aborts and rolls back.
async function callReplace(companyId: string, importId: string, actor: string | null) {
return runAsServiceRole((client) =>
client.query<{ deleted: number }>(
`SELECT public.replace_sie_import($1::uuid, $2::uuid, $3::uuid) AS deleted`,
[companyId, importId, actor],
),
)
}
describe('sie_imports: partial unique index + replace flow', () => {
it('blocks a second active row with the same (company_id, file_hash)', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
@@ -219,11 +245,8 @@ describe('sie_imports: partial unique index + replace flow', () => {
openingBalanceEntryId: obEntry,
})
const { rows } = await getPool().query<{ replace_sie_import: number }>(
`SELECT public.replace_sie_import($1::uuid, $2::uuid) AS replace_sie_import`,
[companyId, importId],
)
expect(rows[0]!.replace_sie_import).toBe(3) // OB + 2 import entries
const { rows } = await callReplace(companyId, importId, userId)
expect(rows[0]!.deleted).toBe(3) // OB + 2 import entries
// The import entries (and their lines) are gone
const entries = await getPool().query<{ id: string; status: string }>(
@@ -301,10 +324,7 @@ describe('sie_imports: partial unique index + replace flow', () => {
fiscalPeriodId,
})
await getPool().query(
`SELECT public.replace_sie_import($1::uuid, $2::uuid)`,
[companyId, importId],
)
await callReplace(companyId, importId, userId)
const vs = await getPool().query<{ last_number: number }>(
`SELECT last_number FROM public.voucher_sequences
@@ -339,10 +359,7 @@ describe('sie_imports: partial unique index + replace flow', () => {
fiscalPeriodId,
})
await getPool().query(
`SELECT public.replace_sie_import($1::uuid, $2::uuid)`,
[companyId, importId],
)
await callReplace(companyId, importId, userId)
// The document attached to the deleted import entry is detached but preserved
const detached = await getPool().query<{
@@ -400,16 +417,305 @@ describe('sie_imports: partial unique index + replace flow', () => {
fiscalPeriodId,
})
await getPool().query(
`SELECT public.replace_sie_import($1::uuid, $2::uuid)`,
[companyId, importId],
)
await callReplace(companyId, importId, userId)
await expect(
getPool().query(
`SELECT public.replace_sie_import($1::uuid, $2::uuid)`,
[companyId, importId],
),
).rejects.toThrow(/not found or not in completed status/)
await expect(callReplace(companyId, importId, userId)).rejects.toThrow(
/not found or not in completed status/,
)
})
})
// Migration 20260727120000_replace_sie_import_authorize_actor.sql.
//
// Before it, replace_sie_import was a SECURITY DEFINER function with EXECUTE
// held by PUBLIC and by `anon`, no company_members lookup, no auth.uid() and
// no raise, while it called set_config('gnubok.allow_delete', 'true', true) to
// disarm the BFL immutability and retention triggers. That combination made it
// an unauthenticated cross-tenant data-destruction primitive over PostgREST.
//
// The gate mirrors undo_sie_import (20260624120000) and must fail CLOSED: a
// NULL role (no membership at all, which is what an anon caller has) is
// rejected just like a member/viewer role.
describe('replace_sie_import: owner/admin authorization gate', () => {
it('succeeds for an owner actor and still deletes the import entries', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const importEntry = await insertPostedEntry({
userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 1,
})
const manualEntry = await insertPostedEntry({
userId, companyId, fiscalPeriodId, sourceType: 'manual', voucherNumber: 2,
})
const importId = await insertSIEImport({
companyId,
userId,
fileHash: `hash-${randomUUID()}`,
status: 'completed',
fiscalPeriodId,
})
const { rows } = await callReplace(companyId, importId, userId)
expect(rows[0]!.deleted).toBe(1)
const surviving = await getPool().query<{ id: string }>(
`SELECT id FROM public.journal_entries WHERE id = ANY($1)`,
[[importEntry, manualEntry]],
)
expect(surviving.rows.map(r => r.id)).toEqual([manualEntry])
const importRow = await getPool().query<{ status: string }>(
`SELECT status FROM public.sie_imports WHERE id = $1`,
[importId],
)
expect(importRow.rows[0]?.status).toBe('replaced')
})
it('succeeds for an admin actor', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const adminId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: adminId, role: 'admin' })
await insertPostedEntry({
userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 1,
})
const importId = await insertSIEImport({
companyId,
userId,
fileHash: `hash-${randomUUID()}`,
status: 'completed',
fiscalPeriodId,
})
const { rows } = await callReplace(companyId, importId, adminId)
expect(rows[0]!.deleted).toBe(1)
})
it('raises for an actor with no membership in the company', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const importEntry = await insertPostedEntry({
userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 1,
})
const importId = await insertSIEImport({
companyId,
userId,
fileHash: `hash-${randomUUID()}`,
status: 'completed',
fiscalPeriodId,
})
// A real user who simply belongs to a different company: the cross-tenant
// case the pre-fix function allowed outright.
const { userId: strangerId } = await seedCompany()
await expect(callReplace(companyId, importId, strangerId)).rejects.toThrow(
/owners and admins/i,
)
// And an id that matches no user at all.
await expect(callReplace(companyId, importId, randomUUID())).rejects.toThrow(
/owners and admins/i,
)
await expectUntouched(companyId, importId, importEntry)
})
it('raises for a viewer-role member', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const viewerId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' })
const importEntry = await insertPostedEntry({
userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 1,
})
const importId = await insertSIEImport({
companyId,
userId,
fileHash: `hash-${randomUUID()}`,
status: 'completed',
fiscalPeriodId,
})
await expect(callReplace(companyId, importId, viewerId)).rejects.toThrow(
/owners and admins/i,
)
await expectUntouched(companyId, importId, importEntry)
})
it('raises for a member-role member', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
const importEntry = await insertPostedEntry({
userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 1,
})
const importId = await insertSIEImport({
companyId,
userId,
fileHash: `hash-${randomUUID()}`,
status: 'completed',
fiscalPeriodId,
})
await expect(callReplace(companyId, importId, memberId)).rejects.toThrow(
/owners and admins/i,
)
await expectUntouched(companyId, importId, importEntry)
})
it('raises when no authorising identity is supplied at all', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const importEntry = await insertPostedEntry({
userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 1,
})
const importId = await insertSIEImport({
companyId,
userId,
fileHash: `hash-${randomUUID()}`,
status: 'completed',
fiscalPeriodId,
})
// Explicit NULL p_user_id, auth.uid() NULL under the service role.
await expect(callReplace(companyId, importId, null)).rejects.toThrow(
/owners and admins/i,
)
// 2-arg shape (p_user_id defaults to NULL): this is the exact call the
// pre-fix function accepted from anon, and it must now raise.
await expect(
runAsServiceRole((client) =>
client.query(
`SELECT public.replace_sie_import($1::uuid, $2::uuid)`,
[companyId, importId],
),
),
).rejects.toThrow(/owners and admins/i)
await expectUntouched(companyId, importId, importEntry)
})
it('ignores a spoofed p_user_id from an authenticated (non-service) caller', async () => {
// The impersonation hole this migration closes: EXECUTE is granted to
// `authenticated`, so any signed-in user can call the RPC over PostgREST.
// A viewer passing the OWNER's UUID as p_user_id must still be pinned to
// their own auth.uid(), rejected with 42501, and nothing may be deleted:
// otherwise a known company/import/owner triple is a cross-tenant hard
// delete of posted verifikationer.
const { companyId, userId: ownerId, fiscalPeriodId } = await seedCompany()
const viewerId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' })
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
const importEntry = await insertPostedEntry({
userId: ownerId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 1,
})
const importId = await insertSIEImport({
companyId,
userId: ownerId,
fileHash: `hash-${randomUUID()}`,
status: 'completed',
fiscalPeriodId,
})
for (const spoofer of [viewerId, memberId]) {
await withUserContext(spoofer, async (client) => {
let raised: (Error & { code?: string }) | null = null
try {
await client.query(
`SELECT public.replace_sie_import($1::uuid, $2::uuid, $3::uuid)`,
[companyId, importId, ownerId],
)
} catch (err) {
raised = err as Error & { code?: string }
}
expect(raised, 'spoofed p_user_id must not authorize').not.toBeNull()
expect(raised!.message).toMatch(/owners and admins/i)
// Explicit errcode so the route can map the raise to a 403.
expect(raised!.code).toBe('42501')
})
}
await expectUntouched(companyId, importId, importEntry)
})
it('still resolves the actor from auth.uid() when p_user_id is omitted', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
await insertPostedEntry({
userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 1,
})
const importId = await insertSIEImport({
companyId,
userId,
fileHash: `hash-${randomUUID()}`,
status: 'completed',
fiscalPeriodId,
})
// withUserContext sets the JWT sub to the owner and rolls back on return,
// so assert inside the callback.
const deleted = await withUserContext(userId, async (client) => {
const res = await client.query<{ deleted: number }>(
`SELECT public.replace_sie_import($1::uuid, $2::uuid) AS deleted`,
[companyId, importId],
)
const imp = await client.query<{ status: string }>(
`SELECT status FROM public.sie_imports WHERE id = $1`,
[importId],
)
expect(imp.rows[0]!.status).toBe('replaced')
return res.rows[0]!.deleted
})
expect(deleted).toBe(1)
})
it('exposes exactly one signature and does not grant EXECUTE to anon', async () => {
// The 2-arg overload is dropped by the migration: leaving it in place
// would keep an unguarded, unrevoked entry point alive and would make the
// 2-arg PostgREST call ambiguous against the new DEFAULT NULL parameter.
const { rows: overloads } = await getPool().query<{ signature: string }>(
`SELECT p.oid::regprocedure::text AS signature
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.proname = 'replace_sie_import'`,
)
expect(overloads.map(r => r.signature)).toEqual([
'replace_sie_import(uuid,uuid,uuid)',
])
const { rows } = await getPool().query<{
anon_can: boolean
public_can: boolean
authenticated_can: boolean
service_role_can: boolean
}>(
`SELECT has_function_privilege('anon', 'public.replace_sie_import(uuid,uuid,uuid)', 'EXECUTE') AS anon_can,
has_function_privilege('public', 'public.replace_sie_import(uuid,uuid,uuid)', 'EXECUTE') AS public_can,
has_function_privilege('authenticated', 'public.replace_sie_import(uuid,uuid,uuid)', 'EXECUTE') AS authenticated_can,
has_function_privilege('service_role', 'public.replace_sie_import(uuid,uuid,uuid)', 'EXECUTE') AS service_role_can`,
)
expect(rows[0]!.anon_can, 'anon must not be able to call replace_sie_import').toBe(false)
expect(rows[0]!.public_can, 'PUBLIC must not hold EXECUTE').toBe(false)
// The app calls the RPC on the service client, falling back to the
// caller's session client when SUPABASE_SERVICE_ROLE_KEY is unset
// (rpcClientForBulkDelete in lib/import/sie-import.ts).
expect(rows[0]!.authenticated_can).toBe(true)
expect(rows[0]!.service_role_can).toBe(true)
})
})
// The gate must fire before any mutation: the import row, its verifikat and
// the allow_delete-protected data are all still there after a rejected call.
async function expectUntouched(companyId: string, importId: string, entryId: string) {
const imp = await getPool().query<{ status: string }>(
`SELECT status FROM public.sie_imports WHERE id = $1 AND company_id = $2`,
[importId, companyId],
)
expect(imp.rows[0]?.status).toBe('completed')
const je = await getPool().query<{ status: string }>(
`SELECT status FROM public.journal_entries WHERE id = $1`,
[entryId],
)
expect(je.rows[0]?.status).toBe('posted')
}
@@ -0,0 +1,247 @@
/**
* Regression suite for saveMappings (P0, 2026-07-26).
*
* The bug: after the 2026-03-30 multi-tenant refactor added company_id to
* sie_account_mappings, saveMappings() built its upsert payload WITHOUT
* user_id. That column is NOT NULL with no default and no BEFORE INSERT
* trigger, and PostgREST sends an upsert as INSERT ... ON CONFLICT DO UPDATE
* (NOT NULL is checked on the proposed tuple before conflict resolution), so
* every row was rejected with 23502 on both the insert and the update path.
* The function then discarded the result instead of destructuring { error },
* and supabase-js does not throw on Postgres errors, so 834 imports between
* 2026-03-30 and 2026-07-26 reported success while persisting zero mappings.
*
* These tests therefore assert on the ACTUAL upsert payload. The shared queued
* mock is a Proxy that discards call arguments, so it cannot see a missing
* column: that blindness is exactly what let the bug survive four months.
*/
import { describe, it, expect } from 'vitest'
import { saveMappings } from '../sie-import'
import type { AccountMapping } from '../types'
import type { SupabaseClient } from '@supabase/supabase-js'
type UpsertCall = {
table: string
rows: Record<string, unknown>[]
options: { onConflict?: string } | undefined
}
/**
* Supabase double that records every upserted row verbatim.
*
* `sessionUserId` mirrors a cookie-session client (auth.getUser() resolves a
* user); pass null for the cookieless service-role clients the API-key/MCP
* paths use, where auth.uid() is NULL and there is no session to read.
* `upsertError` mirrors a Postgres rejection: supabase-js returns it in the
* result envelope and never throws.
*/
function createRecordingSupabase(options?: {
sessionUserId?: string | null
upsertError?: { code: string; message: string }
failOnCall?: number
}) {
const upserts: UpsertCall[] = []
const sessionUserId = options?.sessionUserId ?? null
const failOnCall = options?.failOnCall ?? 1
const supabase = {
auth: {
getUser: async () => ({
data: { user: sessionUserId ? { id: sessionUserId } : null },
error: null,
}),
},
from: (table: string) => ({
upsert: async (
rows: Record<string, unknown>[],
upsertOptions?: { onConflict?: string },
) => {
upserts.push({ table, rows, options: upsertOptions })
const shouldFail = options?.upsertError && upserts.length === failOnCall
return { data: null, error: shouldFail ? options.upsertError : null }
},
}),
}
return { supabase: supabase as unknown as SupabaseClient, upserts }
}
function makeMapping(
source: string,
target: string,
overrides?: Partial<AccountMapping>,
): AccountMapping {
return {
sourceAccount: source,
sourceName: `Konto ${source}`,
targetAccount: target,
targetName: `BAS ${target}`,
confidence: 1,
matchType: 'exact',
isOverride: false,
...overrides,
}
}
describe('saveMappings: the row that actually reaches Postgres', () => {
it('writes both company_id and user_id into every mapping row', async () => {
const { supabase, upserts } = createRecordingSupabase()
await saveMappings(
supabase,
'company-1',
[makeMapping('1910', '1930')],
'user-1',
)
expect(upserts).toHaveLength(1)
expect(upserts[0].table).toBe('sie_account_mappings')
// Full column set, not toMatchObject: a missing NOT NULL column is the bug
// being pinned, and toMatchObject cannot see an absent key. id, created_at
// and updated_at are deliberately absent (DB default / trigger).
expect(upserts[0].rows).toEqual([
{
user_id: 'user-1',
company_id: 'company-1',
source_account: '1910',
source_name: 'Konto 1910',
target_account: '1930',
confidence: 1,
match_type: 'exact',
},
])
expect(upserts[0].options?.onConflict).toBe('company_id,source_account')
})
it('keeps user_id distinct from company_id', async () => {
// Both are UUID strings, so the compiler cannot tell them apart: this is
// the same class of mistake that once put a user id in company_id.
const { supabase, upserts } = createRecordingSupabase()
await saveMappings(supabase, 'company-1', [makeMapping('1910', '1930')], 'user-1')
expect(upserts[0].rows[0].company_id).toBe('company-1')
expect(upserts[0].rows[0].user_id).toBe('user-1')
expect(upserts[0].rows[0].user_id).not.toBe(upserts[0].rows[0].company_id)
})
it('stamps user_id on every row of every batch past the 100-row chunk', async () => {
const { supabase, upserts } = createRecordingSupabase()
const mappings = Array.from({ length: 250 }, (_, i) =>
makeMapping(String(4000 + i), String(4000 + i)),
)
await saveMappings(supabase, 'company-1', mappings, 'user-1')
expect(upserts).toHaveLength(3)
expect(upserts.map((u) => u.rows.length)).toEqual([100, 100, 50])
for (const call of upserts) {
for (const row of call.rows) {
expect(row.user_id).toBe('user-1')
expect(row.company_id).toBe('company-1')
}
}
})
it('skips unmapped accounts but still stamps the ones it keeps', async () => {
const { supabase, upserts } = createRecordingSupabase()
await saveMappings(
supabase,
'company-1',
[
makeMapping('1910', '1930'),
makeMapping('9999', '', { targetName: '', confidence: 0, matchType: 'manual' }),
],
'user-1',
)
expect(upserts[0].rows).toHaveLength(1)
expect(upserts[0].rows[0].source_account).toBe('1910')
expect(upserts[0].rows[0].user_id).toBe('user-1')
})
it('does not touch the table when nothing is mapped', async () => {
const { supabase, upserts } = createRecordingSupabase()
await saveMappings(
supabase,
'company-1',
[makeMapping('9999', '', { confidence: 0, matchType: 'manual' })],
'user-1',
)
expect(upserts).toHaveLength(0)
})
})
describe('saveMappings: failures are surfaced, never swallowed', () => {
it('throws when the upsert is rejected by Postgres', async () => {
// supabase-js resolves with { error } instead of throwing. The old code
// never destructured it, so this exact rejection was invisible.
const { supabase } = createRecordingSupabase({
upsertError: { code: '23502', message: 'null value in column "user_id"' },
})
await expect(
saveMappings(supabase, 'company-1', [makeMapping('1910', '1930')], 'user-1'),
).rejects.toThrow(/kunde inte spara kontomappningar/i)
})
it('reports how many rows landed before the failing batch', async () => {
const { supabase } = createRecordingSupabase({
upsertError: { code: '23503', message: 'violates foreign key constraint' },
failOnCall: 2,
})
const mappings = Array.from({ length: 150 }, (_, i) =>
makeMapping(String(4000 + i), String(4000 + i)),
)
await expect(
saveMappings(supabase, 'company-1', mappings, 'user-1'),
).rejects.toThrow(/100 av 150/)
})
it('resolves quietly when every batch succeeds', async () => {
const { supabase, upserts } = createRecordingSupabase()
await expect(
saveMappings(supabase, 'company-1', [makeMapping('1910', '1930')], 'user-1'),
).resolves.toBeUndefined()
expect(upserts).toHaveLength(1)
})
})
describe('saveMappings: where user_id comes from', () => {
it('prefers the explicitly passed userId over the session', async () => {
// executeSIEImport and the MCP/API-key paths must win: their client is
// often a cookieless service client whose session would resolve to nobody.
const { supabase, upserts } = createRecordingSupabase({ sessionUserId: 'session-user' })
await saveMappings(supabase, 'company-1', [makeMapping('1910', '1930')], 'explicit-user')
expect(upserts[0].rows[0].user_id).toBe('explicit-user')
})
it('falls back to the session user when the caller omits userId', async () => {
// The POST /api/import/sie/mappings handler still calls with three
// arguments; its client is the cookie-session server client, so the
// fallback resolves a real user there.
const { supabase, upserts } = createRecordingSupabase({ sessionUserId: 'session-user' })
await saveMappings(supabase, 'company-1', [makeMapping('1910', '1930')])
expect(upserts[0].rows[0].user_id).toBe('session-user')
})
it('refuses to upsert at all when no user id can be resolved', async () => {
// Better a loud failure than a 23502 the caller cannot see: user_id is
// NOT NULL, so there is no row to write here.
const { supabase, upserts } = createRecordingSupabase({ sessionUserId: null })
await expect(
saveMappings(supabase, 'company-1', [makeMapping('1910', '1930')]),
).rejects.toThrow(/user_id saknas/i)
expect(upserts).toHaveLength(0)
})
})
+158
View File
@@ -564,6 +564,164 @@ describe('ensureFiscalPeriod validation', () => {
),
).rejects.toThrow(/överlappar men matchar inte/)
})
// BFL 3 kap. caps any räkenskapsår at 18 months (12 is the norm; 18 is the
// ceiling for a förlängt/omlagt year). #RAR used to be validated for start
// and end DAY only, so a 24-month räkenskapsår from a foreign system
// imported cleanly and stamped every voucher with an illegal period. The cap
// now mirrors validatePeriodDuration(), which the fiscal-periods routes,
// period-service and onboarding already enforce.
describe('18-month cap (BFL 3 kap.)', () => {
/** Queue for the happy path: day-1 start, so the earlier-period lookup is skipped. */
const createQueue = (newId: string) => [
{ data: null, error: null }, // containing check, no match
{ data: [], error: null }, // overlapping check, none
{ data: [], error: null }, // no predecessor in the continuity chain
{ data: { id: newId }, error: null }, // insert result
{ data: [], error: null }, // no successor to relink
]
it('allows a normal 12-month räkenskapsår (unaffected by the cap)', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany(createQueue('calendar-2026'))
const id = await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2026-01-01',
'2026-12-31',
)
expect(id).toBe('calendar-2026')
})
it('allows exactly 18 months (förlängt räkenskapsår, the legal maximum)', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany(createQueue('extended-first-year'))
const id = await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-07-01',
'2026-12-31',
)
expect(id).toBe('extended-first-year')
})
it('refuses 19 months, one month past the cap', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null }, // containing check, no match
{ data: [], error: null }, // overlapping check, none
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-06-01',
'2026-12-31',
),
).rejects.toThrow(/omfattar 19 månader.*högst 18 månader \(BFL 3 kap\.\)/s)
})
it('refuses a 24-month räkenskapsår and names the recovery path', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-01-01',
'2026-12-31',
),
).rejects.toThrow(/omfattar 24 månader/)
// The message has to tell an accountant who cannot edit the SIE file what
// to do instead: re-export one räkenskapsår per file from the source system.
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-01-01',
'2026-12-31',
),
).rejects.toThrow(/exportera om från källsystemet med ett räkenskapsår per fil/)
})
it('refuses before deleting an empty seeded period, leaving the company untouched', async () => {
// The over-long range overlaps an onboarding-seeded period that the
// importer would otherwise replace. The cap runs before that destructive
// delete, so a refused import costs the user nothing.
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null }, // containing check, no match
{
data: [
{
id: 'seeded-2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
name: 'Räkenskapsår 2026',
is_closed: false,
locked_at: null,
opening_balances_set: false,
},
],
error: null,
},
{ data: [], error: null }, // journal_entries: none, so the period is replaceable
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-01-01',
'2026-12-31',
),
).rejects.toThrow(/högst 18 månader/)
// containing + overlapping + journal_entries only: no delete, no insert.
expect(supabase.from).toHaveBeenCalledTimes(3)
})
it('checks every #RAR range it is handed, not just the first', async () => {
// A SIE migration walks one räkenskapsår per file (#RAR 0 each time), so
// the realistic failure is a valid current year followed by a broken
// prior year. Each range is validated on its own; a good year importing
// does not buy the next one a pass.
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany(createQueue('year-2026'))
const currentYear = await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2026-01-01',
'2026-12-31',
)
expect(currentYear).toBe('year-2026')
enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2024-01-01',
'2025-12-31', // 24 months masquerading as the prior year
),
).rejects.toThrow(/omfattar 24 månader/)
})
})
})
describe('linkOpeningBalanceEntryToPeriod', () => {
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from '@/tests/pg/setup'
import { getPool, runAsServiceRole, withUserContext } from '@/tests/pg/setup'
import {
seedCompany,
insertAuthUser,
@@ -10,17 +10,24 @@ import {
} from '@/tests/pg/fixtures'
// Migration 20260624120000_undo_sie_import_explicit_actor.sql makes
// undo_sie_import accept the authorising user as p_user_id and resolve the
// owner/admin gate against COALESCE(p_user_id, auth.uid()).
// undo_sie_import accept the authorising user as p_user_id.
//
// Why: the RPC now runs on the service-role client (to escape the 8s
// Why: the RPC runs on the service-role client (to escape the 8s
// statement_timeout on large imports). That client is cookie-less, so inside
// the RPC auth.uid() is NULL: before this fix the role lookup matched nothing
// the RPC auth.uid() is NULL: before that fix the role lookup matched nothing
// and the function ALWAYS raised "Only company owners and admins can undo SIE
// imports", breaking undo entirely on hosted.
//
// These tests call the function over the raw pool (no JWT context), which is
// exactly the auth.uid()-is-NULL situation the service client creates.
// Migration 20260727121000_undo_sie_import_caller_guard.sql then closes the
// hole the COALESCE(p_user_id, auth.uid()) shape opened: EXECUTE is granted
// to `authenticated`, so any signed-in PostgREST caller could pass an owner's
// UUID as p_user_id and impersonate them into the gate. p_user_id is now
// honored ONLY when auth.role() = 'service_role'; every other caller is
// pinned to its own auth.uid().
//
// These tests call the function under a simulated service-role context
// (runAsServiceRole), which is how the app's service client presents:
// auth.uid() NULL, auth.role() = 'service_role'.
async function insertCompletedImport(params: {
companyId: string
@@ -66,9 +73,11 @@ async function insertPostedImportEntry(params: {
}
async function callUndo(companyId: string, importId: string, actor: string | null) {
return getPool().query<{ deleted: number }>(
`SELECT public.undo_sie_import($1::uuid, $2::uuid, $3::uuid) AS deleted`,
[companyId, importId, actor],
return runAsServiceRole((client) =>
client.query<{ deleted: number }>(
`SELECT public.undo_sie_import($1::uuid, $2::uuid, $3::uuid) AS deleted`,
[companyId, importId, actor],
),
)
}
@@ -128,6 +137,64 @@ describe('undo_sie_import: explicit actor (service-client path)', () => {
)
})
it('ignores a spoofed p_user_id from an authenticated (non-service) caller', async () => {
// 20260727121000: a member passing the OWNER's UUID as p_user_id over an
// authenticated PostgREST session must stay pinned to their own
// auth.uid(), be rejected with 42501, and delete nothing. Same guard as
// replace_sie_import (20260727120000).
const { companyId, userId: ownerId, fiscalPeriodId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
const importId = await insertCompletedImport({ companyId, userId: ownerId, fiscalPeriodId })
const jeId = await insertPostedImportEntry({ companyId, userId: ownerId, fiscalPeriodId })
await withUserContext(memberId, async (client) => {
let raised: (Error & { code?: string }) | null = null
try {
await client.query(
`SELECT public.undo_sie_import($1::uuid, $2::uuid, $3::uuid)`,
[companyId, importId, ownerId],
)
} catch (err) {
raised = err as Error & { code?: string }
}
expect(raised, 'spoofed p_user_id must not authorize').not.toBeNull()
expect(raised!.message).toMatch(/owners and admins/i)
expect(raised!.code).toBe('42501')
})
// The gate fired before any mutation.
const { rows: impRows } = await getPool().query<{ status: string }>(
`SELECT status FROM public.sie_imports WHERE id = $1`,
[importId],
)
expect(impRows[0].status).toBe('completed')
const { rows: jeRows } = await getPool().query(
`SELECT 1 FROM public.journal_entries WHERE id = $1`,
[jeId],
)
expect(jeRows).toHaveLength(1)
})
it('does not grant EXECUTE to anon or PUBLIC (20260727121000 least privilege)', async () => {
const { rows } = await getPool().query<{
anon_can: boolean
public_can: boolean
authenticated_can: boolean
service_role_can: boolean
}>(
`SELECT has_function_privilege('anon', 'public.undo_sie_import(uuid,uuid,uuid)', 'EXECUTE') AS anon_can,
has_function_privilege('public', 'public.undo_sie_import(uuid,uuid,uuid)', 'EXECUTE') AS public_can,
has_function_privilege('authenticated', 'public.undo_sie_import(uuid,uuid,uuid)', 'EXECUTE') AS authenticated_can,
has_function_privilege('service_role', 'public.undo_sie_import(uuid,uuid,uuid)', 'EXECUTE') AS service_role_can`,
)
expect(rows[0].anon_can, 'anon must not be able to call undo_sie_import').toBe(false)
expect(rows[0].public_can, 'PUBLIC must not hold EXECUTE').toBe(false)
expect(rows[0].authenticated_can).toBe(true)
expect(rows[0].service_role_can).toBe(true)
})
it('still resolves the actor from auth.uid() when p_user_id is omitted (backward compat)', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { getPool, runAsServiceRole } from '@/tests/pg/setup'
import { seedCompany, insertDraftJournalEntry } from '@/tests/pg/fixtures'
// Migration 20260702154500_dimension_import_provenance_undo_lockstep.sql:
@@ -96,10 +96,15 @@ async function insertDimensionValue(params: {
return rows[0].id
}
// undo_sie_import honors p_user_id only for service_role callers
// (20260727121000), and these cases assert persisted state over the plain
// pool afterwards, so the call runs under a committing service-role context.
async function callUndo(companyId: string, importId: string, actor: string) {
return getPool().query<{ deleted: number }>(
`SELECT public.undo_sie_import($1::uuid, $2::uuid, $3::uuid) AS deleted`,
[companyId, importId, actor],
return runAsServiceRole((client) =>
client.query<{ deleted: number }>(
`SELECT public.undo_sie_import($1::uuid, $2::uuid, $3::uuid) AS deleted`,
[companyId, importId, actor],
),
)
}
+54 -6
View File
@@ -902,23 +902,39 @@ describe('parseBankFile: Handelsbanken format', () => {
expect(descriptions).not.toContain(expect.stringContaining('Prel'))
})
it('prefers transaktionsdatum over reskontradatum when both are present', () => {
// Handelsbanken has both columns; transaktionsdatum should be used
it('uses reskontradatum when both date columns are present', () => {
// Handelsbanken has both columns; reskontradatum (booking date) should be used
const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv')
// In our test data both dates are the same, but verify it selects dates properly
expect(result.transactions[0].date).toBe('2024-01-15')
})
it('uses transaktionsdatum as the primary date field', () => {
// Create data where reskontradatum differs from transaktionsdatum
it('uses reskontradatum (booking date), not transaktionsdatum, as the primary date field', () => {
// A card purchase booked two days after the swipe: the PSD2 / Enable Banking
// feed delivers this row on its booking_date (the 16th). Emitting the
// transaktionsdatum here would put the same affärshändelse in a different
// exact-date content-dedup bucket and insert it twice.
const diffDates = [
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
'2024-01-16;2024-01-15;PURCHASE;-100,00;5000,00',
'2026-03-16;2026-03-14;KORTKÖP CLAS OHLSON;-100,00;5000,00',
].join('\n')
const result = parseBankFile(diffDates, 'shb.csv')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[0].date).toBe('2026-03-16')
})
it('falls back to transaktionsdatum when reskontradatum is absent', () => {
const txOnly = [
'Transaktionsdatum;Text;Belopp;Saldo',
'2026-03-14;KORTKÖP CLAS OHLSON;-100,00;5000,00',
].join('\n')
const result = parseBankFile(txOnly, 'shb.csv')
expect(result.format).toBe('handelsbanken')
expect(result.transactions).toHaveLength(1)
expect(result.issues).toHaveLength(0)
expect(result.transactions[0].date).toBe('2026-03-14')
})
it('detects Handelsbanken CSV when a metadata preamble precedes the header', () => {
@@ -1013,6 +1029,38 @@ describe('parseBankFile: Länsförsäkringar format', () => {
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
it('uses Bokföringsdag (booking date), not Datum, when the two differ', () => {
// Same rationale as Handelsbanken: the PSD2 feed keys the row on its
// booking_date, so the CSV must emit the same date or the affärshändelse
// lands in two different exact-date dedup buckets and inserts twice.
const diffDates = [
'"Datum";"Bokföringsdag";"Typ";"Text";"Belopp";"Saldo"',
'"2026-03-14";"2026-03-16";"Kortköp";"CLAS OHLSON";"-100,00";"5 000,00"',
].join('\n')
const result = parseBankFile(diffDates, 'lf.csv')
expect(result.format).toBe('lansforsakringar')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].date).toBe('2026-03-16')
})
it('uses the Bokföringsdag column position on header-less files', () => {
// The header-less layout is pinned by isLFRow (two adjacent dates, comma
// number in field 4), so field 1 is Bokföringsdag. Header-ful and
// header-less exports of the same account must agree on the date, or the
// two upload paths duplicate each other.
const noHeaderDiffDates = [
'"2026-03-14";"2026-03-16";"Kortköp";"CLAS OHLSON";"-100,00";"5 000,00"',
'"2026-03-11";"2026-03-12";"Kortköp";"ICA MAXI";"-432,50";"5 100,00"',
].join('\n')
const result = parseBankFile(noHeaderDiffDates, 'lf.csv')
expect(result.format).toBe('lansforsakringar')
expect(result.transactions).toHaveLength(2)
expect(result.transactions[0].date).toBe('2026-03-16')
expect(result.transactions[1].date).toBe('2026-03-12')
})
})
describe('parseBankFile: ICA Banken format', () => {
@@ -47,21 +47,98 @@ describe('suggestColumnMapping', () => {
expect(result.balance).toBe(3) // remaining (all-positive) numeric column
})
it('handles the Handelsbanken private layout (Reskontra + Transaktionsdatum + Text)', () => {
it('handles the Handelsbanken private layout: picks Reskontradatum (booking), not Transaktionsdatum', () => {
// The two date columns deliberately DIFFER here: a card purchase swiped the
// 14th and booked the 16th. The dedicated Handelsbanken parser emits
// Reskontradatum (formats/handelsbanken.ts primaryDateIdx), so the manual
// "Annan CSV" mapping must pre-select the same column, otherwise the same
// statement dates the same affärshändelse two days apart depending on which
// upload path the user took, and the Saldo column no longer ties to the row.
const headers = ['Reskontradatum', 'Transaktionsdatum', 'Text', 'Belopp', 'Saldo']
const dataRows = [
['2024-01-15', '2024-01-15', 'SPOTIFY AB', '-99,00', '12345,67'],
['2024-01-14', '2024-01-14', 'HEMKÖP', '-432,50', '12444,67'],
['2024-01-16', '2024-01-14', 'SPOTIFY AB', '-99,00', '12345,67'],
['2024-01-15', '2024-01-13', 'HEMKÖP', '-432,50', '12444,67'],
]
const result = suggestColumnMapping(headers, dataRows)
expect(result.date).toBe(1) // prefers transaktionsdatum over reskontradatum
expect(result.date).toBe(0) // Reskontradatum (booking), NOT 1 (Transaktionsdatum)
expect(result.description).toBe(2) // Text
expect(result.amount).toBe(3) // Belopp, NOT 4 (Saldo)
expect(result.balance).toBe(4)
})
it('handles the Länsförsäkringar layout: picks Bokföringsdag, not the leading Datum', () => {
// Matches formats/lansforsakringar.ts, which resolves Bokföringsdag (field 1)
// and only falls back to Datum when no booking column exists.
const headers = ['Datum', 'Bokföringsdag', 'Typ', 'Text', 'Belopp', 'Saldo']
const dataRows = [
['2024-01-14', '2024-01-16', 'Kortköp', 'SPOTIFY AB', '-99,00', '12345,67'],
['2024-01-13', '2024-01-15', 'Kortköp', 'HEMKÖP', '-432,50', '12444,67'],
]
const result = suggestColumnMapping(headers, dataRows)
expect(result.date).toBe(1) // Bokföringsdag, NOT 0 (Datum)
expect(result.description).toBe(3) // Text
expect(result.amount).toBe(4)
expect(result.balance).toBe(5)
})
it('handles the Swedbank layout: picks the abbreviated Bokfdag over Transdag and Valutadag', () => {
// Swedbank is the decisive precedent: Transdag is present in the file and
// formats/swedbank.ts deliberately resolves Bokfdag. Matching by label also
// makes this deterministic instead of depending on Bokfdag happening to be
// the leftmost date-shaped column.
const headers = [
'Radnr', 'Clnr', 'Kontonr', 'Produkt', 'Valuta',
'Bokfdag', 'Transdag', 'Valutadag', 'Referens', 'Text', 'Belopp', 'Saldo',
]
const dataRows = [
['1', '8327', '1234567890', 'Företagskonto', 'SEK',
'2024-01-16', '2024-01-14', '2024-01-16', 'HEMKÖP', 'Kortköp', '-432.50', '12444.67'],
['2', '8327', '1234567890', 'Företagskonto', 'SEK',
'2024-01-15', '2024-01-15', '2024-01-15', 'LÖN', 'Insättning', '25000.00', '12877.17'],
]
const result = suggestColumnMapping(headers, dataRows)
expect(result.date).toBe(5) // Bokfdag, NOT 6 (Transdag) and NOT 7 (Valutadag)
expect(result.amount).toBe(10) // Belopp
expect(result.balance).toBe(11) // Saldo
})
it('FALLBACK: maps Transaktionsdatum when the export carries no booking date', () => {
// transaktionsdatum is demoted to the last tier, not removed: a file that
// only ever carried a transaction date must still map without user input.
const headers = ['Transaktionsdatum', 'Text', 'Belopp']
const dataRows = [
['2024-01-14', 'SPOTIFY AB', '-99,00'],
['2024-01-13', 'HEMKÖP', '-432,50'],
]
const result = suggestColumnMapping(headers, dataRows)
expect(result.date).toBe(0) // Transaktionsdatum: the only date column there is
expect(result.description).toBe(1)
expect(result.amount).toBe(2)
})
it('FALLBACK: maps a bare Datum column when that is the only date column', () => {
// Nordea / Skandia / Nordea Business format D shape.
const headers = ['Datum', 'Transaktion', 'Belopp', 'Saldo']
const dataRows = [
['2024-01-15', 'SPOTIFY AB', '-99,00', '12345,67'],
['2024-01-14', 'HEMKÖP', '-432,50', '12444,67'],
]
const result = suggestColumnMapping(headers, dataRows)
expect(result.date).toBe(0)
expect(result.amount).toBe(2)
expect(result.balance).toBe(3)
})
it('does not invent a balance column when the file has none', () => {
const headers = ['Datum', 'Text', 'Belopp']
const dataRows = [
+43 -3
View File
@@ -202,13 +202,53 @@ const SUGGEST_DATE_PATTERNS = [
/^\d{8}$/,
]
/** Pick the best date column by header label, preferring transaction date over value date. */
/**
* Booking-date header labels: Bokföringsdag / Bokföringsdatum, tolerating an
* un-decoded 'o' for 'ö' and Swedbank's abbreviated 'Bokfdag'. Superset of the
* SEB and Länsförsäkringar parsers' detectors.
*/
const BOOKING_DATE_RE = /bokf((ö|o)rings)?da(g|tum)/
/**
* Pick the best date column by header label, preferring the BOOKING date.
*
* Tier order is booking-date first, which is what every dedicated parser in
* this directory emits: Handelsbanken picks Reskontradatum over
* Transaktionsdatum, Länsförsäkringar picks Bokföringsdag over Datum, and
* Swedbank picks Bokfdag even though Transdag sits right next to it in the
* file. A Handelsbanken or Swedbank export routed through the manual
* "Annan CSV" mapping must pre-select the same column the dedicated parser
* would, otherwise the two upload paths date the same affärshändelse
* differently.
*
* The emitted date is not cosmetic: it feeds generateExternalId and the
* exact-date content-dedup bucket. The PSD2 / Enable Banking feed for the same
* account keys every row on the ASPSP's booking_date and cannot be moved off it
* (Berlin Group exposes no stable transaction date, and the value_date path
* caused the June 2026 fleet-wide re-import; see
* extensions/general/enable-banking/lib/sync.ts). Booking date is also what the
* Saldo column ties to: dating a row on the card-swipe date desynchronizes the
* 19xx balance from the bank statement. A user who wants the purchase dated the
* 14th books it from the kvitto, not from the bank feed.
*
* A bare 'datum' outranks 'transaktionsdatum' because in every single-date
* export here (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. Files carrying both plus a real booking column
* resolve at tier 1 or 2 before either is reached.
*
* transaktionsdatum / transdag stay as the LAST tier: a fallback, not a
* preference. A file that carries only a transaction date must still map.
*
* Every tier skips headers containing 'valuta', so Valutadag / Valutadatum (the
* value date) and a plain Valuta (currency) column are never picked.
*/
function pickDateHeader(headers: string[]): number {
const tiers: Array<(h: string) => boolean> = [
(h) => h.includes('transaktionsdatum'),
(h) => BOOKING_DATE_RE.test(h),
(h) => h.includes('reskontradatum'),
(h) => /bokf(ö|o)ringsda(g|tum)/.test(h),
(h) => h === 'datum' || h === 'date',
(h) => h.includes('transaktionsdatum') || h.includes('transdag'),
]
for (const match of tiers) {
const idx = headers.findIndex((h) => match(h) && !h.includes('valuta'))

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