Check/salary bankid api (#892)

* fix(bankid): harden login/signup flow — polling, signup rollback, metadata merge, enrichment lookup

- middleware: read BankID enrichment from the bankid_enrichment table (the
  extension_data path has been dead since the multi-tenant refactor), so
  company-less BankID users land on /select-company instead of the manual wizard
- BankIdAuth: hard 6-min poll deadline; every failed poll counts toward the
  give-up limit; guard overlapping ticks so completion runs exactly once
  (a double /complete regenerated the magic link and invalidated the first,
  failing logins intermittently); retry clicks wait out the start cooldown
  instead of silently no-oping; Swedish messages for 429/unknown start errors
- bankid/complete: all-or-nothing signup — delete the created user when the
  identity insert, app_metadata update, or magic-link generation fails, so a
  retry starts clean instead of hitting account_exists with an unusable account
- bankid/unlink: read-merge-write app_metadata so has_password survives unlink
  (BankID-only users could otherwise strand themselves with no login method)
- login: BankID "create account" CTA now links to /register instead of
  dismissing the notice; sv.json: fix missing å/ä/ö in settings_bankid strings

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

* docs: move secondary guides into docs/, delete dead root files

Move DOCKER.md, SELF-HOSTING.md, WHITELABEL.md and extensions.md
(renamed EXTENSIONS.md) into a new docs/ folder and update all path
references (README, setup.sh, .dockerignore image rules, docker-publish
workflow comment, _example-branding, lib/branding/service.ts).

Delete two dead root files: customer.json (stray API-test payload) and
findings.md (point-in-time swarm audit export, criticals already filed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(api): security & correctness hardening + withRouteContext MFA migration across API routes

Audit of ~100 app/api routes. Highlights:

Security
- agent/conversations: list leaked colleagues' titles + message previews
  (company-scoped RLS, no user filter) -> user-scoped
- calendar/feed PUT: raw body into .update() allowed feed_token fixation on a
  public unauthenticated URL -> strict schema, content toggles only
- bokslutsdispositioner: unbounded schablonintaktRate could inflate the
  IL 30 kap 25% periodiseringsfond cap base -> bounded
- agent profile/composer/onboarding: viewers could rewrite the agent profile
  while sibling /verify blocked them -> role-gated

Correctness
- account-totals / listAssets: unbounded queries silently truncated at 1000
  rows (under-counted money; skipped assets at year-end depreciation) ->
  fetchAllRows with stable order (+3 more pagination fixes)
- voucher-gaps: swallowed detect_voucher_gaps RPC errors (BFNAR gap view could
  show "no gaps" when the check never ran) -> surfaced
- 5 phantom-success writes (OK on zero matched rows) fixed
- assets K3 component-sum validated against stale acquisition_cost -> fixed
- invite silent email-send failure -> response carries email_sent;
  deadlines/calendar cast-then-check JSON crashes -> Zod

Convention
- ~44 legacy routes converted to withRouteContext (MFA); added Zod validation,
  corrected status codes, console.* -> lib/logger

Response shapes preserved for existing callers. ~110 new tests.

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

* feat(bookkeeping): save a booking as a reusable template from Bokför direkt

Add a "Spara som mall" action to the manual booking dialog so users can
capture a kontering they just worked out as a booking template — right
where they figured out how something should be booked.

- derive amount-parameterised template lines from the concrete booking
  (settlement = the non-VAT leg nearest the total, 26xx = a VAT line with
  its rate snapped to the nearest standard rate, the rest = business
  ratios; line labels come from the loaded BAS chart)
- extract the shared TemplateForm out of BookingTemplatesPanel so the
  booking dialog reuses the same editor, live preview and convertibility
  hints instead of duplicating them
- save via the existing POST /api/settings/booking-templates endpoint

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

* fix(bokslut): render arsredovisning RR/BR at ÅRL post level — no kontonummer

Bolagsverket rejected a user's filed årsredovisning with "Balansräkning
och resultaträkning ska inte innehålla kontonummer": the PDF built every
statement row as per-account "1930 Företagskonto" lines while the iXBRL
filing path already aggregated to statutory posts, so the two artifacts
diverged.

The PDF statements now derive from the same K2 risbs mapping the iXBRL
document uses (mapTrialBalancesToK2), via a new statement-rows.ts that
emits post-level rows in uppställningsform order for both the K2 and K3
templates. Also fixed along the way:

- Jämförelseår column (ÅRL 3:5 §) — previous-year trial balances now load
  and render; the old PDF had no comparatives at all.
- mapping.warnings (unmapped accounts, RR ≠ 2099, obalans, reclass
  nudges) flow into ArsredovisningData.warnings so the wizard flags a
  non-fileable document before download.
- Flerårsöversikt current/previous year overridden with the mapper's
  strict-3000–3799 Nettoomsattning, mirroring build-input's
  duplicate-fact rule, so the FB table ties to the RR.
- FB eget kapital-table is post-level and drops obeskattade reserver
  (never eget kapital); K3 equity-changes statement uses real prior-year
  opening balances with derived utdelning/nyemission residuals that tie
  the roll-forward exactly to booked UB.
- build-input dedupes warnings now that the PDF path runs the same
  mapping.

Regression test asserts no RR/BR label ever contains a four-digit
account number again.

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

* fix(reports): diagnose untransferred prior-year results behind balance-sheet differens

Prod incident (97 kr): a multi-year SIE migration lacked one year's
omforing av arets resultat; the residual corrupted every later derived
opening balance and Balansrakningen showed a bare "Differens: 97 kr"
with no explanation. Continuity checking cannot catch this failure mode
(prior-year UB and derived IB match per-account by construction) - the
invariant that actually breaks is per-year P&L = 0 for all non-latest
years.

- lib/reports/imbalance-diagnosis.ts: shared detector
  (findUntransferredResults + buildImbalanceDiagnosis)
- Balansrakning/Balansrapport attach imbalance_diagnosis when unbalanced,
  naming the exact culprit years; rendered in web views + PDF; MCP
  gnubok_get_balance_sheet inherits the field via spread
- SIE import: parse-time warning when a completed year's vouchers leave
  a P&L residual, plus a post-import DB walk surfacing culprits as
  warnings and structured details.untransferredResults; the Arcim
  migration workspace previously dropped result.warnings entirely and
  now renders them
- opening-balance/correct: pre-flight the company lock date and return
  409 OB_COMPANY_LOCK_DATE (retryable: false, lock date interpolated in
  the client message) instead of the retryable 500 that invited blind
  retries; catch-path maps a raced trigger rejection to the same code

Diagnosis runs only on unbalanced paths (zero cost when healthy) and
never fails the report or the import. No migration, nothing persisted.

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

* fix: production error remediation — FX rates, deadlines, log levels, correction relink

Batch of fixes for recurring Vercel runtime errors:

- Riksbanken FX rates: persistent read-through cache (exchange_rates
  table), one retry honoring Retry-After on 429/5xx, bounded ingest
  concurrency, and an honest fallback — most recent cached observation
  or null, never a hardcoded rate silently booked into amount_sek.
  Unrated transactions stay repairable via refresh-exchange-rate.
- Tax deadline regeneration inserts replacement rows before deleting
  the superseded set, so a failed insert no longer wipes a company's
  deadlines (the 23502 user_id regression did exactly that). Migration
  makes deadlines.user_id nullable for system-generated rows.
- Route wrappers + errorResponse log 4xx outcomes at warn so only
  genuine 5xx reach Vercel's runtime-error clustering; client-supplied
  /api/log telemetry demoted to warn as well.
- application/json documents (raw PSD2 responses archived per BFL)
  validate as parseable JSON with object/array root instead of always
  failing the magic-byte check.
- correctEntry surfaces document-relink failures to callers, and the
  BFL document-immutability trigger now allows relinking underlag from
  a reversed entry to its correction (migration + pg test).
- Middleware clears stale session cookies on /api requests too, using
  scope 'local' so cleanup doesn't re-trigger the failed token refresh.

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

* fix(skatteverket): persist token health and stop retrying dead consents

Terminal auth errors (SESSION_EXPIRED, REFRESH_EXHAUSTED, MISSING_SCOPE,
TOKEN_CORRUPTED) mark the token row needs_reconsent with the error code
and timestamp — SKV per-flow refresh tokens live 65 minutes, so once
expired nothing recovers without a fresh BankID consent. The AGI
kvittens and skattekonto sync crons skip flagged connections instead of
failing every night, and the settings panel prompts for re-consent
proactively. A successful reconnect resets the row to active.

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

* fix(banking): allocate distinct BAS ledger slots for PSD2 mirror accounts

A bank returning N same-currency accounts used to map them all onto the
currency default (1930/1932/1933/1934), tripping the UNIQUE
(company_id, ledger_account) constraint per-account — swallowed errors
left accounts silently unmirrored. allocatePsd2LedgerAccount now hands
out the currency default first, then free 1931–1959 sub-account slots,
skipping slots held by any existing row.

- Callback persists allocations to accounts_data so the picker pre-fills
  reality; reconnect reuses previously mirrored ledgers instead of
  re-deriving (a user remap to 1935 survives).
- Selection save resolves effective ledgers up front and rejects
  duplicates or cross-connection conflicts with a 400 instead of
  silently skipping the mirror.
- Bank error codes + psu_type are forwarded to the settings page for
  every OAuth error, keying the Handelsbanken corporate fullmakt
  guidance.

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

* fix(agent): stage exact journal lines on categorization previews

Categorization previews only carried debit/credit accounts, the GROSS
amount, and separate VAT rows — read together that looks like an
unbalanced 'gross on cost account + VAT debit' entry, and it misled
both users and agents into rejecting correct proposals. The MCP
preview and the pending-operation PATCH now materialize the exact
lines the commit executor will post (net cost line, VAT line, gross
bank line, SEK) via buildTransactionEntryLines, and PATCH re-derives
them from the new mapping instead of spreading stale staged lines.
ApprovalCard and /pending render the verifikat lines, falling back to
the legacy summary only for operations staged before this fix.

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

* feat(bookkeeping): prune unused imported accounts from the chart

SIE imports routinely bring in hundreds of accounts that were never
used and clutter the kontoplan. New account_usage_counts RPC (one
grouped query instead of a count per account) backs GET
/api/bookkeeping/accounts/usage, and POST /api/bookkeeping/accounts/prune
deletes zero-usage accounts — dry-run first, then an explicit account
list capped at 2000. Accounts with journal lines are skipped, never
deleted. The chart manager shows a usage column and a prune dialog
grouping custom accounts vs unused BAS-seeded ones.

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

* feat(api): carry dimensions through v1 invoice and supplier-invoice surfaces

Credit-note creation now copies default_dimensions and per-line
dimensions from the original, so the reversing journal entry nets
against the same dimension cells instead of dropping them. List/detail
responses expose the dimension fields, and the OpenAPI spec snapshot
follows.

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

* perf: batch serial Supabase round-trips on hot dashboard paths

Every dashboard render pays the layout's query chain, so serialized
awaits are direct wall-clock: the layout, chat conversation, invoice
detail, supplier detail, select-company, and agent-onboarding pages now
run their independent lookups in parallel batches, and
getCompanyCapabilities folds its disabled-config read into the same
round-trip. JournalEntryList hydrates the saved fiscal-year scope
optimistically instead of serializing the first entries fetch behind
the fiscal-periods request. The supplier detail page filters invoices
server-side via a new supplier_id query param instead of fetching the
whole company ledger, and the invoice editor (with its framer-motion
dependency) lazy-loads so it stops shipping with the invoice list
bundle.

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

* feat(salary): one-click runs, payslip delivery, payments settings, run cockpit

Salary P1 batch, driving the 20-click flow toward 3 clicks:

- One-click 'Starta lönekörning': POST /api/salary/runs accepts an
  empty body and resolves defaults server-side — period follows the
  latest non-corrected run, payment date from the new
  salary_pay_day setting, series from the per-source-type map. The
  separate /salary/runs/new page is gone.
- Run detail page rebuilt as a step-railed cockpit (progress rail,
  KPI cards, employee ledger, journal preview) on a deliberately
  wider canvas; components extracted to components/salary/run/.
- Payslip delivery: tokenized public payslip pages (/payslip/[token],
  backed by salary_payslip_links) plus per-employee email send with
  PDF — employees need no account, and the middleware exempts the
  route from auth redirects.
- Payments settings: salary pay day, default bank, and pain.001 vs
  Bankgirot Lön format with per-bank upload instructions and an LB
  sunset warning (banks retire LB during 2026).
- AGI panel: full submission status flows (stale drafts, signing
  links, kvittens polling, error reports); tax payment panel with
  skattekonto shortcut and mark-as-paid.
- Salary calendar bulk editing, employee benefits/tax-card polish,
  municipality tax-table lookup improvements.

messages/sv+en also carry the strings for the account-prune,
skatteverket-reconsent, and banking surfaces committed just before
this.

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

* chore: adopt Next 16 proxy.ts convention + repo housekeeping

- Rename middleware.ts to proxy.ts with the proxy() export (Next 16
  renamed the middleware convention; behavior unchanged).
- Exclude dev_docs/ from tsconfig so stray snippets in planning docs
  don't break the build type-check.
- Ratchet antipatterns-baseline down (raw-route-auth 165 → 119) to
  lock in the withRouteContext migration from 5cfd2b76.
- template-library uses roundOre() instead of inline rounding.
- database.md: drop account_balances from the key-tables list.

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

* fix(bookkeeping): robust service-role detection in correction document relink

relink_documents_to_correction() keyed its service-role branch on auth.role(),
which reads the singular request.jwt.claim.role GUC that PostgREST v10+ and the
pg-real harness no longer populate. Genuine service-role callers (pending-ops
executor / MCP approve) landed in the auth gate and could not relink underlag.
Read the role from the request.jwt.claims JSON directly, mirroring the canonical
link_voucher_rpcs_tenant_guard convention. Validated on staging.

Also: harden the salary run page's error paths (res.json().catch) against
non-JSON error bodies, and roll back the pg-real service-role case in finally so
an aborted transaction cannot poison a pooled connection for the next test.

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

* fix(documents): restore journal_entry_line_id link durability (BFL 7 kap)

Migration 20260704103000 rewrote enforce_document_journal_entry_immutability to
guard journal_entry_id but left journal_entry_line_id to the metadata trigger,
which exempts draft-linked docs -- and the entry-level trigger only fired on
UPDATE OF journal_entry_id, so a line-id-only UPDATE never invoked it at all.
That let a set journal_entry_line_id be cleared to NULL, breaking the "link
durable from first set" invariant (document-immutability.pg regression).

Widen the trigger to fire on journal_entry_line_id too and guard it with the
same uuid-durability rule as journal_entry_id (setting NULL -> uuid stays
allowed; clearing/re-pointing a set value is blocked, status-independent). The
correction-relink GUC path, which legitimately clears line_id when moving
underlag to the posted correction, stays exempt. Validated on staging.

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

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-05 03:05:09 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ec27228a8e
commit 2c2743eb79
269 changed files with 17974 additions and 6481 deletions
+58
View File
@@ -4,6 +4,7 @@ import { normalizeVatNumber } from '@/lib/vat/vat-number'
import { isSaneDateString } from '@/lib/utils'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import type { AuditAction } from '@/types'
// ============================================================
// Shared primitives
@@ -1411,6 +1412,14 @@ export const UpdateSettingsSchema = z.object({
dimensions_enabled: z.boolean().optional(),
// Salary payment file
preferred_payment_format: z.enum(['bg_lb', 'pain001']).optional(),
// Salary settings (migration 20260703190000). Day of month salaries are
// paid (1–28 so it exists in every month) and the default bank whose
// upload instructions the payment-file panel pre-selects.
salary_pay_day: z.number().int().min(1).max(28).optional(),
salary_default_bank: z
.enum(['swedbank', 'seb', 'handelsbanken', 'nordea', 'other'])
.nullable()
.optional(),
}).refine(
(data) => {
// BFL 3 kap.: Enskild firma must have fiscal year starting January
@@ -1513,6 +1522,19 @@ export const UpdateAccountSchema = z.object({
sru_code: z.string().nullable().optional(),
})
// Looser account-number shape than the 4-digit primitive on purpose: imported
// charts can carry non-standard numbers (sub-accounts like '19301'), and those
// are exactly the rows the prune flow exists to remove.
export const PruneAccountsSchema = z
.object({
dry_run: z.boolean(),
account_numbers: z.array(z.string().min(1).max(10)).max(2000).optional(),
})
.refine((v) => v.dry_run || (v.account_numbers?.length ?? 0) > 0, {
message: 'account_numbers is required when dry_run is false',
path: ['account_numbers'],
})
// ============================================================
// Bank reconciliation schemas
// ============================================================
@@ -1643,6 +1665,28 @@ export const PendingOperationsBulkSchema = z.object({
ids: z.array(z.string().uuid()).min(1).max(100),
})
// ============================================================
// Audit trail schemas
// ============================================================
// `satisfies` keeps every filter value a member of the AuditAction union —
// adding a bogus value here fails the typecheck.
const auditActions = [
'INSERT', 'UPDATE', 'DELETE', 'COMMIT', 'REVERSE', 'CORRECT',
'LOCK_PERIOD', 'CLOSE_PERIOD', 'DOCUMENT_DELETE_BLOCKED',
'RETENTION_BLOCK', 'SECURITY_EVENT', 'INTEGRITY_FAILURE',
] as const satisfies readonly AuditAction[]
export const AuditTrailQuerySchema = z.object({
action: z.enum(auditActions).optional(),
table_name: z.string().min(1).optional(),
record_id: z.string().min(1).optional(),
from_date: isoDate.optional(),
to_date: isoDate.optional(),
page: z.coerce.number().int().min(1).default(1),
page_size: z.coerce.number().int().min(1).max(200).default(50),
})
// ============================================================
// Voucher gap schemas
// ============================================================
@@ -2011,6 +2055,20 @@ export const CreateSalaryRunSchema = z.object({
notes: z.string().max(2000).optional(),
})
// One-click variant for the dashboard route: all fields optional — the route
// resolves defaults server-side (period = month after the latest
// non-corrected run, payment date from company_settings.salary_pay_day,
// series from the per-source-type map), so "Starta lönekörning" can POST {}.
// The v1 REST surface keeps the strict CreateSalaryRunSchema above (it
// inserts the fields verbatim and must 400 on omissions, not 500).
export const CreateSalaryRunWithDefaultsSchema = z.object({
period_year: z.number().int().min(2020).max(2100).optional(),
period_month: z.number().int().min(1).max(12).optional(),
payment_date: isoDate.optional(),
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').optional(),
notes: z.string().max(2000).optional(),
})
export const AddEmployeeToRunSchema = z.object({
employee_id: uuid,
hours_worked: z.number().nonnegative().optional(),
+9 -2
View File
@@ -485,8 +485,15 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
return stampHeaders(response, requestId)
} catch (err) {
log.error('op failed', err as Error, { durationMs: Date.now() - start })
return await v1ErrorResponse(err, log, { requestId })
// Resolve the envelope first so the log level can follow the mapped
// status — thrown 4xx domain errors are expected outcomes (warn), only
// 5xx are runtime errors. v1ErrorResponse logs the error itself at the
// same threshold.
const response = await v1ErrorResponse(err, log, { requestId })
const meta = { durationMs: Date.now() - start, status: response.status }
if (response.status < 500) log.warn('op failed', err as Error, meta)
else log.error('op failed', err as Error, meta)
return response
}
}
}
+9 -2
View File
@@ -164,8 +164,15 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
})
return response
} catch (err) {
errLog.error('op failed', err as Error, { durationMs: Date.now() - start })
return errorResponse(err, errLog, { requestId })
// Resolve the envelope first so the log level can follow the mapped
// status — thrown 4xx domain errors are expected outcomes (warn), only
// 5xx are runtime errors. errorResponse logs the error itself at the
// same threshold.
const response = errorResponse(err, errLog, { requestId })
const meta = { durationMs: Date.now() - start, status: response.status }
if (response.status < 500) errLog.warn('op failed', err as Error, meta)
else errLog.error('op failed', err as Error, meta)
return response
}
}
}
@@ -29,6 +29,7 @@ function makeMinimalK3Data(): ArsredovisningData {
period_start: '2025-01-01',
period_end: '2025-12-31',
},
previous_period: null,
accounting_framework: 'k3',
forvaltningsberattelse: {
description: 'Bolaget bedriver konsultverksamhet inom IT.',
@@ -38,32 +39,52 @@ function makeMinimalK3Data(): ArsredovisningData {
{ year: '2025', net_revenue: 500_000, result_after_financial: 300_000, soliditet_pct: 80.0 },
],
egen_kapital_changes: [
{ label: '2081 Aktiekapital', amount: 50_000 },
{ label: '2099 Årets resultat', amount: 300_000 },
{ label: 'Aktiekapital', amount: 50_000 },
{ label: 'Årets resultat', amount: 300_000 },
],
resultatdisposition: 'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
agm_date: '2026-06-15',
},
resultatrakning: [
{ label: '3001 Försäljning', amount: 500_000 },
{ label: 'Summa rörelseintäkter', amount: 500_000, is_total: true },
{ label: '4010 Inköp', amount: -200_000 },
{ label: 'Rörelseresultat', amount: 300_000, is_total: true },
{ label: 'Årets resultat', amount: 300_000, is_total: true },
{
label: 'Rörelseintäkter, lagerförändringar m.m.',
current: null,
previous: null,
is_heading: true,
},
{ label: 'Nettoomsättning', current: 500_000, previous: null, indent: 1 },
{
label: 'Summa rörelseintäkter, lagerförändringar m.m.',
current: 500_000,
previous: null,
is_total: true,
},
{ label: 'Rörelsekostnader', current: null, previous: null, is_heading: true },
{ label: 'Råvaror och förnödenheter', current: -200_000, previous: null, indent: 1 },
{ label: 'Summa rörelsekostnader', current: -200_000, previous: null, is_total: true },
{ label: 'Rörelseresultat', current: 300_000, previous: null, is_total: true },
{ label: 'Årets resultat', current: 300_000, previous: null, is_total: true },
],
balansrakning: {
assets: [
{ label: 'Omsättningstillgångar', amount: 600_000, is_total: true, indent: 0 },
{ label: '1930 Bank', amount: 600_000, indent: 1 },
{ label: 'Omsättningstillgångar', current: null, previous: null, is_heading: true },
{ label: 'Kassa och bank', current: null, previous: null, is_heading: true, indent: 1 },
{ label: 'Kassa och bank', current: 600_000, previous: null, indent: 2 },
{ label: 'Summa kassa och bank', current: 600_000, previous: null, is_total: true, indent: 1 },
{ label: 'Summa tillgångar', current: 600_000, previous: null, is_total: true },
],
total_assets: 600_000,
total_assets_previous: null,
equity_liabilities: [
{ label: 'Eget kapital', amount: 600_000, is_total: true, indent: 0 },
{ label: '2081 Aktiekapital', amount: 50_000, indent: 1 },
{ label: '2099 Årets resultat', amount: 300_000, indent: 1 },
{ label: '2098 Balanserade vinstmedel', amount: 250_000, indent: 1 },
{ label: 'Eget kapital', current: null, previous: null, is_heading: true },
{ label: 'Aktiekapital', current: 50_000, previous: null, indent: 2 },
{ label: 'Balanserat resultat', current: 250_000, previous: null, indent: 2 },
{ label: 'Årets resultat', current: 300_000, previous: null, indent: 2 },
{ label: 'Summa eget kapital', current: 600_000, previous: null, is_total: true },
{ label: 'Summa eget kapital och skulder', current: 600_000, previous: null, is_total: true },
],
total_equity_liabilities: 600_000,
total_equity_liabilities_previous: null,
},
noter: [
{
@@ -138,6 +159,22 @@ describe('ArsredovisningK3PDF', () => {
expect(buffer.length).toBeGreaterThan(0)
})
it('renders the jämförelseår column when previous_period is set', async () => {
const data = makeMinimalK3Data()
data.previous_period = {
name: '2024',
period_start: '2024-01-01',
period_end: '2024-12-31',
}
data.resultatrakning = data.resultatrakning.map((row) =>
row.is_heading ? row : { ...row, previous: 100_000 },
)
data.balansrakning.total_assets_previous = 400_000
const doc = ArsredovisningK3PDF({ data })
const buffer = await renderToBuffer(doc)
expect(buffer.length).toBeGreaterThan(0)
})
it('renders with reconciled and unreconciled cash flow', async () => {
const data = makeMinimalK3Data()
data.kassaflodesanalys!.reconciliation.is_reconciled = false
@@ -0,0 +1,201 @@
/**
* ÅRL post-level statement rows for the årsredovisning PDF.
*
* The regression that matters most here: NO label may carry a BAS account
* number. A user filed an årsredovisning whose RR/BR listed per-account rows
* ("1930 Företagskonto", "3001 Försäljning …") and Bolagsverket rejected it
* with "Balansräkning och resultaträkning ska inte innehålla kontonummer".
* The rows must be the statutory posts from the K2 risbs mapping — the same
* mapping the iXBRL filing uses — so PDF and digital filing cannot diverge.
*/
import { describe, it, expect } from 'vitest'
import {
mapTrialBalancesToK2,
type TrialBalanceRowLike,
type TrialBalancePair,
} from '@/lib/bokslut/ixbrl/k2-mapper'
import { buildRrRows, buildBrRows } from '../statement-rows'
function tbRow(
account: string,
name: string,
opts: { debit?: number; credit?: number },
): TrialBalanceRowLike {
return {
account_number: account,
account_name: name,
closing_debit: opts.debit ?? 0,
closing_credit: opts.credit ?? 0,
}
}
/**
* Balanced fixture year: 600 000 bank = 50 000 aktiekapital + 250 000
* balanserat + 300 000 årets resultat. RR: 520 600 omsättning − 200 000
* kostnader − 20 600 skatt = 300 000.
*/
function currentPair(): TrialBalancePair {
const full = [
tbRow('1930', 'Företagskonto', { debit: 600_000 }),
tbRow('2081', 'Aktiekapital', { credit: 50_000 }),
tbRow('2091', 'Balanserad vinst', { credit: 250_000 }),
tbRow('2099', 'Årets resultat', { credit: 300_000 }),
]
const preClosing = [
...full.filter((r) => r.account_number !== '2099'),
tbRow('3001', 'Försäljning varor 25%', { credit: 520_600 }),
tbRow('4010', 'Inköp material', { debit: 200_000 }),
tbRow('8910', 'Skatt på årets resultat', { debit: 20_600 }),
]
return { full, preClosing }
}
function previousPair(): TrialBalancePair {
const full = [
tbRow('1930', 'Företagskonto', { debit: 300_000 }),
tbRow('2081', 'Aktiekapital', { credit: 50_000 }),
tbRow('2091', 'Balanserad vinst', { credit: 100_000 }),
tbRow('2099', 'Årets resultat', { credit: 150_000 }),
]
const preClosing = [
...full.filter((r) => r.account_number !== '2099'),
tbRow('3001', 'Försäljning varor 25%', { credit: 250_000 }),
tbRow('4010', 'Inköp material', { debit: 100_000 }),
]
return { full, preClosing }
}
describe('buildRrRows / buildBrRows — no kontonummer regression', () => {
it('no RR or BR label contains a BAS account number', () => {
const mapping = mapTrialBalancesToK2(currentPair(), previousPair())
const rr = buildRrRows(mapping)
const { assets, equityLiabilities } = buildBrRows(mapping)
for (const row of [...rr, ...assets, ...equityLiabilities]) {
// Catches both "1930 Företagskonto" prefixes and any stray 4-digit
// account reference inside a label.
expect(row.label).not.toMatch(/\d{4}/)
}
})
})
describe('buildRrRows', () => {
it('follows the ÅRL uppställningsform order with posts and subtotals', () => {
const mapping = mapTrialBalancesToK2(currentPair(), null)
const labels = buildRrRows(mapping).map((r) => r.label)
const order = [
'Rörelseintäkter, lagerförändringar m.m.',
'Nettoomsättning',
'Summa rörelseintäkter, lagerförändringar m.m.',
'Rörelsekostnader',
'Råvaror och förnödenheter',
'Summa rörelsekostnader',
'Rörelseresultat',
'Summa finansiella poster',
'Resultat efter finansiella poster',
'Resultat före skatt',
'Skatt på årets resultat',
'Årets resultat',
]
const indices = order.map((label) => labels.indexOf(label))
expect(indices).not.toContain(-1)
expect([...indices].sort((a, b) => a - b)).toEqual(indices)
})
it('shows cost posts with a presentational minus', () => {
const mapping = mapTrialBalancesToK2(currentPair(), null)
const rr = buildRrRows(mapping)
expect(rr.find((r) => r.label === 'Råvaror och förnödenheter')?.current).toBe(-200_000)
expect(rr.find((r) => r.label === 'Skatt på årets resultat')?.current).toBe(-20_600)
expect(rr.find((r) => r.label === 'Summa rörelsekostnader')?.current).toBe(-200_000)
})
it('skips zero posts but keeps Nettoomsättning and all subtotals', () => {
const mapping = mapTrialBalancesToK2(currentPair(), null)
const labels = buildRrRows(mapping).map((r) => r.label)
expect(labels).not.toContain('Handelsvaror')
expect(labels).not.toContain('Övriga rörelseintäkter')
expect(labels).toContain('Nettoomsättning')
expect(labels).toContain('Summa finansiella poster')
})
it('omits the Bokslutsdispositioner section when no dispositions are booked', () => {
const mapping = mapTrialBalancesToK2(currentPair(), null)
const labels = buildRrRows(mapping).map((r) => r.label)
expect(labels).not.toContain('Bokslutsdispositioner')
expect(labels).not.toContain('Summa bokslutsdispositioner')
})
it('ties Årets resultat to the mapping total and the booked 2099', () => {
const mapping = mapTrialBalancesToK2(currentPair(), null)
const rr = buildRrRows(mapping)
const aretsResultat = rr[rr.length - 1]
expect(aretsResultat.label).toBe('Årets resultat')
expect(aretsResultat.is_total).toBe(true)
expect(aretsResultat.current).toBe(mapping.totals.aretsResultat.current)
expect(aretsResultat.current).toBe(300_000)
})
it('fills the jämförelseår column when a previous year exists, null otherwise', () => {
const withPrev = buildRrRows(mapTrialBalancesToK2(currentPair(), previousPair()))
expect(withPrev.find((r) => r.label === 'Nettoomsättning')?.previous).toBe(250_000)
expect(withPrev.find((r) => r.label === 'Årets resultat')?.previous).toBe(150_000)
const firstYear = buildRrRows(mapTrialBalancesToK2(currentPair(), null))
expect(firstYear.find((r) => r.label === 'Nettoomsättning')?.previous).toBeNull()
})
})
describe('buildBrRows', () => {
it('renders Kassa och bank as a post and ends both sides on tied totals', () => {
const mapping = mapTrialBalancesToK2(currentPair(), null)
const { assets, equityLiabilities } = buildBrRows(mapping)
expect(assets.find((r) => r.label === 'Kassa och bank' && !r.is_heading)?.current).toBe(
600_000,
)
const totalAssets = assets[assets.length - 1]
expect(totalAssets.label).toBe('Summa tillgångar')
expect(totalAssets.current).toBe(600_000)
const totalEqLiab = equityLiabilities[equityLiabilities.length - 1]
expect(totalEqLiab.label).toBe('Summa eget kapital och skulder')
expect(totalEqLiab.current).toBe(600_000)
expect(totalEqLiab.current).toBe(totalAssets.current)
})
it('omits zero subsections (varulager, obeskattade reserver, långfristiga skulder)', () => {
const mapping = mapTrialBalancesToK2(currentPair(), null)
const { assets, equityLiabilities } = buildBrRows(mapping)
const labels = [...assets, ...equityLiabilities].map((r) => r.label)
expect(labels).not.toContain('Varulager m.m.')
expect(labels).not.toContain('Obeskattade reserver')
expect(labels).not.toContain('Långfristiga skulder')
})
it('keeps statutory always-visible posts even at zero', () => {
const mapping = mapTrialBalancesToK2(currentPair(), null)
const { assets, equityLiabilities } = buildBrRows(mapping)
expect(assets.some((r) => r.label === 'Övriga fordringar')).toBe(true)
expect(equityLiabilities.some((r) => r.label === 'Leverantörsskulder')).toBe(true)
expect(equityLiabilities.some((r) => r.label === 'Balanserat resultat')).toBe(true)
})
it('maps equity to Bundet/Fritt posts with previous-year figures', () => {
const mapping = mapTrialBalancesToK2(currentPair(), previousPair())
const { equityLiabilities } = buildBrRows(mapping)
const aktiekapital = equityLiabilities.find((r) => r.label === 'Aktiekapital')
expect(aktiekapital?.current).toBe(50_000)
expect(aktiekapital?.previous).toBe(50_000)
const balanserat = equityLiabilities.find((r) => r.label === 'Balanserat resultat')
expect(balanserat?.current).toBe(250_000)
expect(balanserat?.previous).toBe(100_000)
const summaEk = equityLiabilities.find((r) => r.label === 'Summa eget kapital')
expect(summaEk?.current).toBe(600_000)
expect(summaEk?.previous).toBe(300_000)
})
it('produces no mapper warnings for the balanced fixture', () => {
const mapping = mapTrialBalancesToK2(currentPair(), previousPair())
expect(mapping.warnings).toEqual([])
})
})
@@ -1,5 +1,5 @@
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
import type { ArsredovisningData } from './types'
import type { ArsredovisningData, StatementRow } from './types'
/**
* K3 årsredovisning PDF template (BFNAR 2012:1).
@@ -146,6 +146,50 @@ function fmt(amount: number): string {
return Math.round(amount).toLocaleString('sv-SE')
}
/**
* Renders ÅRL post-level statement rows (see statement-rows.ts) with a
* jämförelseår column — mirrors the K2 template so the two documents stay
* visually consistent.
*/
function StatementTable({
rows,
hasPrevious,
}: {
rows: StatementRow[]
hasPrevious: boolean
}) {
return (
<>
{rows.map((line, i) => {
const indentPad = (line.indent ?? 0) * 12
if (line.is_heading) {
return (
<View key={i} style={styles.tableRow}>
<Text
style={{
flex: 1,
fontFamily: 'Helvetica-Bold',
paddingLeft: indentPad,
marginTop: 6,
}}
>
{line.label}
</Text>
</View>
)
}
return (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={{ flex: 1, paddingLeft: indentPad }}>{line.label}</Text>
<Text style={styles.colAmount}>{fmt(line.current ?? 0)}</Text>
<Text style={styles.colAmount}>{hasPrevious ? fmt(line.previous ?? 0) : ''}</Text>
</View>
)
})}
</>
)
}
function PageChrome({
data,
pageLabel,
@@ -230,56 +274,37 @@ export function ArsredovisningK3PDF({ data }: { data: ArsredovisningData }) {
<Text style={styles.paragraph}>{data.forvaltningsberattelse.resultatdisposition}</Text>
</Page>
{/* Resultaträkning */}
<Page size="A4" style={styles.page}>
{/* Resultaträkning — ÅRL post level, no account numbers. */}
<Page size="A4" style={styles.page} wrap>
<PageChrome data={data} pageLabel="Resultaträkning" />
<Text style={styles.sectionTitle}>Resultaträkning (kr)</Text>
<View style={styles.tableHeader}>
<Text style={styles.colLabel}>Post</Text>
<Text style={styles.colAmount}>{data.fiscal_period.name}</Text>
<Text style={styles.colAmount}>{data.previous_period?.name ?? ''}</Text>
</View>
{data.resultatrakning.map((line, i) => (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={styles.colLabel}>{line.label}</Text>
<Text style={styles.colAmount}>{fmt(line.amount)}</Text>
</View>
))}
<StatementTable rows={data.resultatrakning} hasPrevious={data.previous_period !== null} />
</Page>
{/* Balansräkning */}
<Page size="A4" style={styles.page}>
{/* Balansräkning — ÅRL post level, no account numbers. */}
<Page size="A4" style={styles.page} wrap>
<PageChrome data={data} pageLabel="Balansräkning" />
<Text style={styles.sectionTitle}>Tillgångar (kr)</Text>
<View style={styles.tableHeader}>
<Text style={styles.colLabel}>Post</Text>
<Text style={styles.colAmount}>{data.fiscal_period.period_end}</Text>
<Text style={styles.colAmount}>{data.previous_period?.period_end ?? ''}</Text>
</View>
{data.balansrakning.assets.map((line, i) => (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={line.indent ? styles.colLabelIndent : styles.colLabel}>
{line.label}
</Text>
<Text style={styles.colAmount}>{fmt(line.amount)}</Text>
</View>
))}
<View style={styles.tableRowTotal}>
<Text style={styles.colLabel}>Summa tillgångar</Text>
<Text style={styles.colAmount}>{fmt(data.balansrakning.total_assets)}</Text>
</View>
<StatementTable
rows={data.balansrakning.assets}
hasPrevious={data.previous_period !== null}
/>
<Text style={styles.sectionTitle}>Eget kapital och skulder (kr)</Text>
{data.balansrakning.equity_liabilities.map((line, i) => (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={line.indent ? styles.colLabelIndent : styles.colLabel}>
{line.label}
</Text>
<Text style={styles.colAmount}>{fmt(line.amount)}</Text>
</View>
))}
<View style={styles.tableRowTotal}>
<Text style={styles.colLabel}>Summa eget kapital och skulder</Text>
<Text style={styles.colAmount}>{fmt(data.balansrakning.total_equity_liabilities)}</Text>
</View>
<StatementTable
rows={data.balansrakning.equity_liabilities}
hasPrevious={data.previous_period !== null}
/>
</Page>
{/* Kassaflödesanalys: K3 only. Rendered as a flat list of rows so the
@@ -1,5 +1,5 @@
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
import type { ArsredovisningData } from './types'
import type { ArsredovisningData, StatementRow } from './types'
const styles = StyleSheet.create({
page: {
@@ -103,6 +103,51 @@ function fmt(amount: number): string {
return Math.round(amount).toLocaleString('sv-SE')
}
/**
* Renders ÅRL post-level statement rows (see statement-rows.ts) with a
* jämförelseår column. Headings carry no amounts; totals get the bordered
* bold style. The previous-year column stays empty for the company's first
* fiscal year.
*/
function StatementTable({
rows,
hasPrevious,
}: {
rows: StatementRow[]
hasPrevious: boolean
}) {
return (
<>
{rows.map((line, i) => {
const indentPad = (line.indent ?? 0) * 12
if (line.is_heading) {
return (
<View key={i} style={styles.tableRow}>
<Text
style={{
flex: 1,
fontFamily: 'Helvetica-Bold',
paddingLeft: indentPad,
marginTop: 6,
}}
>
{line.label}
</Text>
</View>
)
}
return (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={{ flex: 1, paddingLeft: indentPad }}>{line.label}</Text>
<Text style={styles.colAmount}>{fmt(line.current ?? 0)}</Text>
<Text style={styles.colAmount}>{hasPrevious ? fmt(line.previous ?? 0) : ''}</Text>
</View>
)
})}
</>
)
}
function PageChrome({
data,
pageLabel,
@@ -194,56 +239,37 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
<Text style={styles.paragraph}>{data.forvaltningsberattelse.resultatdisposition}</Text>
</Page>
{/* Resultaträkning */}
<Page size="A4" style={styles.page}>
{/* Resultaträkning — ÅRL post level, no account numbers. */}
<Page size="A4" style={styles.page} wrap>
<PageChrome data={data} pageLabel="Resultaträkning" />
<Text style={styles.sectionTitle}>Resultaträkning (kr)</Text>
<View style={styles.tableHeader}>
<Text style={styles.colLabel}>Post</Text>
<Text style={styles.colAmount}>{data.fiscal_period.name}</Text>
<Text style={styles.colAmount}>{data.previous_period?.name ?? ''}</Text>
</View>
{data.resultatrakning.map((line, i) => (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={styles.colLabel}>{line.label}</Text>
<Text style={styles.colAmount}>{fmt(line.amount)}</Text>
</View>
))}
<StatementTable rows={data.resultatrakning} hasPrevious={data.previous_period !== null} />
</Page>
{/* Balansräkning */}
<Page size="A4" style={styles.page}>
{/* Balansräkning — ÅRL post level, no account numbers. */}
<Page size="A4" style={styles.page} wrap>
<PageChrome data={data} pageLabel="Balansräkning" />
<Text style={styles.sectionTitle}>Tillgångar (kr)</Text>
<View style={styles.tableHeader}>
<Text style={styles.colLabel}>Post</Text>
<Text style={styles.colAmount}>{data.fiscal_period.period_end}</Text>
<Text style={styles.colAmount}>{data.previous_period?.period_end ?? ''}</Text>
</View>
{data.balansrakning.assets.map((line, i) => (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={line.indent ? styles.colLabelIndent : styles.colLabel}>
{line.label}
</Text>
<Text style={styles.colAmount}>{fmt(line.amount)}</Text>
</View>
))}
<View style={styles.tableRowTotal}>
<Text style={styles.colLabel}>Summa tillgångar</Text>
<Text style={styles.colAmount}>{fmt(data.balansrakning.total_assets)}</Text>
</View>
<StatementTable
rows={data.balansrakning.assets}
hasPrevious={data.previous_period !== null}
/>
<Text style={styles.sectionTitle}>Eget kapital och skulder (kr)</Text>
{data.balansrakning.equity_liabilities.map((line, i) => (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={line.indent ? styles.colLabelIndent : styles.colLabel}>
{line.label}
</Text>
<Text style={styles.colAmount}>{fmt(line.amount)}</Text>
</View>
))}
<View style={styles.tableRowTotal}>
<Text style={styles.colLabel}>Summa eget kapital och skulder</Text>
<Text style={styles.colAmount}>{fmt(data.balansrakning.total_equity_liabilities)}</Text>
</View>
<StatementTable
rows={data.balansrakning.equity_liabilities}
hasPrevious={data.previous_period !== null}
/>
</Page>
{/* Noter */}
+162 -210
View File
@@ -1,11 +1,16 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
import { listAssets } from '@/lib/bokslut/assets/asset-service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { LATENT_TAX_DEFAULT_RATE } from '@/lib/bokslut/tax-provision/latent-tax-calculator'
import {
mapTrialBalancesToK2,
type K2MappingResult,
type TrialBalancePair,
} from '@/lib/bokslut/ixbrl/k2-mapper'
import { buildBrRows, buildRrRows } from './statement-rows'
import { getNarrative, type NarrativeRow } from './narrative-service'
import {
anyAssetHasComponents,
@@ -20,17 +25,10 @@ import type {
ArsredovisningData,
EgenKapitalRow,
FlerarsoversiktRow,
IncomeStatementLine,
BalanceSheetLine,
NoteEntry,
KassaflodesAnalysisSummary,
} from './types'
import type {
AccountingFramework,
Asset,
BalanceSheetSection,
IncomeStatementSection,
} from '@/types'
import type { AccountingFramework, Asset } from '@/types'
/**
* Pre-populate the K2 årsredovisning data for a fiscal period. Loads:
@@ -51,7 +49,14 @@ export async function buildArsredovisningData(
fiscalPeriodId: string,
overrides: Partial<ArsredovisningData['forvaltningsberattelse']> = {},
): Promise<ArsredovisningData> {
const [periodResult, settingsResult, companyResult, periodList, incomeStatement, balanceSheet, narrative] = await Promise.all([
// The RR/BR are rendered at ÅRL post level from the same K2 risbs mapping
// that drives the iXBRL filing, never from per-account report rows.
// Bolagsverket rejects balans-/resultaträkningar med kontonummer, so the
// statement data must not carry account-level granularity at all. Two TB
// variants per year (see TrialBalancePair): the FULL trial balance drives
// the BR (2099 booked), the PRE-CLOSING one drives the RR (class 3-8
// still open).
const [periodResult, settingsResult, companyResult, periodList, tbFull, tbPreClosing, narrative] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, previous_period_id, closing_entry_id')
@@ -79,8 +84,8 @@ export async function buildArsredovisningData(
.order('period_start', { ascending: false })
.range(from, to),
),
generateIncomeStatement(supabase, companyId, fiscalPeriodId),
generateBalanceSheet(supabase, companyId, fiscalPeriodId),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeYearEndClosing: true }),
// Load persisted narrative overrides: replaces the URL-query-param
// carry from earlier phases. Caller-supplied overrides (passed in via
// the second arg) still win, so the API can layer per-request edits on
@@ -116,6 +121,43 @@ export async function buildArsredovisningData(
// with an empty org number.
const city = (settings as { city?: string | null } | null)?.city ?? null
// Previous fiscal year → jämförelsesiffror (ÅRL 3:5 §). Resolved from the
// already-fetched period list; a TB failure downgrades to "no comparison
// year" with a warning instead of blocking the whole document (partial SIE
// imports can leave prior years without IB continuity).
const statementWarnings: string[] = []
const prevPeriodRow = period.previous_period_id
? ((periodList ?? []) as PeriodRow[]).find((p) => p.id === period.previous_period_id) ?? null
: null
let previousTb: TrialBalancePair | null = null
if (prevPeriodRow) {
try {
const [prevFull, prevPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, prevPeriodRow.id),
generateTrialBalance(supabase, companyId, prevPeriodRow.id, {
excludeYearEndClosing: true,
}),
])
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
} catch {
statementWarnings.push(
'Jämförelsesiffror kunde inte hämtas för föregående räkenskapsår, balans- och resultaträkningen visas utan jämförelseår. Kontrollera det föregående årets bokföring.',
)
}
}
const mapping = mapTrialBalancesToK2(
{ full: tbFull.rows, preClosing: tbPreClosing.rows },
previousTb,
)
const previousPeriod =
prevPeriodRow && previousTb
? {
name: prevPeriodRow.name,
period_start: prevPeriodRow.period_start,
period_end: prevPeriodRow.period_end,
}
: null
// Merge precedence: caller overrides → persisted narrative → boilerplate
const persistedDescription = narrative?.description ?? undefined
const persistedEvents = narrative?.important_events ?? undefined
@@ -130,7 +172,28 @@ export async function buildArsredovisningData(
accountingFramework,
)
const egen_kapital_changes = buildEquityChanges(balanceSheet.equity_liability_sections)
const egen_kapital_changes = buildEquityChanges(mapping)
// Duplicate-value consistency with the RR (mirrors build-input.ts): the
// flerårsöversikt is computed from the income statement (ALL class-3
// revenue), but nettoomsättning per ÅRL is strictly 3000-3799. Override
// the current + previous year so the FB table ties to the RR two pages
// later. Older years have no RR in the document and keep the IS values.
if (flerarsoversikt.length > 0) {
const lastIdx = flerarsoversikt.length - 1
flerarsoversikt[lastIdx] = {
...flerarsoversikt[lastIdx],
net_revenue: mapping.rr['Nettoomsattning']?.current ?? 0,
result_after_financial: mapping.totals.resultatEfterFinansiellaPoster.current,
}
if (lastIdx > 0 && previousPeriod && flerarsoversikt[lastIdx - 1].year === previousPeriod.name) {
flerarsoversikt[lastIdx - 1] = {
...flerarsoversikt[lastIdx - 1],
net_revenue: mapping.rr['Nettoomsattning']?.previous ?? 0,
result_after_financial: mapping.totals.resultatEfterFinansiellaPoster.previous ?? 0,
}
}
}
// K3 vs K2 split: K3 has a richer note set + a kassaflöde + a separate
// equity-changes statement. The 18a/b warning that flagged "K3 noter not
@@ -189,19 +252,28 @@ export async function buildArsredovisningData(
)
}
// Equity-changes statement: derived from the saved equity rows + this
// year's resultat. We reuse buildEquityChangesNote's roll-forward to
// keep one source of truth for the closing total.
equity_changes_statement = buildK3EquityChangesStatement(
balanceSheet.equity_liability_sections,
incomeStatement.net_result,
)
// Equity-changes statement: derived from the post-level mapping. We
// reuse buildEquityChangesNote's roll-forward to keep one source of
// truth for the closing total.
equity_changes_statement = buildK3EquityChangesStatement(mapping)
}
const resultatrakning = flattenIncomeStatement(incomeStatement)
const balansrakning = flattenBalanceSheet(balanceSheet)
const resultatrakning = buildRrRows(mapping)
const brRows = buildBrRows(mapping)
const balansrakning = {
assets: brRows.assets,
total_assets: mapping.totals.tillgangar.current,
total_assets_previous: mapping.totals.tillgangar.previous,
equity_liabilities: brRows.equityLiabilities,
total_equity_liabilities: mapping.totals.egetKapitalSkulder.current,
total_equity_liabilities_previous: mapping.totals.egetKapitalSkulder.previous,
}
const warnings: string[] = [...noterWarnings]
// mapping.warnings carry the compliance-critical signals (unmapped
// accounts whose balances are MISSING from the document, RR ≠ 2099,
// obalans, reclass review nudges), surfacing them pre-download is what
// keeps a non-fileable PDF from reaching Bolagsverket.
const warnings: string[] = [...statementWarnings, ...mapping.warnings, ...noterWarnings]
if (entityType !== 'aktiebolag' && entityType !== 'unknown') {
warnings.push(
'Den här årsredovisningen genereras med K2-mallen (BFNAR 2016:10) som standard. För K3- eller annan företagsform kan strukturen behöva justeras manuellt innan inlämning.',
@@ -261,6 +333,7 @@ export async function buildArsredovisningData(
period_start: period.period_start,
period_end: period.period_end,
},
previous_period: previousPeriod,
accounting_framework: accountingFramework,
forvaltningsberattelse: {
description:
@@ -381,22 +454,31 @@ async function buildFlerarsoversikt(
return rows
}
function buildEquityChanges(sections: BalanceSheetSection[]): EgenKapitalRow[] {
const equity: EgenKapitalRow[] = []
for (const section of sections) {
for (const row of section.rows) {
if (
row.account_number.startsWith('20') ||
row.account_number.startsWith('21')
) {
equity.push({
label: `${row.account_number} ${row.account_name}`,
amount: row.amount,
})
}
}
/**
* Förvaltningsberättelsens "Förändring av eget kapital" table, post-level
* labels only (no kontonummer). Only genuine equity posts (20xx) appear;
* obeskattade reserver are NOT eget kapital and were dropped from the table
* when the account-row version was replaced by the mapping-driven one.
*/
function buildEquityChanges(mapping: K2MappingResult): EgenKapitalRow[] {
const posts: Array<{ label: string; concept: string; alwaysShow?: boolean }> = [
{ label: 'Aktiekapital', concept: 'Aktiekapital', alwaysShow: true },
{ label: 'Ej registrerat aktiekapital', concept: 'EjRegistreratAktiekapital' },
{ label: 'Bunden överkursfond', concept: 'OverkursfondBunden' },
{ label: 'Uppskrivningsfond', concept: 'Uppskrivningsfond' },
{ label: 'Reservfond', concept: 'Reservfond' },
{ label: 'Överkursfond', concept: 'Overkursfond' },
{ label: 'Balanserat resultat', concept: 'BalanseratResultat', alwaysShow: true },
{ label: 'Årets resultat', concept: 'AretsResultatEgetKapital', alwaysShow: true },
]
const rows: EgenKapitalRow[] = []
for (const post of posts) {
const amount = mapping.br[post.concept]?.current ?? 0
if (amount === 0 && !post.alwaysShow) continue
rows.push({ label: post.label, amount })
}
return equity
rows.push({ label: 'Summa eget kapital', amount: mapping.totals.egetKapital.current })
return rows
}
async function buildK2Noter(
@@ -860,186 +942,56 @@ async function buildK3Noter(
}
/**
* K3 separate "Förändring av eget kapital" statement. Reads opening balances
* from the K3 balance sheet's equity section (account ranges per BAS):
* - 2081 (aktiekapital) → opening aktiekapital
* - 2085-2089 (övriga bundna reserver) → bundna_reserver
* - 2090-2099 (balanserade vinstmedel + årets resultat) → fritt eget kapital
*
* Year movements (nyemission, utdelning) aren't trivially derivable from
* closing balances alone: they require movement analysis. v1 reports the
* year's net result and leaves nyemission/utdelning at 0; future iterations
* can extract these from journal entries on specific accounts.
* K3 separate "Förändring av eget kapital" statement, derived from the
* post-level mapping. With a previous fiscal year the opening balances are
* the REAL prior-year UB values (mapping .previous), and the year's
* movements are derived so the roll-forward ties exactly to the booked UB:
* bundet-EK growth is presented as nyemission, a fritt-EK shortfall beyond
* årets resultat as utdelning (the overwhelmingly common cases; a positive
* fritt residual, e.g. aktieägartillskott, is folded into nyemission
* rather than invent an unbookable row). First fiscal year falls back to
* opening = closing - årets resultat.
*/
function buildK3EquityChangesStatement(
sections: BalanceSheetSection[],
netResult: number,
mapping: K2MappingResult,
): { rows: EgenKapitalRow[]; closing_total: number } {
// Closing balance from BS: we approximate opening = closing - net result,
// which is exact when no equity movements happened outside årets resultat.
// For nyemission/utdelning the user can edit the equity-change narrative
// in a future enhancement.
let aktiekapitalClosing = 0
let bundnaClosing = 0
let fritProtClosing = 0
for (const section of sections) {
for (const row of section.rows) {
const num = row.account_number
// BAS 2081-2084 = aktiekapital + medlemsinsatser
// BAS 2085-2087 = bundna reserver (uppskrivningsfond, reservfond, bundna fonder)
// BAS 2090-2099 = fritt eget kapital (including årets resultat 2099)
if (num >= '2081' && num <= '2084') {
aktiekapitalClosing += row.amount
} else if (num >= '2085' && num <= '2087') {
bundnaClosing += row.amount
} else if (num.startsWith('209')) {
fritProtClosing += row.amount
}
}
}
// Opening fritt eget kapital = closing − net result (årets resultat
// already lives in 2099 at closing).
const opening = {
aktiekapital: Math.round(aktiekapitalClosing * 100) / 100,
bundna_reserver: Math.round(bundnaClosing * 100) / 100,
balanserade_vinstmedel:
Math.round((fritProtClosing - netResult) * 100) / 100,
}
const changes = {
nyemission: 0,
utdelning: 0,
arets_resultat: Math.round(netResult * 100) / 100,
}
return buildEquityChangesNote({ opening, changes })
}
const cur = (concept: string): number => mapping.br[concept]?.current ?? 0
const prev = (concept: string): number => mapping.br[concept]?.previous ?? 0
function flattenIncomeStatement(is: {
revenue_sections: IncomeStatementSection[]
total_revenue: number
expense_sections: IncomeStatementSection[]
total_expenses: number
financial_sections: IncomeStatementSection[]
total_financial: number
net_result: number
}): IncomeStatementLine[] {
const lines: IncomeStatementLine[] = []
for (const s of is.revenue_sections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
lines.push({ label: 'Summa rörelseintäkter', amount: is.total_revenue, is_total: true })
for (const s of is.expense_sections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: -r.amount })
}
}
lines.push({
label: 'Rörelseresultat',
amount: is.total_revenue - is.total_expenses,
is_total: true,
})
const aretsResultat = cur('AretsResultatEgetKapital')
const aktiekapitalClosing = cur('Aktiekapital') + cur('EjRegistreratAktiekapital')
const bundnaClosing = mapping.totals.bundetEgetKapital.current - aktiekapitalClosing
const frittClosing = mapping.totals.frittEgetKapital.current
// Split financial sections so the RR follows the K2 / ÅRL 3:2 structure:
// financial items (80-87) → "Resultat efter finansiella poster" →
// bokslutsdispositioner (88) → "Resultat före skatt" → skatt (89) →
// "Årets resultat". Without the dispositioner + skatt rows the document
// is non-compliant for any AB that posted bolagsskatt or
// periodiseringsfond, and the RR doesn't reconcile to BS 2099.
const finItems = is.financial_sections.filter(
(s) => !/bokslutsdisposition|skatter och årets resultat/i.test(s.title),
)
const dispositionsSections = is.financial_sections.filter((s) =>
/bokslutsdisposition/i.test(s.title),
)
const skattSections = is.financial_sections.filter((s) =>
/skatter och årets resultat/i.test(s.title),
)
for (const s of finItems) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
const hasPrevious = mapping.totals.egetKapital.previous !== null
let opening: { aktiekapital: number; bundna_reserver: number; balanserade_vinstmedel: number }
let nyemission = 0
let utdelning = 0
if (hasPrevious) {
const aktiekapitalOpening = prev('Aktiekapital') + prev('EjRegistreratAktiekapital')
const bundnaOpening =
(mapping.totals.bundetEgetKapital.previous ?? 0) - aktiekapitalOpening
const frittOpening = mapping.totals.frittEgetKapital.previous ?? 0
opening = {
aktiekapital: aktiekapitalOpening,
bundna_reserver: bundnaOpening,
balanserade_vinstmedel: frittOpening,
}
}
const finSubtotal = finItems.reduce((sum, s) => sum + s.subtotal, 0)
const resAfterFinancial = is.total_revenue - is.total_expenses + finSubtotal
lines.push({
label: 'Resultat efter finansiella poster',
amount: Math.round(resAfterFinancial * 100) / 100,
is_total: true,
})
if (dispositionsSections.length > 0) {
for (const s of dispositionsSections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
const dispositionsSubtotal = dispositionsSections.reduce((sum, s) => sum + s.subtotal, 0)
lines.push({
label: 'Resultat före skatt',
amount: Math.round((resAfterFinancial + dispositionsSubtotal) * 100) / 100,
is_total: true,
})
nyemission =
aktiekapitalClosing - aktiekapitalOpening + (bundnaClosing - bundnaOpening)
const frittResidual = frittClosing - frittOpening - aretsResultat
if (frittResidual < 0) utdelning = frittResidual
else nyemission += frittResidual
} else {
// No dispositioner posted: keep the simpler "Resultat före skatt" row
// immediately after the finansnetto totals so the RR still has the
// pre-tax subtotal expected by ÅRL.
lines.push({
label: 'Resultat före skatt',
amount: Math.round(resAfterFinancial * 100) / 100,
is_total: true,
})
}
if (skattSections.length > 0) {
for (const s of skattSections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
opening = {
aktiekapital: aktiekapitalClosing,
bundna_reserver: bundnaClosing,
balanserade_vinstmedel: frittClosing - aretsResultat,
}
}
lines.push({ label: 'Årets resultat', amount: is.net_result, is_total: true })
return lines
return buildEquityChangesNote({
opening,
changes: { nyemission, utdelning, arets_resultat: aretsResultat },
})
}
function flattenBalanceSheet(bs: {
asset_sections: BalanceSheetSection[]
total_assets: number
equity_liability_sections: BalanceSheetSection[]
total_equity_liabilities: number
}): {
assets: BalanceSheetLine[]
total_assets: number
equity_liabilities: BalanceSheetLine[]
total_equity_liabilities: number
} {
const assetLines: BalanceSheetLine[] = []
for (const s of bs.asset_sections) {
assetLines.push({ label: s.title, amount: s.subtotal, is_total: true, indent: 0 })
for (const r of s.rows) {
assetLines.push({
label: `${r.account_number} ${r.account_name}`,
amount: r.amount,
indent: 1,
})
}
}
const eqLines: BalanceSheetLine[] = []
for (const s of bs.equity_liability_sections) {
eqLines.push({ label: s.title, amount: s.subtotal, is_total: true, indent: 0 })
for (const r of s.rows) {
eqLines.push({
label: `${r.account_number} ${r.account_name}`,
amount: r.amount,
indent: 1,
})
}
}
return {
assets: assetLines,
total_assets: bs.total_assets,
equity_liabilities: eqLines,
total_equity_liabilities: bs.total_equity_liabilities,
}
}
@@ -1,6 +1,5 @@
import type {
EgenKapitalRow,
IncomeStatementLine,
NoteEntry,
} from './types'
@@ -338,7 +337,3 @@ export function anyAssetHasComponents(assets: AssetWithComponents[]): boolean {
// Re-export so the type-guard signature can be reused by build-data and
// tests without exposing the local AssetWithComponents structural type.
export { isComponentArray as isK3ComponentArray }
// Re-export the line type to make integration easier for callers that need
// to merge with non-K3 lines without re-importing from './types'.
export type { IncomeStatementLine }
@@ -0,0 +1,447 @@
/**
* ÅRL uppställningsform presentation rows for the årsredovisning PDF.
*
* Derives post-level RR/BR rows from the same K2 risbs mapping that drives
* the iXBRL filing (lib/bokslut/ixbrl/k2-mapper.ts), so the human-readable
* PDF and the digitally filed document cannot diverge. Labels and row order
* mirror lib/bokslut/ixbrl/document/k2-document.ts.
*
* Account numbers must never appear in these labels — Bolagsverket rejects
* balans- och resultaträkningar that contain kontonummer; the statutory
* uppställningsform (ÅRL bilaga 1–2) knows only posts, never BAS accounts.
*
* Sign conventions match the iXBRL document layer: every ConceptAmount is
* oriented to the concept's natural balance, and cost/deduction posts get a
* presentational minus (`displayMinus`) so the RR reads top-to-bottom as
* intäkter − kostnader = resultat.
*/
import type { ConceptAmount } from '@/lib/bokslut/ixbrl/types'
import type { K2MappingResult } from '@/lib/bokslut/ixbrl/k2-mapper'
import type { StatementRow } from './types'
const ZERO: ConceptAmount = { current: 0, previous: null }
function hasValue(amount: ConceptAmount): boolean {
return amount.current !== 0 || (amount.previous ?? 0) !== 0
}
interface RowOptions {
indent?: number
/** Presentational minus — show cost posts as negative. */
displayMinus?: boolean
/** Emit the row even when zero in both years (statutory always-visible posts). */
alwaysShow?: boolean
}
class RowBuilder {
readonly rows: StatementRow[] = []
constructor(private readonly hasPrevious: boolean) {}
heading(label: string, indent = 0): void {
this.rows.push({ label, current: null, previous: null, is_heading: true, indent })
}
post(label: string, amount: ConceptAmount | undefined, opts: RowOptions = {}): void {
const value = amount ?? ZERO
if (!opts.alwaysShow && !hasValue(value)) return
this.rows.push(this.buildAmountRow(label, value, opts, false))
}
total(label: string, amount: ConceptAmount, opts: RowOptions = {}): void {
this.rows.push(this.buildAmountRow(label, amount, opts, true))
}
private buildAmountRow(
label: string,
amount: ConceptAmount,
opts: RowOptions,
isTotal: boolean,
): StatementRow {
const sign = opts.displayMinus ? -1 : 1
return {
label,
current: sign * amount.current,
previous: this.hasPrevious ? sign * (amount.previous ?? 0) : null,
...(isTotal ? { is_total: true } : {}),
...(opts.indent ? { indent: opts.indent } : {}),
}
}
}
/** The mapper leaves `previous` null on every concept when the company has
* no previous fiscal year; any concept with a number means a jämförelseår
* exists. */
function mappingHasPrevious(mapping: K2MappingResult): boolean {
return mapping.totals.tillgangar.previous !== null
}
/**
* Resultaträkning — kostnadsslagsindelad per ÅRL bilaga 2 / K2 risbs, in
* uppställningsform order.
*/
export function buildRrRows(mapping: K2MappingResult): StatementRow[] {
const { rr, totals } = mapping
const b = new RowBuilder(mappingHasPrevious(mapping))
b.heading('Rörelseintäkter, lagerförändringar m.m.')
b.post('Nettoomsättning', rr['Nettoomsattning'], { indent: 1, alwaysShow: true })
b.post(
'Förändring av lager av produkter i arbete, färdiga varor och pågående arbete för annans räkning',
rr['ForandringLagerProdukterIArbeteFardigaVarorPagaendeArbetenAnnansRakning'],
{ indent: 1 },
)
b.post('Aktiverat arbete för egen räkning', rr['AktiveratArbeteEgenRakning'], { indent: 1 })
b.post('Övriga rörelseintäkter', rr['OvrigaRorelseintakter'], { indent: 1 })
b.total('Summa rörelseintäkter, lagerförändringar m.m.', totals.rorelseintakter)
b.heading('Rörelsekostnader')
const cost = (label: string, concept: string): void =>
b.post(label, rr[concept], { indent: 1, displayMinus: true })
cost('Råvaror och förnödenheter', 'RavarorFornodenheterKostnader')
cost('Handelsvaror', 'HandelsvarorKostnader')
cost('Övriga externa kostnader', 'OvrigaExternaKostnader')
cost('Personalkostnader', 'Personalkostnader')
cost(
'Av- och nedskrivningar av materiella och immateriella anläggningstillgångar',
'AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar',
)
cost(
'Nedskrivningar av omsättningstillgångar utöver normala nedskrivningar',
'NedskrivningarOmsattningstillgangarUtoverNormalaNedskrivningar',
)
cost('Övriga rörelsekostnader', 'OvrigaRorelsekostnader')
b.total('Summa rörelsekostnader', totals.rorelsekostnader, { displayMinus: true })
b.total('Rörelseresultat', totals.rorelseresultat)
b.heading('Finansiella poster')
b.post('Resultat från andelar i koncernföretag', rr['ResultatAndelarKoncernforetag'], {
indent: 1,
})
b.post(
'Resultat från andelar i intresseföretag och gemensamt styrda företag',
rr['ResultatAndelarIntresseforetagGemensamtStyrda'],
{ indent: 1 },
)
b.post(
'Resultat från övriga företag som det finns ett ägarintresse i',
rr['ResultatOvrigaforetagAgarintresse'],
{ indent: 1 },
)
b.post(
'Resultat från övriga finansiella anläggningstillgångar',
rr['ResultatOvrigaFinansiellaAnlaggningstillgangar'],
{ indent: 1 },
)
b.post(
'Övriga ränteintäkter och liknande resultatposter',
rr['OvrigaRanteintakterLiknandeResultatposter'],
{ indent: 1 },
)
b.post(
'Nedskrivningar av finansiella anläggningstillgångar och kortfristiga placeringar',
rr['NedskrivningarFinansiellaAnlaggningstillgangarKortfristigaPlaceringar'],
{ indent: 1, displayMinus: true },
)
b.post(
'Räntekostnader och liknande resultatposter',
rr['RantekostnaderLiknandeResultatposter'],
{ indent: 1, displayMinus: true },
)
b.total('Summa finansiella poster', totals.finansiellaPoster)
b.total('Resultat efter finansiella poster', totals.resultatEfterFinansiellaPoster)
if (hasValue(totals.bokslutsdispositioner)) {
b.heading('Bokslutsdispositioner')
b.post('Erhållna koncernbidrag', rr['ErhallnaKoncernbidrag'], { indent: 1 })
b.post('Lämnade koncernbidrag', rr['LamnadeKoncernbidrag'], {
indent: 1,
displayMinus: true,
})
b.post('Förändring av periodiseringsfonder', rr['ForandringPeriodiseringsfond'], {
indent: 1,
})
b.post('Förändring av överavskrivningar', rr['ForandringOveravskrivningar'], { indent: 1 })
b.post('Övriga bokslutsdispositioner', rr['OvrigaBokslutsdispositioner'], { indent: 1 })
b.total('Summa bokslutsdispositioner', totals.bokslutsdispositioner)
}
b.total('Resultat före skatt', totals.resultatForeSkatt)
b.heading('Skatter')
b.post('Skatt på årets resultat', rr['SkattAretsResultat'], { indent: 1, displayMinus: true })
b.post('Övriga skatter', rr['OvrigaSkatter'], { indent: 1, displayMinus: true })
b.total('Årets resultat', totals.aretsResultat)
return b.rows
}
/**
* Balansräkning per ÅRL bilaga 1 / K2 risbs. Subsections whose total is zero
* in both years are omitted entirely (matching the iXBRL document layer);
* kortfristiga fordringar, kassa och bank, eget kapital and kortfristiga
* skulder always render.
*/
export function buildBrRows(mapping: K2MappingResult): {
assets: StatementRow[]
equityLiabilities: StatementRow[]
} {
const { br, totals } = mapping
const hasPrevious = mappingHasPrevious(mapping)
const a = new RowBuilder(hasPrevious)
a.post('Tecknat men ej inbetalt kapital', br['TecknatEjInbetaltKapital'])
a.heading('Anläggningstillgångar')
if (hasValue(totals.immateriellaAnlaggningstillgangar)) {
a.heading('Immateriella anläggningstillgångar', 1)
a.post(
'Koncessioner, patent, licenser, varumärken samt liknande rättigheter',
br['KoncessionerPatentLicenserVarumarkenLiknandeRattigheter'],
{ indent: 2 },
)
a.post('Hyresrätter och liknande rättigheter', br['HyresratterLiknandeRattigheter'], {
indent: 2,
})
a.post('Goodwill', br['Goodwill'], { indent: 2 })
a.post(
'Förskott avseende immateriella anläggningstillgångar',
br['ForskottImmateriellaAnlaggningstillgangar'],
{ indent: 2 },
)
a.total(
'Summa immateriella anläggningstillgångar',
totals.immateriellaAnlaggningstillgangar,
{ indent: 1 },
)
}
if (hasValue(totals.materiellaAnlaggningstillgangar)) {
a.heading('Materiella anläggningstillgångar', 1)
a.post('Byggnader och mark', br['ByggnaderMark'], { indent: 2 })
a.post(
'Maskiner och andra tekniska anläggningar',
br['MaskinerAndraTekniskaAnlaggningar'],
{ indent: 2 },
)
a.post(
'Inventarier, verktyg och installationer',
br['InventarierVerktygInstallationer'],
{ indent: 2 },
)
a.post(
'Förbättringsutgifter på annans fastighet',
br['ForbattringsutgifterAnnansFastighet'],
{ indent: 2 },
)
a.post(
'Övriga materiella anläggningstillgångar',
br['OvrigaMateriellaAnlaggningstillgangar'],
{ indent: 2 },
)
a.post(
'Pågående nyanläggningar och förskott avseende materiella anläggningstillgångar',
br['PagaendeNyanlaggningarForskottMateriellaAnlaggningstillgangar'],
{ indent: 2 },
)
a.total('Summa materiella anläggningstillgångar', totals.materiellaAnlaggningstillgangar, {
indent: 1,
})
}
if (hasValue(totals.finansiellaAnlaggningstillgangar)) {
a.heading('Finansiella anläggningstillgångar', 1)
a.post('Andelar i koncernföretag', br['AndelarKoncernforetag'], { indent: 2 })
a.post('Fordringar hos koncernföretag', br['FordringarKoncernforetagLangfristiga'], {
indent: 2,
})
a.post(
'Andelar i intresseföretag och gemensamt styrda företag',
br['AndelarIntresseforetagGemensamtStyrdaForetag'],
{ indent: 2 },
)
a.post(
'Fordringar hos intresseföretag och gemensamt styrda företag',
br['FordringarIntresseforetagGemensamtStyrdaForetagLangfristiga'],
{ indent: 2 },
)
a.post('Ägarintressen i övriga företag', br['AgarintressenOvrigaForetag'], { indent: 2 })
a.post(
'Fordringar hos övriga företag som det finns ett ägarintresse i',
br['FordringarOvrigaForetagAgarintresseLangfristiga'],
{ indent: 2 },
)
a.post(
'Andra långfristiga värdepappersinnehav',
br['AndraLangfristigaVardepappersinnehav'],
{ indent: 2 },
)
a.post('Lån till delägare eller närstående', br['LanDelagareNarstaende'], { indent: 2 })
a.post('Andra långfristiga fordringar', br['AndraLangfristigaFordringar'], { indent: 2 })
a.total(
'Summa finansiella anläggningstillgångar',
totals.finansiellaAnlaggningstillgangar,
{ indent: 1 },
)
}
a.total('Summa anläggningstillgångar', totals.anlaggningstillgangar)
a.heading('Omsättningstillgångar')
if (hasValue(totals.varulager)) {
a.heading('Varulager m.m.', 1)
a.post('Råvaror och förnödenheter', br['LagerRavarorFornodenheter'], { indent: 2 })
a.post('Varor under tillverkning', br['LagerVarorUnderTillverkning'], { indent: 2 })
a.post('Färdiga varor och handelsvaror', br['LagerFardigaVarorHandelsvaror'], { indent: 2 })
a.post(
'Pågående arbete för annans räkning',
br['PagaendeArbetenAnnansRakningOmsattningstillgangar'],
{ indent: 2 },
)
a.post('Förskott till leverantörer', br['ForskottTillLeverantorer'], { indent: 2 })
a.post('Övriga lagertillgångar', br['OvrigaLagertillgangar'], { indent: 2 })
a.total('Summa varulager m.m.', totals.varulager, { indent: 1 })
}
a.heading('Kortfristiga fordringar', 1)
a.post('Kundfordringar', br['Kundfordringar'], { indent: 2 })
a.post('Fordringar hos koncernföretag', br['FordringarKoncernforetagKortfristiga'], {
indent: 2,
})
a.post(
'Fordringar hos intresseföretag och gemensamt styrda företag',
br['FordringarIntresseforetagGemensamtStyrdaForetagKortfristiga'],
{ indent: 2 },
)
a.post(
'Fordringar hos övriga företag som det finns ett ägarintresse i',
br['FordringarOvrigaforetagAgarintresseKortfristiga'],
{ indent: 2 },
)
a.post('Övriga fordringar', br['OvrigaFordringarKortfristiga'], {
indent: 2,
alwaysShow: true,
})
a.post('Upparbetad men ej fakturerad intäkt', br['UpparbetadEjFaktureradIntakt'], {
indent: 2,
})
a.post(
'Förutbetalda kostnader och upplupna intäkter',
br['ForutbetaldaKostnaderUpplupnaIntakter'],
{ indent: 2 },
)
a.total('Summa kortfristiga fordringar', totals.kortfristigaFordringar, { indent: 1 })
if (hasValue(totals.kortfristigaPlaceringar)) {
a.heading('Kortfristiga placeringar', 1)
a.post('Andelar i koncernföretag', br['AndelarKoncernforetagKortfristiga'], { indent: 2 })
a.post('Övriga kortfristiga placeringar', br['OvrigaKortfristigaPlaceringar'], {
indent: 2,
})
a.total('Summa kortfristiga placeringar', totals.kortfristigaPlaceringar, { indent: 1 })
}
a.heading('Kassa och bank', 1)
a.post('Kassa och bank', br['KassaBankExklRedovisningsmedel'], {
indent: 2,
alwaysShow: true,
})
a.post('Redovisningsmedel', br['Redovisningsmedel'], { indent: 2 })
a.total('Summa kassa och bank', totals.kassaBank, { indent: 1 })
a.total('Summa omsättningstillgångar', totals.omsattningstillgangar)
a.total('Summa tillgångar', totals.tillgangar)
const e = new RowBuilder(hasPrevious)
e.heading('Eget kapital')
e.heading('Bundet eget kapital', 1)
e.post('Aktiekapital', br['Aktiekapital'], { indent: 2, alwaysShow: true })
e.post('Ej registrerat aktiekapital', br['EjRegistreratAktiekapital'], { indent: 2 })
e.post('Bunden överkursfond', br['OverkursfondBunden'], { indent: 2 })
e.post('Uppskrivningsfond', br['Uppskrivningsfond'], { indent: 2 })
e.post('Reservfond', br['Reservfond'], { indent: 2 })
e.total('Summa bundet eget kapital', totals.bundetEgetKapital, { indent: 1 })
e.heading('Fritt eget kapital', 1)
e.post('Överkursfond', br['Overkursfond'], { indent: 2 })
e.post('Balanserat resultat', br['BalanseratResultat'], { indent: 2, alwaysShow: true })
e.post('Årets resultat', br['AretsResultatEgetKapital'], { indent: 2, alwaysShow: true })
e.total('Summa fritt eget kapital', totals.frittEgetKapital, { indent: 1 })
e.total('Summa eget kapital', totals.egetKapital)
if (hasValue(totals.obeskattadeReserver)) {
e.heading('Obeskattade reserver')
e.post('Periodiseringsfonder', br['Periodiseringsfonder'], { indent: 1 })
e.post('Ackumulerade överavskrivningar', br['AckumuleradeOveravskrivningar'], { indent: 1 })
e.post('Övriga obeskattade reserver', br['OvrigaObeskattadeReserver'], { indent: 1 })
e.total('Summa obeskattade reserver', totals.obeskattadeReserver)
}
if (hasValue(totals.avsattningar)) {
e.heading('Avsättningar')
e.post(
'Avsättningar för pensioner och liknande förpliktelser enligt lag',
br['AvsattningarPensionerLiknandeForpliktelserEnligtLag'],
{ indent: 1 },
)
e.post(
'Övriga avsättningar för pensioner och liknande förpliktelser',
br['OvrigaAvsattningarPensionerLiknandeForpliktelser'],
{ indent: 1 },
)
e.post('Övriga avsättningar', br['OvrigaAvsattningar'], { indent: 1 })
e.total('Summa avsättningar', totals.avsattningar)
}
if (hasValue(totals.langfristigaSkulder)) {
e.heading('Långfristiga skulder')
e.post('Obligationslån', br['Obligationslan'], { indent: 1 })
e.post('Checkräkningskredit', br['CheckrakningskreditLangfristig'], { indent: 1 })
e.post(
'Övriga skulder till kreditinstitut',
br['OvrigaLangfristigaSkulderKreditinstitut'],
{ indent: 1 },
)
e.post('Skulder till koncernföretag', br['SkulderKoncernforetagLangfristiga'], {
indent: 1,
})
e.post(
'Skulder till intresseföretag och gemensamt styrda företag',
br['SkulderIntresseforetagGemensamtStyrdaForetagLangfristiga'],
{ indent: 1 },
)
e.post(
'Skulder till övriga företag som det finns ett ägarintresse i',
br['SkulderOvrigaForetagAgarintresseLangfristiga'],
{ indent: 1 },
)
e.post('Övriga skulder', br['OvrigaLangfristigaSkulder'], { indent: 1 })
e.total('Summa långfristiga skulder', totals.langfristigaSkulder)
}
e.heading('Kortfristiga skulder')
e.post('Förskott från kunder', br['ForskottFranKunder'], { indent: 1 })
e.post('Checkräkningskredit', br['CheckrakningskreditKortfristig'], { indent: 1 })
e.post('Övriga skulder till kreditinstitut', br['OvrigaKortfristigaSkulderKreditinstitut'], {
indent: 1,
})
e.post(
'Pågående arbete för annans räkning',
br['PagaendeArbetenAnnansRakningKortfristigaSkulder'],
{ indent: 1 },
)
e.post('Fakturerad men ej upparbetad intäkt', br['FaktureradEjUpparbetadIntakt'], {
indent: 1,
})
e.post('Leverantörsskulder', br['Leverantorsskulder'], { indent: 1, alwaysShow: true })
e.post('Växelskulder', br['Vaxelskulder'], { indent: 1 })
e.post('Skulder till koncernföretag', br['SkulderKoncernforetagKortfristiga'], { indent: 1 })
e.post(
'Skulder till intresseföretag och gemensamt styrda företag',
br['SkulderIntresseforetagGemensamtStyrdaForetagKortfristiga'],
{ indent: 1 },
)
e.post(
'Skulder till övriga företag som det finns ett ägarintresse i',
br['SkulderOvrigaForetagAgarintresseKortfristiga'],
{ indent: 1 },
)
e.post('Skatteskulder', br['Skatteskulder'], { indent: 1 })
e.post('Övriga skulder', br['OvrigaKortfristigaSkulder'], { indent: 1, alwaysShow: true })
e.post(
'Upplupna kostnader och förutbetalda intäkter',
br['UpplupnaKostnaderForutbetaldaIntakter'],
{ indent: 1 },
)
e.total('Summa kortfristiga skulder', totals.kortfristigaSkulder)
e.total('Summa eget kapital och skulder', totals.egetKapitalSkulder)
return { assets: a.rows, equityLiabilities: e.rows }
}
+30 -13
View File
@@ -30,18 +30,24 @@ export interface NoteEntry {
body: string
}
export interface IncomeStatementLine {
/**
* One presentation row of the RR/BR in ÅRL uppställningsform. Post-level
* only — labels must never contain BAS account numbers (Bolagsverket
* rejects balans-/resultaträkningar med kontonummer). Rows are derived
* from the K2 risbs mapping in lib/bokslut/arsredovisning/statement-rows.ts.
*/
export interface StatementRow {
label: string
amount: number
/** True for total / subtotal lines. */
/** Whole-SEK amount for the current year; null on heading rows. */
current: number | null
/** Previous-year amount (jämförelseår, ÅRL 3:5 §); null on heading rows
* and when the company has no previous fiscal year. */
previous: number | null
/** Subtotal/total rows render bold with a top border. */
is_total?: boolean
}
export interface BalanceSheetLine {
label: string
amount: number
is_total?: boolean
/** Indent depth for nested grouping (0 = top, 1 = subgroup). */
/** Section headings carry no amounts. */
is_heading?: boolean
/** Indent depth (0 = section, 1 = subsection, 2 = post under subsection). */
indent?: number
}
@@ -59,6 +65,14 @@ export interface ArsredovisningData {
period_start: string
period_end: string
}
/** Previous fiscal period backing the jämförelseår column (ÅRL 3:5 §).
* Null for the company's first fiscal year, or when the previous year's
* trial balance could not be generated (a warning is emitted then). */
previous_period: {
name: string
period_start: string
period_end: string
} | null
/** Which BFNAR framework the document was generated under. Drives PDF
* rendering branching (K3 has an additional kassaflöde + equity-changes
* page and a richer note set) and lets the UI label the document
@@ -81,12 +95,15 @@ export interface ArsredovisningData {
* recorded": PDF then leaves the blank. */
agm_date: string | null
}
resultatrakning: IncomeStatementLine[]
resultatrakning: StatementRow[]
balansrakning: {
assets: BalanceSheetLine[]
assets: StatementRow[]
total_assets: number
equity_liabilities: BalanceSheetLine[]
/** Jämförelseår total; null when previous_period is null. */
total_assets_previous: number | null
equity_liabilities: StatementRow[]
total_equity_liabilities: number
total_equity_liabilities_previous: number | null
}
noter: NoteEntry[]
/** K3-only: full kassaflödesanalys (indirect method) rendered as its own
+21 -11
View File
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
Asset,
AssetCategory,
@@ -116,19 +117,28 @@ export async function listAssets(
companyId: string,
options: { activeOnly?: boolean } = {},
): Promise<Asset[]> {
let query = supabase
.from('assets')
.select('*')
.eq('company_id', companyId)
.order('acquisition_date', { ascending: true })
// Paginated past PostgREST's silent 1000-row cap — the depreciation engine
// iterates this list, so truncation would silently skip assets at year-end.
// Secondary order on id gives the stable total order .range() paging
// requires; acquisition_date alone is not unique.
try {
return await fetchAllRows<Asset>(({ from, to }) => {
let query = supabase
.from('assets')
.select('*')
.eq('company_id', companyId)
.order('acquisition_date', { ascending: true })
.order('id', { ascending: true })
if (options.activeOnly) {
query = query.is('disposed_at', null)
if (options.activeOnly) {
query = query.is('disposed_at', null)
}
return query.range(from, to)
})
} catch (err) {
throw new Error(`Failed to list assets: ${err instanceof Error ? err.message : String(err)}`)
}
const { data, error } = await query
if (error) throw new Error(`Failed to list assets: ${error.message}`)
return (data ?? []) as Asset[]
}
export async function getAsset(
+3 -1
View File
@@ -368,7 +368,9 @@ export async function buildIxbrlInput(
},
programvara: { namn: PROGRAMVARA_NAMN, version: PROGRAMVARA_VERSION },
entryPointId: entryPoint.id,
warnings: [...pdfData.warnings, ...warnings],
// Dedupe: buildArsredovisningData now runs the same K2 mapping for the
// PDF statements, so its warnings overlap with the ones produced here.
warnings: Array.from(new Set([...pdfData.warnings, ...warnings])),
}
}
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { applyTemplate, convertLibraryToBookingTemplate, getTemplateScope, LIBRARY_TEMPLATE_PREFIX, TEMPLATE_CATEGORY_LABELS } from '../template-library'
import { applyTemplate, convertLibraryToBookingTemplate, deriveTemplateLinesFromBooking, getTemplateScope, LIBRARY_TEMPLATE_PREFIX, TEMPLATE_CATEGORY_LABELS } from '../template-library'
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
function makeLibraryTemplate(lines: BookingTemplateLibraryLine[], overrides: Partial<BookingTemplateLibrary> = {}): BookingTemplateLibrary {
@@ -94,6 +94,94 @@ describe('applyTemplate', () => {
})
})
describe('deriveTemplateLinesFromBooking', () => {
it('classifies cost + input VAT + settlement (the Bokför direkt shape)', () => {
// Mirrors the screenshot: 2641 D 96.40, 5420 D 385.40, 2893 K 481.80.
const lines = deriveTemplateLinesFromBooking(
[
{ account_number: '2641', debit_amount: '96.40', credit_amount: '' },
{ account_number: '5420', debit_amount: '385.40', credit_amount: '' },
{ account_number: '2893', debit_amount: '', credit_amount: '481.80' },
],
{ '2641': 'Debiterad ingående moms', '5420': 'Programvaror', '2893': 'Avräkning ägare' },
)
const vat = lines.find((l) => l.account === '2641')!
expect(vat.type).toBe('vat')
expect(vat.vat_rate).toBe(0.25)
expect(vat.side).toBe('debit')
const cost = lines.find((l) => l.account === '5420')!
expect(cost.type).toBe('business')
expect(cost.label).toBe('Programvaror')
const settlement = lines.find((l) => l.account === '2893')!
expect(settlement.type).toBe('settlement')
expect(settlement.ratio).toBe(1)
expect(settlement.side).toBe('credit')
// Exactly one business + one settlement → convertible for the tx picker.
expect(convertLibraryToBookingTemplate(
{ id: 'x', company_id: null, team_id: null, created_by: null, name: 'n', description: '', category: 'other', entity_type: 'all', lines, is_system: false, is_active: true, created_at: '', updated_at: '' },
)).not.toBeNull()
})
it('re-applying the derived template reproduces the original split (± öre)', () => {
const source = [
{ account_number: '5420', debit_amount: '385.40', credit_amount: '' },
{ account_number: '2641', debit_amount: '96.40', credit_amount: '' },
{ account_number: '2893', debit_amount: '', credit_amount: '481.80' },
]
const applied = applyTemplate(deriveTemplateLinesFromBooking(source), 481.8)
const byAccount = Object.fromEntries(applied.map((l) => [l.account_number, l]))
expect(Number(byAccount['5420'].debit_amount)).toBeCloseTo(385.4, 1)
expect(Number(byAccount['2641'].debit_amount)).toBeCloseTo(96.36, 1)
expect(Number(byAccount['2893'].credit_amount)).toBeCloseTo(481.8, 2)
})
it('derives a simple two-line transfer (one business + one settlement)', () => {
const lines = deriveTemplateLinesFromBooking([
{ account_number: '1630', debit_amount: '10000', credit_amount: '' },
{ account_number: '1930', debit_amount: '', credit_amount: '10000' },
])
expect(lines).toHaveLength(2)
expect(lines.filter((l) => l.type === 'settlement')).toHaveLength(1)
expect(lines.filter((l) => l.type === 'business')).toHaveLength(1)
// The credit leg is tagged settlement on an equal-amount tie.
expect(lines.find((l) => l.account === '1930')!.type).toBe('settlement')
})
it('falls back to the account number when no name is supplied', () => {
const lines = deriveTemplateLinesFromBooking([
{ account_number: '1630', debit_amount: '500', credit_amount: '' },
{ account_number: '1930', debit_amount: '', credit_amount: '500' },
])
expect(lines.every((l) => l.label.length > 0)).toBe(true)
expect(lines.find((l) => l.account === '1630')!.label).toBe('1630')
})
it('drops rows without a 4-digit account or amount and returns [] below two lines', () => {
expect(
deriveTemplateLinesFromBooking([
{ account_number: '', debit_amount: '100', credit_amount: '' },
{ account_number: '19', debit_amount: '', credit_amount: '100' },
{ account_number: '1930', debit_amount: '0', credit_amount: '' },
]),
).toEqual([])
})
it('snaps a 12% VAT line to the reduced rate', () => {
// Hotel: 5820 net 1000, 2641 VAT 120, 1930 gross 1120.
const lines = deriveTemplateLinesFromBooking([
{ account_number: '5820', debit_amount: '1000', credit_amount: '' },
{ account_number: '2641', debit_amount: '120', credit_amount: '' },
{ account_number: '1930', debit_amount: '', credit_amount: '1120' },
])
expect(lines.find((l) => l.account === '2641')!.vat_rate).toBe(0.12)
expect(lines.find((l) => l.account === '1930')!.type).toBe('settlement')
})
})
describe('getTemplateScope', () => {
it('identifies system templates', () => {
expect(getTemplateScope({ is_system: true, team_id: null, company_id: null })).toBe('system')
@@ -83,7 +83,7 @@ const { createJournalEntry, findFiscalPeriod } = await import('../engine')
const mockedCreateEntry = vi.mocked(createJournalEntry)
const mockedFindFiscalPeriod = vi.mocked(findFiscalPeriod)
const { createTransactionJournalEntry, buildDomesticExpenseLines } = await import('../transaction-entries')
const { createTransactionJournalEntry, buildDomesticExpenseLines, buildTransactionEntryLines } = await import('../transaction-entries')
function makeMappingResult(overrides: Partial<MappingResult> = {}): MappingResult {
return {
@@ -477,6 +477,65 @@ describe('createTransactionJournalEntry', () => {
})
})
// The exported builder feeds the staged categorization preview (MCP
// preview_data.lines and the pending-operations PATCH re-derive) — these
// tests pin that what a user approves is the netted entry, not the
// gross-on-cost-account summary that used to mislead users and agents.
describe('buildTransactionEntryLines', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('nets the cost line against input VAT with öre precision', () => {
const tx = makeTransaction({ amount: -312.53, description: 'Mjukvaruabonnemang' })
const vatLines: VatJournalLine[] = [
{ account_number: '2641', debit_amount: 62.51, credit_amount: 0, description: 'Ingående moms (enligt underlag)' },
]
const mapping = makeMappingResult({
debit_account: '5420',
credit_account: '1930',
vat_lines: vatLines,
})
const lines = buildTransactionEntryLines(tx, mapping)
expect(lines).toHaveLength(3)
expect(lines.find(l => l.account_number === '5420')?.debit_amount).toBe(250.02) // 312.53 - 62.51
expect(lines.find(l => l.account_number === '2641')?.debit_amount).toBe(62.51)
expect(lines.find(l => l.account_number === '1930')?.credit_amount).toBe(312.53)
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBeCloseTo(totalCredit, 2)
})
it('matches the lines createTransactionJournalEntry posts (preview == booked)', async () => {
const tx = makeTransaction({ amount: -1250, description: 'Software license' })
const mapping = makeMappingResult({
debit_account: '5420',
credit_account: '1930',
vat_lines: [
{ account_number: '2641', debit_amount: 250, credit_amount: 0, description: 'Ingående moms 25%' },
],
})
const previewLines = buildTransactionEntryLines(tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
expect(previewLines).toEqual(input.lines)
})
it('throws InvalidMappingResultError on missing accounts', () => {
const tx = makeTransaction()
expect(() => buildTransactionEntryLines(tx, makeMappingResult({ debit_account: '' })))
.toThrow('Invalid mapping result')
expect(() => buildTransactionEntryLines(tx, makeMappingResult({ credit_account: '' })))
.toThrow('Invalid mapping result')
})
})
describe('buildDomesticExpenseLines', () => {
it('25% VAT: 3 lines (expense net + 2641 + 1930)', () => {
const lines = buildDomesticExpenseLines(1250, '5410', 'Office supplies', 0.25)
+120
View File
@@ -1,6 +1,7 @@
import type { BookingTemplateCategory, BookingTemplateLibrary, BookingTemplateLibraryLine, VatTreatment } from '@/types'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import { roundOre } from '@/lib/money'
/**
* Prefix for library template ids when they are mapped into the
@@ -165,3 +166,122 @@ export function convertLibraryToBookingTemplate(
common: false,
}
}
// ---------------------------------------------------------------------------
// Deriving a template from a concrete booking
// ---------------------------------------------------------------------------
/** A single concrete booking row, as produced by the manual-booking forms
* (BookDirectlyDialog / JournalEntryForm). Amounts are strings straight from
* the inputs; either debit or credit is set, not both. */
export interface BookingRowInput {
account_number: string
debit_amount: string
credit_amount: string
}
/** Standard Swedish VAT rates a template line can carry (matches the rate
* selector in the template editor). 0 = no/foreign VAT. */
const STANDARD_VAT_RATES = [0.25, 0.12, 0.06, 0] as const
/**
* Snap a VAT line's implied rate to the nearest standard rate. The implied rate
* is vatAmount / net where net = total − vatAmount (the same relationship
* applyTemplate inverts: vat = total × rate / (1 + rate)).
*/
function snapVatRate(vatAmount: number, total: number): number {
const net = total - vatAmount
const implied = net > 0 ? vatAmount / net : 0
return STANDARD_VAT_RATES.reduce<number>(
(best, rate) => (Math.abs(rate - implied) < Math.abs(best - implied) ? rate : best),
0.25,
)
}
/**
* Derive amount-parameterised template lines from a concrete, balanced set of
* booking rows — the inverse of {@link applyTemplate}, used by "Spara som mall".
*
* A booking stores literal debit/credit amounts; a template stores ratios of a
* total plus VAT rates. The mapping:
* - total = the larger of Σdebit / Σcredit (equal when the entry balances)
* - a 26xx line → a VAT line, its rate snapped to the nearest standard rate
* - the single non-VAT line closest to the total → the settlement leg (the
* bank / counter account), ratio 1
* - every other non-VAT line → a business (cost/revenue) line, ratio = amount/total
*
* Which non-VAT leg is tagged settlement vs business is only cosmetic — a
* template books its literal accounts regardless of the tag (see the
* "blind to business/settlement tagging" regression). The classification is a
* best-effort starting point that the user reviews and can re-tag in the editor
* before saving.
*
* Rows without a 4-digit account or with no amount are dropped. Returns [] when
* fewer than two usable lines remain or the total is non-positive.
*/
export function deriveTemplateLinesFromBooking(
rows: BookingRowInput[],
accountNames: Record<string, string> = {},
): BookingTemplateLibraryLine[] {
const parsed = rows
.map((row) => {
const account = row.account_number.trim()
const debit = Math.abs(parseFloat(row.debit_amount) || 0)
const credit = Math.abs(parseFloat(row.credit_amount) || 0)
const side: 'debit' | 'credit' = debit >= credit ? 'debit' : 'credit'
return { account, side, amount: Math.max(debit, credit) }
})
.filter((row) => /^\d{4}$/.test(row.account) && row.amount > 0)
if (parsed.length < 2) return []
const sumDebit = parsed.reduce((s, r) => (r.side === 'debit' ? s + r.amount : s), 0)
const sumCredit = parsed.reduce((s, r) => (r.side === 'credit' ? s + r.amount : s), 0)
const total = roundOre(Math.max(sumDebit, sumCredit))
if (total <= 0) return []
const isVat = (account: string) => account.startsWith('26')
// Pick the settlement leg among the non-VAT lines: the one closest to the
// total (the bank / counter account). Equal distances prefer a credit leg,
// then the later row.
let settlementIndex = -1
let bestDistance = Infinity
let bestIsCredit = false
parsed.forEach((row, index) => {
if (isVat(row.account)) return
const distance = Math.abs(row.amount - total)
const closer = distance < bestDistance - 0.005
const tiePreferCredit =
Math.abs(distance - bestDistance) <= 0.005 && (row.side === 'credit' || !bestIsCredit)
if (settlementIndex === -1 || closer || tiePreferCredit) {
settlementIndex = index
bestDistance = distance
bestIsCredit = row.side === 'credit'
}
})
const label = (account: string) => accountNames[account]?.trim() || account
return parsed.map((row, index) => {
if (isVat(row.account)) {
return {
account: row.account,
label: label(row.account),
side: row.side,
type: 'vat',
vat_rate: snapVatRate(row.amount, total),
}
}
if (index === settlementIndex) {
return { account: row.account, label: label(row.account), side: row.side, type: 'settlement', ratio: 1 }
}
return {
account: row.account,
label: label(row.account),
side: row.side,
type: 'business',
ratio: Math.round((row.amount / total) * 10000) / 10000,
}
})
}
+36 -17
View File
@@ -16,7 +16,11 @@ import type {
const log = createLogger('transaction-entries')
/**
* Create a journal entry from a bank transaction using mapping engine result
* Build the journal entry lines for a bank transaction from a mapping engine
* result. Single source of truth for the gross→net split: the expense account
* gets the amount net of deductible input VAT while the bank line stays gross.
* Used both by createTransactionJournalEntry (commit) and by the staged
* categorization preview, so the lines a user approves are the lines posted.
*
* Standard expense pattern (domestic purchase with 25% VAT):
* Debit 5xxx/6xxx Expense account [net amount]
@@ -41,28 +45,14 @@ const log = createLogger('transaction-entries')
* Debit 1930 Företagskonto [total]
* Credit 3xxx Revenue account [total]
*/
export async function createTransactionJournalEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
export function buildTransactionEntryLines(
transaction: Transaction,
mappingResult: MappingResult,
// Optional audit-trail text to append to the verifikation's description.
// Used by the agent for representation bookings to capture deltagare +
// syfte directly on the journal entry (SKV's representationsregler /
// ML 8 kap require the verifikation to document who attended and why).
notes?: string,
): Promise<JournalEntry | null> {
): CreateJournalEntryLineInput[] {
if (!mappingResult.debit_account || !mappingResult.credit_account) {
throw new InvalidMappingResultError(mappingResult.debit_account, mappingResult.credit_account)
}
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, transaction.date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for transaction date:', transaction.date)
return null
}
const absAmountSek = Math.abs(resolveSekAmount(
transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate
))
@@ -251,6 +241,35 @@ export async function createTransactionJournalEntry(
}
}
return lines
}
/**
* Create a journal entry from a bank transaction using mapping engine result.
* Line patterns are documented on buildTransactionEntryLines above.
*/
export async function createTransactionJournalEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
transaction: Transaction,
mappingResult: MappingResult,
// Optional audit-trail text to append to the verifikation's description.
// Used by the agent for representation bookings to capture deltagare +
// syfte directly on the journal entry (SKV's representationsregler /
// ML 8 kap require the verifikation to document who attended and why).
notes?: string,
): Promise<JournalEntry | null> {
// Build lines first — throws InvalidMappingResultError on a broken mapping
// before any period lookup, preserving the original validation order.
const lines = buildTransactionEntryLines(transaction, mappingResult)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, transaction.date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for transaction date:', transaction.date)
return null
}
// Compose the verifikation's description (verifikationstext). journal_entries
// has no separate notes column: the description IS the BFL audit field, so
// representation deltagare/syfte etc. belong here. Separate the bank text
+1 -1
View File
@@ -11,7 +11,7 @@
* client-readable, BRANDING_* for server-only) or registers a branding
* extension via registerBrandingService().
*
* See WHITELABEL.md for the full env var reference and fork checklist.
* See docs/WHITELABEL.md for the full env var reference and fork checklist.
*/
export interface BrandingConfig {
+169
View File
@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
// syncMappedAccounts is exercised by its own suite — here it only needs to be
// observable so allocatePsd2LedgerAccount's chart-ensure call can be asserted.
const { mockSyncMappedAccounts } = vi.hoisted(() => ({
mockSyncMappedAccounts: vi.fn(),
}))
vi.mock('@/lib/import/account-sync', () => ({
syncMappedAccounts: (...args: unknown[]) => mockSyncMappedAccounts(...args),
}))
import {
findFreeLedgerAccount,
allocatePsd2LedgerAccount,
defaultLedgerForCurrency,
} from '../service'
type CashRow = { ledger_account: string; bank_connection_id: string | null }
function makeSupabase(rows: CashRow[], error: { message: string } | null = null) {
return {
from: vi.fn(() => ({
select: vi.fn().mockReturnThis(),
eq: vi.fn(() => Promise.resolve({ data: error ? null : rows, error })),
})),
} as unknown as SupabaseClient
}
beforeEach(() => {
vi.clearAllMocks()
mockSyncMappedAccounts.mockResolvedValue({
created: 1,
renamed: 0,
renamedAccounts: [],
renameFailed: 0,
error: null,
})
})
describe('defaultLedgerForCurrency', () => {
it('maps the four known currencies and falls back to 1930', () => {
expect(defaultLedgerForCurrency('SEK')).toBe('1930')
expect(defaultLedgerForCurrency('eur')).toBe('1932')
expect(defaultLedgerForCurrency('USD')).toBe('1933')
expect(defaultLedgerForCurrency('GBP')).toBe('1934')
expect(defaultLedgerForCurrency('NOK')).toBe('1930')
})
})
describe('findFreeLedgerAccount', () => {
it('returns the currency default when nothing holds it', async () => {
const supabase = makeSupabase([])
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1930')
expect(await findFreeLedgerAccount(supabase, 'c1', 'EUR')).toBe('1932')
})
it('returns the default when only a MANUAL row holds it (seed promotion)', async () => {
// The seeded 1930 row has no bank connection — upsertFromPsd2 promotes it
// in place, so the slot counts as free.
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: null }])
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1930')
})
it('overflows to 1931 when a CONNECTED row holds the default', async () => {
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: 'conn-1' }])
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1931')
})
it('never hands out another currency default as an overflow slot', async () => {
const supabase = makeSupabase([
{ ledger_account: '1930', bank_connection_id: 'conn-1' },
{ ledger_account: '1931', bank_connection_id: 'conn-1' },
])
// 1932/1933/1934 are reserved for EUR/USD/GBP — next free is 1935.
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1935')
})
it('does not steal a manual account on an overflow slot', async () => {
const supabase = makeSupabase([
{ ledger_account: '1930', bank_connection_id: 'conn-1' },
// Manual (e.g. SIE-imported) account on 1931 — promoting it would
// silently repoint an unrelated account.
{ ledger_account: '1931', bank_connection_id: null },
])
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1935')
})
it('honors the exclude set for slots assigned earlier in the caller loop', async () => {
const supabase = makeSupabase([])
const exclude = new Set(['1930', '1931'])
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK', exclude)).toBe('1935')
})
it('returns null when every slot in 1931–1959 is taken', async () => {
const rows: CashRow[] = [{ ledger_account: '1930', bank_connection_id: 'conn-1' }]
for (let n = 1931; n <= 1959; n++) {
rows.push({ ledger_account: String(n), bank_connection_id: 'conn-1' })
}
const supabase = makeSupabase(rows)
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBeNull()
})
it('returns null when the lookup fails', async () => {
const supabase = makeSupabase([], { message: 'boom' })
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBeNull()
})
})
describe('allocatePsd2LedgerAccount', () => {
it('allocates a slot and ensures it exists in the chart of accounts', async () => {
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: 'conn-1' }])
const ledger = await allocatePsd2LedgerAccount(supabase, 'c1', 'u1', {
currency: 'SEK',
accountName: 'Sparkonto',
})
expect(ledger).toBe('1931')
expect(mockSyncMappedAccounts).toHaveBeenCalledTimes(1)
const [, companyId, userId, mappings] = mockSyncMappedAccounts.mock.calls[0]
expect(companyId).toBe('c1')
expect(userId).toBe('u1')
expect(mappings).toEqual([
expect.objectContaining({
sourceAccount: '1931',
targetAccount: '1931',
sourceName: 'Sparkonto',
}),
])
})
it('uses a currency fallback name when the bank account has none', async () => {
const supabase = makeSupabase([])
await allocatePsd2LedgerAccount(supabase, 'c1', 'u1', { currency: 'EUR' })
const [, , , mappings] = mockSyncMappedAccounts.mock.calls[0]
expect(mappings[0].sourceName).toBe('Bankkonto EUR')
})
it('returns null when the chart sync fails — a slot that cannot be booked against is useless', async () => {
mockSyncMappedAccounts.mockResolvedValue({
created: 0,
renamed: 0,
renamedAccounts: [],
renameFailed: 0,
error: 'chart unavailable',
})
const supabase = makeSupabase([])
expect(
await allocatePsd2LedgerAccount(supabase, 'c1', 'u1', { currency: 'SEK' }),
).toBeNull()
})
it('returns null when no slot is free', async () => {
const rows: CashRow[] = [{ ledger_account: '1930', bank_connection_id: 'conn-1' }]
for (let n = 1931; n <= 1959; n++) {
rows.push({ ledger_account: String(n), bank_connection_id: 'conn-1' })
}
const supabase = makeSupabase(rows)
expect(
await allocatePsd2LedgerAccount(supabase, 'c1', 'u1', { currency: 'SEK' }),
).toBeNull()
expect(mockSyncMappedAccounts).not.toHaveBeenCalled()
})
})
+121
View File
@@ -1,9 +1,25 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { CashAccount, CashAccountSource } from '@/types'
import { createLogger } from '@/lib/logger'
import { syncMappedAccounts } from '@/lib/import/account-sync'
const log = createLogger('cash-accounts')
/**
* Suggested BAS account per currency. Single source — the enable-banking
* callback and the AccountPickerDialog both key off these.
*/
export const CURRENCY_LEDGER_DEFAULTS: Record<string, string> = {
SEK: '1930',
EUR: '1932',
USD: '1933',
GBP: '1934',
}
export function defaultLedgerForCurrency(currency: string): string {
return CURRENCY_LEDGER_DEFAULTS[currency.toUpperCase()] ?? '1930'
}
/**
* Canonical read/write surface for cash_accounts.
*
@@ -115,6 +131,111 @@ export async function findByIban(
return (data as CashAccount | null) ?? null
}
/**
* Find a free BAS class-19 slot for a new PSD2 cash account, respecting the
* UNIQUE (company_id, ledger_account) constraint. A bank returning N
* same-currency accounts must not map them all to the currency default —
* that's exactly the collision this prevents.
*
* Rules:
* - The currency default (1930/1932/1933/1934) is available when no
* PSD2-backed row holds it. A manual holder (the seeded 1930 row) does
* not block it — upsertFromPsd2 promotes that row in place.
* - Overflow walks the free-use 1931–1959 sub-account slots, skipping the
* four currency defaults (reserved as suggestions for their currencies)
* and any slot held by ANY existing row — promoting an unrelated manual
* account (SIE-imported, kassa) would silently steal it.
* - `exclude` carries slots already assigned earlier in the caller's loop
* but not yet visible in the table.
*
* Returns null when no slot is free (or the lookup fails) — callers fall back
* to their previous behavior and surface the error.
*/
export async function findFreeLedgerAccount(
supabase: SupabaseClient,
companyId: string,
currency: string,
exclude: ReadonlySet<string> = new Set(),
): Promise<string | null> {
const preferred = defaultLedgerForCurrency(currency)
const { data: rows, error } = await supabase
.from('cash_accounts')
.select('ledger_account, bank_connection_id')
.eq('company_id', companyId)
if (error) {
log.error('findFreeLedgerAccount lookup failed', { companyId, error: error.message })
return null
}
const anyTaken = new Set<string>()
const connectedTaken = new Set<string>()
for (const row of (rows ?? []) as Array<{ ledger_account: string; bank_connection_id: string | null }>) {
anyTaken.add(row.ledger_account)
if (row.bank_connection_id !== null) connectedTaken.add(row.ledger_account)
}
if (!exclude.has(preferred) && !connectedTaken.has(preferred)) return preferred
const reserved = new Set(Object.values(CURRENCY_LEDGER_DEFAULTS))
for (let n = 1931; n <= 1959; n++) {
const candidate = String(n)
if (reserved.has(candidate)) continue
if (exclude.has(candidate) || anyTaken.has(candidate)) continue
return candidate
}
log.warn('findFreeLedgerAccount exhausted 1931–1959', { companyId, currency })
return null
}
/**
* Allocate a ledger slot for a new PSD2 account AND make sure that account
* number exists in the company's chart of accounts — cash_accounts has no FK
* to the chart, but booking (and the AccountPicker, which only lists chart
* accounts) breaks on numbers the chart doesn't know. Sub-accounts outside
* the BAS reference (1931, …) are created with metadata derived from the
* account number; standard numbers get their BAS name.
*/
export async function allocatePsd2LedgerAccount(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: { currency: string; accountName?: string | null; exclude?: ReadonlySet<string> },
): Promise<string | null> {
const ledger = await findFreeLedgerAccount(supabase, companyId, input.currency, input.exclude ?? new Set())
if (!ledger) return null
const name = input.accountName?.trim() || `Bankkonto ${input.currency.toUpperCase()}`
const sync = await syncMappedAccounts(
supabase,
companyId,
userId,
[
{
sourceAccount: ledger,
sourceName: name,
targetAccount: ledger,
targetName: name,
confidence: 1,
matchType: 'exact',
isOverride: false,
},
],
false,
)
if (sync.error) {
log.error('allocatePsd2LedgerAccount chart sync failed', {
companyId,
ledger,
error: sync.error,
})
return null
}
return ledger
}
/**
* Upsert a PSD2-sourced cash account during connection callback / sync. Keyed on
* (company_id, bank_connection_id, external_uid). When the row exists, balance
@@ -0,0 +1,333 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from '@/tests/pg/setup'
import {
insertAuthUser,
insertBalancedLines,
insertCompanyMember,
insertDraftJournalEntry,
seedCompany,
} from '@/tests/pg/fixtures'
// relink_documents_to_correction (20260704103000) is the ONLY legal path for
// moving underlag from a corrected (reversed) entry to its correction. The
// immutability triggers block the direct UPDATE unconditionally; the RPC
// validates the correction chain and performs the move under the narrow
// gnubok.allow_correction_relink GUC. These tests reproduce the production
// failure ([storno] relinkDocumentsToEntry: BFL_DOCUMENT_IMMUTABILITY) and
// prove the RPC path works while every abuse path stays blocked.
const BFL_RETENTION_ERROR = /BFL [57] kap/i
async function insertDocument(params: {
userId: string
companyId: string
journalEntryId: string | null
journalEntryLineId?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.document_attachments
(id, user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash, journal_entry_id, journal_entry_line_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
id,
params.userId,
params.companyId,
`documents/${params.userId}/${id}.pdf`,
'underlag.pdf',
1024,
'application/pdf',
'a'.repeat(64),
params.journalEntryId,
params.journalEntryLineId ?? null,
],
)
return id
}
async function insertEntryAtStatus(params: {
userId: string
companyId: string
fiscalPeriodId: string
voucherNumber: number
status?: 'posted' | 'reversed'
correctionOfId?: string
}): Promise<string> {
const entryId = await insertDraftJournalEntry({
userId: params.userId,
companyId: params.companyId,
fiscalPeriodId: params.fiscalPeriodId,
voucherNumber: params.voucherNumber,
})
await insertBalancedLines(entryId)
if (params.correctionOfId) {
// Drafts are mutable — set the correction link before posting; the
// immutability trigger blocks changing it afterwards.
await getPool().query(
`UPDATE public.journal_entries SET correction_of_id = $2 WHERE id = $1`,
[entryId, params.correctionOfId],
)
}
await getPool().query(
`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`,
[entryId],
)
if (params.status === 'reversed') {
await getPool().query(
`UPDATE public.journal_entries SET status = 'reversed' WHERE id = $1`,
[entryId],
)
}
return entryId
}
/** A reversed original + its posted correction, mirroring correctEntry()'s end state. */
async function seedCorrectionPair(seed: {
userId: string
companyId: string
fiscalPeriodId: string
}): Promise<{ originalId: string; correctionId: string }> {
const originalId = await insertEntryAtStatus({
...seed,
voucherNumber: 1,
status: 'reversed',
})
const correctionId = await insertEntryAtStatus({
...seed,
voucherNumber: 2,
correctionOfId: originalId,
})
return { originalId, correctionId }
}
describe('correction-document-relink.pg — relink_documents_to_correction RPC', () => {
it('reproduces the production failure: direct relink UPDATE is blocked', async () => {
const seed = await seedCompany()
const { originalId, correctionId } = await seedCorrectionPair(seed)
const docId = await insertDocument({
userId: seed.userId,
companyId: seed.companyId,
journalEntryId: originalId,
})
// This is exactly what relinkDocumentsToEntry used to run — and why it
// failed 100% of the time in production.
await expect(
getPool().query(
`UPDATE public.document_attachments
SET journal_entry_id = $1, journal_entry_line_id = NULL
WHERE id = $2`,
[correctionId, docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
})
it('moves underlag along a genuine correction chain and clears the line link', async () => {
const seed = await seedCompany()
const { originalId, correctionId } = await seedCorrectionPair(seed)
const lineRow = await getPool().query<{ id: string }>(
`SELECT id FROM public.journal_entry_lines WHERE journal_entry_id = $1 LIMIT 1`,
[originalId],
)
const docId = await insertDocument({
userId: seed.userId,
companyId: seed.companyId,
journalEntryId: originalId,
journalEntryLineId: lineRow.rows[0]!.id,
})
await withUserContext(seed.userId, async (client) => {
const result = await client.query<{ moved: number }>(
`SELECT public.relink_documents_to_correction($1::uuid, $2::uuid, $3::uuid) AS moved`,
[seed.userId, originalId, correctionId],
)
expect(result.rows[0]!.moved).toBe(1)
const after = await client.query<{
journal_entry_id: string | null
journal_entry_line_id: string | null
}>(
`SELECT journal_entry_id, journal_entry_line_id
FROM public.document_attachments WHERE id = $1`,
[docId],
)
expect(after.rows[0]!.journal_entry_id).toBe(correctionId)
expect(after.rows[0]!.journal_entry_line_id).toBeNull()
// The move is auditable.
const audit = await client.query(
`SELECT 1 FROM public.audit_log
WHERE table_name = 'document_attachments'
AND record_id = $1
AND description LIKE 'Relinked%'`,
[originalId],
)
expect(audit.rows).toHaveLength(1)
})
})
it('accepts a service-role caller with an explicit p_user_id (pending-operations executor)', async () => {
const seed = await seedCompany()
const { originalId, correctionId } = await seedCorrectionPair(seed)
await insertDocument({
userId: seed.userId,
companyId: seed.companyId,
journalEntryId: originalId,
})
const client = await getPool().connect()
try {
await client.query('BEGIN')
// Service-role shape: role claim without a sub — auth.uid() is NULL.
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
JSON.stringify({ role: 'service_role' }),
])
const result = await client.query<{ moved: number }>(
`SELECT public.relink_documents_to_correction($1::uuid, $2::uuid, $3::uuid) AS moved`,
[seed.userId, originalId, correctionId],
)
expect(result.rows[0]!.moved).toBe(1)
} finally {
// Always roll back, even if the RPC or an assertion threw: releasing a
// connection mid-transaction poisons it for the next test that reuses it.
await client.query('ROLLBACK').catch(() => {})
client.release()
}
})
it('rejects when the target is not the correction of the source', async () => {
const seed = await seedCompany()
const originalId = await insertEntryAtStatus({
...seed,
voucherNumber: 1,
status: 'reversed',
})
// Posted, but with no correction_of_id link back to the original.
const unrelatedId = await insertEntryAtStatus({ ...seed, voucherNumber: 2 })
await insertDocument({
userId: seed.userId,
companyId: seed.companyId,
journalEntryId: originalId,
})
await withUserContext(seed.userId, async (client) => {
await expect(
client.query(
`SELECT public.relink_documents_to_correction($1::uuid, $2::uuid, $3::uuid)`,
[seed.userId, originalId, unrelatedId],
),
).rejects.toThrow(/not the correction of/i)
})
})
it('rejects when the source entry is not reversed', async () => {
const seed = await seedCompany()
const originalId = await insertEntryAtStatus({ ...seed, voucherNumber: 1 }) // still posted
const correctionId = await insertEntryAtStatus({
...seed,
voucherNumber: 2,
correctionOfId: originalId,
})
await withUserContext(seed.userId, async (client) => {
await expect(
client.query(
`SELECT public.relink_documents_to_correction($1::uuid, $2::uuid, $3::uuid)`,
[seed.userId, originalId, correctionId],
),
).rejects.toThrow(/not reversed/i)
})
})
it('rejects a caller who is not a member of the entries company', async () => {
const seed = await seedCompany()
const { originalId, correctionId } = await seedCorrectionPair(seed)
const bobId = await insertAuthUser()
await withUserContext(bobId, async (client) => {
await expect(
client.query(
`SELECT public.relink_documents_to_correction($1::uuid, $2::uuid, $3::uuid)`,
[bobId, originalId, correctionId],
),
).rejects.toThrow(/not a member/i)
})
})
it('rejects a spoofed p_user_id from an authenticated session', async () => {
const seed = await seedCompany()
const { originalId, correctionId } = await seedCorrectionPair(seed)
const malloryId = await insertAuthUser()
await insertCompanyMember({
companyId: seed.companyId,
userId: malloryId,
role: 'member',
})
await withUserContext(malloryId, async (client) => {
await expect(
client.query(
`SELECT public.relink_documents_to_correction($1::uuid, $2::uuid, $3::uuid)`,
// Attributing the relink to seed.userId while auth.uid() is mallory.
[seed.userId, originalId, correctionId],
),
).rejects.toThrow(/does not match authenticated user/i)
})
})
it('GUC stays narrow: journal_entry_id → NULL is blocked even with the relink GUC set', async () => {
const seed = await seedCompany()
const { originalId } = await seedCorrectionPair(seed)
const docId = await insertDocument({
userId: seed.userId,
companyId: seed.companyId,
journalEntryId: originalId,
})
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_correction_relink', 'true', true)`)
// Clearing to NULL would be a soft-delete bypass of BFL 7 kap 2§ —
// must raise even under the GUC.
await expect(
client.query(
`UPDATE public.document_attachments SET journal_entry_id = NULL WHERE id = $1`,
[docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
it('GUC stays narrow: sha256_hash mutation is blocked even with the relink GUC set', async () => {
const seed = await seedCompany()
const { originalId } = await seedCorrectionPair(seed)
const docId = await insertDocument({
userId: seed.userId,
companyId: seed.companyId,
journalEntryId: originalId,
})
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_correction_relink', 'true', true)`)
await expect(
client.query(
`UPDATE public.document_attachments SET sha256_hash = $2 WHERE id = $1`,
[docId, 'f'.repeat(64)],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
})
+34 -15
View File
@@ -131,7 +131,7 @@ export async function correctEntry(
*/
preloadedOriginal?: OriginalWithLines
}
): Promise<{ reversal: JournalEntry; corrected: JournalEntry }> {
): Promise<{ reversal: JournalEntry; corrected: JournalEntry; documentRelinkError?: string }> {
// Validate the corrected lines are balanced
const balance = validateBalance(correctedLines)
if (!balance.valid) {
@@ -424,9 +424,16 @@ export async function correctEntry(
// live representation of the affärshändelse, so the transaction row should
// keep reading as booked against it (and stay correctable/uncategorizable),
// and the underlag should travel with it. Best-effort: the correction_of_id
// chain preserves traceability even if either relink fails.
// chain preserves traceability even if either relink fails, but a document
// relink failure is surfaced on the result so the caller can warn instead
// of silently stranding underlag on the reversed entry.
await relinkTransactionsToEntry(supabase, companyId, originalEntryId, correctedEntry!.id)
await relinkDocumentsToEntry(supabase, companyId, originalEntryId, correctedEntry!.id)
const documentRelinkError = await relinkDocumentsToEntry(
supabase,
userId,
originalEntryId,
correctedEntry!.id
)
// ===== Step 3: Fetch complete entries =====
const { data: finalReversal } = await supabase
@@ -444,6 +451,7 @@ export async function correctEntry(
const result = {
reversal: finalReversal as JournalEntry,
corrected: finalCorrected as JournalEntry,
...(documentRelinkError ? { documentRelinkError } : {}),
}
await eventBus.emit({
@@ -582,27 +590,38 @@ async function relinkTransactionsToEntry(
}
/**
* Re-point every document_attachment from one entry to another. Used when a
* verifikation is moved to a different period so its underlag travels with the
* live (corrected) entry. The line-level link is cleared because the corrected
* entry has new line ids. Failures are logged, not thrown: the entry-level
* correction chain is the source of truth for traceability.
* Re-point every document_attachment from one entry to another so the
* underlag travels with the live (corrected) entry. Goes through the
* relink_documents_to_correction RPC: a direct UPDATE on
* document_attachments.journal_entry_id is categorically blocked by the
* BFL immutability triggers (migration 20260506130000/150000); the RPC
* validates the correction chain (source reversed, target posted,
* target.correction_of_id = source) and performs the move under the narrow
* gnubok.allow_correction_relink GUC. The line-level link is cleared because
* the corrected entry has new line ids.
*
* Failures are logged, not thrown (the entry-level correction chain is the
* source of truth for traceability), but the error message is returned so
* correctEntry can surface a warning instead of pretending the underlag
* moved.
*/
async function relinkDocumentsToEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
fromEntryId: string,
toEntryId: string
): Promise<void> {
const { error } = await supabase
.from('document_attachments')
.update({ journal_entry_id: toEntryId, journal_entry_line_id: null })
.eq('company_id', companyId)
.eq('journal_entry_id', fromEntryId)
): Promise<string | undefined> {
const { error } = await supabase.rpc('relink_documents_to_correction', {
p_user_id: userId,
p_from_entry_id: fromEntryId,
p_to_entry_id: toEntryId,
})
if (error) {
console.error(
`[storno] relinkDocumentsToEntry: failed to move documents ${fromEntryId} → ${toEntryId}:`,
error.message
)
return error.message
}
return undefined
}
@@ -116,6 +116,53 @@ describe('validateDocumentMagicBytes: application/xhtml+xml', () => {
})
})
describe('validateDocumentMagicBytes: application/json', () => {
const toBuffer = (text: string, bom = false): ArrayBuffer => {
const bytes = new TextEncoder().encode(bom ? `${text}` : text)
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
it('accepts a JSON object (raw PSD2 response archive shape)', () => {
const psd2Page = JSON.stringify({ transactions: [{ amount: '100.00' }], continuation_key: null })
expect(validateDocumentMagicBytes(toBuffer(psd2Page), 'application/json')).toBeNull()
})
it('accepts a JSON array, leading whitespace, and a UTF-8 BOM', () => {
expect(validateDocumentMagicBytes(toBuffer('[1, 2, 3]'), 'application/json')).toBeNull()
expect(validateDocumentMagicBytes(toBuffer('\n {"a": 1}'), 'application/json')).toBeNull()
expect(validateDocumentMagicBytes(toBuffer('{"a": 1}', true), 'application/json')).toBeNull()
})
it('rejects prose placeholders and bare JSON scalars', () => {
expect(validateDocumentMagicBytes(toBuffer('summary of the response'), 'application/json')).toMatch(
/kunde inte verifieras/,
)
// Scalars parse as JSON but are not a plausible archived API response:
// the object/array root requirement keeps the anti-placeholder defense.
expect(validateDocumentMagicBytes(toBuffer('"just a string"'), 'application/json')).toMatch(
/kunde inte verifieras/,
)
expect(validateDocumentMagicBytes(toBuffer('42'), 'application/json')).toMatch(
/kunde inte verifieras/,
)
expect(validateDocumentMagicBytes(toBuffer('null'), 'application/json')).toMatch(
/kunde inte verifieras/,
)
})
it('rejects truncated JSON', () => {
expect(validateDocumentMagicBytes(toBuffer('{"transactions": [{"amount":'), 'application/json')).toMatch(
/kunde inte verifieras/,
)
})
it('does not loosen validation for other declared types', () => {
expect(validateDocumentMagicBytes(toBuffer('{"a": 1}'), 'application/pdf')).toMatch(
/kunde inte verifieras/,
)
})
})
describe('validateDocumentMagicBytes: PDF header offset tolerance', () => {
const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer =>
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
+22
View File
@@ -111,6 +111,24 @@ function looksLikeXhtml(bytes: Uint8Array): boolean {
return head.startsWith('<?xml') || head.startsWith('<!doctype html') || head.startsWith('<html')
}
/**
* JSON has no binary magic number either. For the declared type
* application/json (raw PSD2 responses archived as räkenskapsinformation per
* BFL 7 kap) the content must parse as JSON with an object or array root — a
* prose placeholder is not valid JSON, and a bare quoted string still fails
* the root check, so the anti-placeholder defense stays intact. Consulted
* ONLY for that declared type.
*/
function looksLikeJson(bytes: Uint8Array): boolean {
const offset = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ? 3 : 0
try {
const parsed = JSON.parse(Buffer.from(bytes.slice(offset)).toString('utf8'))
return typeof parsed === 'object' && parsed !== null
} catch {
return false
}
}
/**
* Verify the buffer actually contains a file of the declared type.
* Returns an error string or null if valid. HEIC has many ftyp brands so
@@ -123,6 +141,10 @@ export function validateDocumentMagicBytes(buffer: ArrayBuffer, declaredMimeType
if (looksLikeXhtml(new Uint8Array(buffer))) return null
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar inte vara ett XHTML/XML-dokument.`
}
if (declaredMimeType === 'application/json') {
if (looksLikeJson(new Uint8Array(buffer))) return null
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar inte vara ett giltigt JSON-dokument.`
}
const detected = detectFileMagic(new Uint8Array(buffer))
if (!detected) {
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar vara skadad eller inte en riktig binärfil: vid uppladdning via API, kontrollera att file_content_base64 är base64-kodade råbytes, inte en textrepresentation.`
+133 -5
View File
@@ -48,17 +48,15 @@ describe('fetchExchangeRate', () => {
})
})
it('returns fallback rate on fetch error', async () => {
it('returns null on fetch error — never a hardcoded rate on the booking path', async () => {
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
const result = await fetchExchangeRate('EUR')
expect(result).not.toBeNull()
expect(result!.currency).toBe('EUR')
expect(result!.rate).toBeGreaterThan(0)
expect(result).toBeNull()
})
it('tries fallback URL when primary returns non-200', async () => {
it('tries fallback URL when primary returns 404 (no observation for the date)', async () => {
vi.spyOn(global, 'fetch')
.mockResolvedValueOnce(new Response('Not Found', { status: 404 }))
.mockResolvedValueOnce(
@@ -76,6 +74,136 @@ describe('fetchExchangeRate', () => {
date: '2025-01-14',
})
})
it('retries once on 429 and does NOT fire the range-fallback request', async () => {
vi.useFakeTimers()
try {
const fetchSpy = vi
.spyOn(global, 'fetch')
.mockResolvedValueOnce(
new Response('Too Many Requests', { status: 429, headers: { 'retry-after': '1' } })
)
.mockResolvedValueOnce(new Response('Too Many Requests', { status: 429 }))
const promise = fetchExchangeRate('EUR', new Date('2025-01-15'))
await vi.runAllTimersAsync()
const result = await promise
// Two calls to the SAME single-day URL (initial + retry); the 7-day
// range endpoint is never hit — the old code fired it on 429 and
// doubled the load on an already rate-limited API.
expect(fetchSpy).toHaveBeenCalledTimes(2)
const urls = fetchSpy.mock.calls.map((c) => String(c[0]))
expect(urls[0]).toBe(urls[1])
expect(urls[0]).toContain('/2025-01-15/2025-01-15')
expect(result).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('recovers when the 429 retry succeeds', async () => {
vi.useFakeTimers()
try {
vi.spyOn(global, 'fetch')
.mockResolvedValueOnce(new Response('Too Many Requests', { status: 429 }))
.mockResolvedValueOnce(
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
)
const promise = fetchExchangeRate('EUR', new Date('2025-01-15'))
await vi.runAllTimersAsync()
const result = await promise
expect(result).toEqual({ currency: 'EUR', rate: 11.42, date: '2025-01-15' })
} finally {
vi.useRealTimers()
}
})
describe('with the persistent exchange_rates cache (supabase passed)', () => {
type CacheRow = { rate: number; observation_date: string }
function makeSupabase(opts: {
exactHit?: CacheRow | null
latestHit?: CacheRow | null
onUpsert?: (row: Record<string, unknown>) => void
}) {
// .maybeSingle() terminates both the exact lookup and the latest
// lookup. Shared across from() calls so the once-queue holds: the
// first maybeSingle in a test is the exact lookup, subsequent ones
// are the latest-cached lookup.
const maybeSingle = vi
.fn()
.mockResolvedValueOnce({ data: opts.exactHit ?? null, error: null })
.mockResolvedValue({ data: opts.latestHit ?? null, error: null })
return {
from: vi.fn(() => ({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
maybeSingle,
upsert: vi.fn((row: Record<string, unknown>) => {
opts.onUpsert?.(row)
return Promise.resolve({ data: null, error: null })
}),
})),
} as never
}
it('serves a cache hit without touching Riksbanken', async () => {
const fetchSpy = vi.spyOn(global, 'fetch')
const supabase = makeSupabase({ exactHit: { rate: 11.11, observation_date: '2025-01-15' } })
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
expect(result).toEqual({ currency: 'EUR', rate: 11.11, date: '2025-01-15' })
expect(fetchSpy).not.toHaveBeenCalled()
})
it('writes a fetched rate into the cache', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
)
let upserted: Record<string, unknown> | undefined
const supabase = makeSupabase({ onUpsert: (row) => (upserted = row) })
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
expect(result).toEqual({ currency: 'EUR', rate: 11.42, date: '2025-01-15' })
expect(upserted).toMatchObject({
currency: 'EUR',
rate_date: '2025-01-15',
rate: 11.42,
observation_date: '2025-01-15',
source: 'riksbanken',
})
})
it('falls back to the most recent cached observation when Riksbanken is down', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
const supabase = makeSupabase({
exactHit: null,
latestHit: { rate: 11.38, observation_date: '2025-01-10' },
})
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
// An honest, dated observation — not a hardcoded 11.5.
expect(result).toEqual({ currency: 'EUR', rate: 11.38, date: '2025-01-10' })
})
it('returns null when Riksbanken is down and the cache is empty', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
const supabase = makeSupabase({ exactHit: null, latestHit: null })
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
expect(result).toBeNull()
})
})
})
describe('fetchMultipleRates', () => {
+140 -41
View File
@@ -1,3 +1,4 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import type { Currency, ExchangeRate } from '@/types'
@@ -13,13 +14,107 @@ const SERIES_IDS: Record<Currency, string> = {
DKK: 'SEKDKKPMI',
}
const RIKSBANKEN_HEADERS = { Accept: 'application/json' }
/**
* Fetch exchange rates from Riksbanken API
* Uses their public API for daily exchange rates
* One retry on 429/5xx, honoring Retry-After (capped at 5s). Riksbanken
* rate-limits the public API; without this the morning sync cron's fan-out
* turned a single 429 into a failed rate for the whole batch.
*/
async function fetchWithRetry(url: string): Promise<Response> {
const first = await fetch(url, { headers: RIKSBANKEN_HEADERS, next: { revalidate: 3600 } })
if (first.status !== 429 && first.status < 500) return first
const retryAfterSeconds = Number.parseInt(first.headers.get('retry-after') ?? '', 10)
const delayMs = Number.isFinite(retryAfterSeconds)
? Math.min(retryAfterSeconds * 1000, 5000)
: 800
await new Promise((resolve) => setTimeout(resolve, delayMs))
return fetch(url, { headers: RIKSBANKEN_HEADERS, next: { revalidate: 3600 } })
}
/** Cache reads/writes are best-effort — a cache failure must never block a rate. */
async function readCachedRate(
supabase: SupabaseClient,
currency: Currency,
rateDate: string,
): Promise<ExchangeRate | null> {
try {
const { data } = await supabase
.from('exchange_rates')
.select('rate, observation_date')
.eq('currency', currency)
.eq('rate_date', rateDate)
.maybeSingle()
if (!data) return null
return { currency, rate: Number(data.rate), date: data.observation_date as string }
} catch {
return null
}
}
async function writeCachedRate(
supabase: SupabaseClient,
currency: Currency,
rateDate: string,
rate: ExchangeRate,
): Promise<void> {
try {
await supabase.from('exchange_rates').upsert(
{
currency,
rate_date: rateDate,
rate: rate.rate,
observation_date: rate.date,
source: 'riksbanken',
},
{ onConflict: 'currency,rate_date', ignoreDuplicates: true },
)
} catch {
// best-effort
}
}
async function readLatestCachedRate(
supabase: SupabaseClient,
currency: Currency,
onOrBefore: string,
): Promise<ExchangeRate | null> {
try {
const { data } = await supabase
.from('exchange_rates')
.select('rate, observation_date')
.eq('currency', currency)
.lte('rate_date', onOrBefore)
.order('rate_date', { ascending: false })
.limit(1)
.maybeSingle()
if (!data) return null
return { currency, rate: Number(data.rate), date: data.observation_date as string }
} catch {
return null
}
}
/**
* Fetch the exchange rate for a currency on a given date from Riksbanken's
* public API, with an optional persistent read-through cache.
*
* Pass `supabase` from server contexts (the sync cron's ingest path does) to
* read/write the shared exchange_rates cache — repeat dates then cost one DB
* lookup instead of an API call, across all companies.
*
* Failure semantics: when Riksbanken is unreachable/rate-limited even after
* one retry, the most recent CACHED observation on or before the date is
* returned. With no cache hit either, returns null — never a hardcoded
* number. (Hardcoded fallback rates used to get silently booked into
* amount_sek whenever the 05:00 cron got 429'd.) Callers treat null as "no
* rate": the transaction is inserted without amount_sek/exchange_rate and
* can be repaired via /api/transactions/[id]/refresh-exchange-rate.
*/
export async function fetchExchangeRate(
currency: Currency,
date?: Date
date?: Date,
supabase?: SupabaseClient,
): Promise<ExchangeRate | null> {
if (currency === 'SEK') {
return {
@@ -38,33 +133,42 @@ export async function fetchExchangeRate(
return null
}
if (supabase) {
const cached = await readCachedRate(supabase, currency, formattedDate)
if (cached) return cached
}
try {
// Riksbanken's public API endpoint
const url = `https://api.riksbank.se/swea/v1/Observations/${seriesId}/${formattedDate}/${formattedDate}`
const response = await fetchWithRetry(url)
const response = await fetch(url, {
headers: {
Accept: 'application/json',
},
next: { revalidate: 3600 }, // Cache for 1 hour
})
let result: ExchangeRate | null = null
// 204 = no data for this date (e.g. rate not published yet today)
// Also handle non-ok responses by falling back to a recent date range
if (!response.ok || response.status === 204) {
// Fetch the last 7 days to find the most recent available rate
if (response.status === 429 || response.status >= 500) {
// Rate-limited/unavailable even after the retry. Do NOT fire the
// 7-day range request — it would hit the same limiter and double the
// load, which is how the old code turned one 429 into two.
throw new Error(`Failed to fetch exchange rate: ${response.status}`)
}
if (response.ok && response.status !== 204) {
const data = await response.json()
if (data && data.length > 0) {
result = { currency, rate: parseFloat(data[0].value), date: data[0].date }
}
}
// 204 / empty / 404: no observation for this exact date (weekend,
// holiday, rate not published yet) — look back 7 days for the latest.
if (!result) {
const to = formattedDate
const fromDate = new Date(targetDate)
fromDate.setDate(fromDate.getDate() - 7)
const from = fromDate.toISOString().split('T')[0]
const fallbackUrl = `https://api.riksbank.se/swea/v1/Observations/${seriesId}/${from}/${to}`
const fallbackResponse = await fetch(fallbackUrl, {
headers: {
Accept: 'application/json',
},
next: { revalidate: 3600 },
})
const fallbackResponse = await fetchWithRetry(fallbackUrl)
if (!fallbackResponse.ok || fallbackResponse.status === 204) {
throw new Error(`Failed to fetch exchange rate: ${fallbackResponse.status}`)
@@ -73,36 +177,31 @@ export async function fetchExchangeRate(
const fallbackData = await fallbackResponse.json()
if (fallbackData && fallbackData.length > 0) {
const latest = fallbackData[fallbackData.length - 1]
return {
currency,
rate: parseFloat(latest.value),
date: latest.date,
}
}
return null
}
const data = await response.json()
if (data && data.length > 0) {
return {
currency,
rate: parseFloat(data[0].value),
date: data[0].date,
result = { currency, rate: parseFloat(latest.value), date: latest.date }
}
}
return null
if (result && supabase) {
await writeCachedRate(supabase, currency, formattedDate, result)
}
return result
} catch (error) {
log.error('Error fetching exchange rate:', error)
// Return fallback rates for development/testing
return getFallbackRate(currency)
// warn, not error: with the cached fallback below this is a degraded-mode
// path, and the transaction stays repairable either way.
log.warn('Error fetching exchange rate:', error)
if (supabase) {
const cached = await readLatestCachedRate(supabase, currency, formattedDate)
if (cached) return cached
}
return null
}
}
/**
* Fallback exchange rates for when API is unavailable
* These should be updated periodically
* Fallback exchange rates for when the API is unavailable. Display-only:
* fetchMultipleRates uses these to keep its fully-populated-Map contract.
* The booking path (fetchExchangeRate) never returns them — a made-up rate
* silently booked into amount_sek is worse than no rate.
*/
function getFallbackRate(currency: Currency): ExchangeRate {
const fallbackRates: Record<Currency, number> = {
+11 -10
View File
@@ -172,11 +172,17 @@ export async function getCompanyCapabilities(
if (isPaywallBypassed()) return [...PAID_CAPABILITIES]
if (!isUuid(companyId)) return [] // fail-closed: never interpolate a non-UUID
const { data: company } = await supabase
.from('companies')
.select('team_id')
.eq('id', companyId)
.maybeSingle()
// The disabled-config subtraction only needs companyId, so it runs in
// parallel with the team lookup — this function sits on the dashboard
// layout's critical path, where each serialized round-trip is latency.
const [{ data: company }, { data: configs }] = await Promise.all([
supabase.from('companies').select('team_id').eq('id', companyId).maybeSingle(),
supabase
.from('company_capability_config')
.select('capability_key, enabled')
.eq('company_id', companyId)
.eq('enabled', false),
])
const rawTeamId = (company as { team_id: string | null } | null)?.team_id ?? null
const teamId = rawTeamId && isUuid(rawTeamId) ? rawTeamId : null
@@ -200,11 +206,6 @@ export async function getCompanyCapabilities(
if (entitled.size === 0) return []
// Subtract any explicitly-disabled (enablement axis).
const { data: configs } = await supabase
.from('company_capability_config')
.select('capability_key, enabled')
.eq('company_id', companyId)
.eq('enabled', false)
for (const c of configs ?? []) {
entitled.delete((c as { capability_key: string }).capability_key)
}
+7
View File
@@ -372,6 +372,13 @@ export function getErrorMessage(
: 'Räkenskapsperioden för det valda datumet är låst. Lås upp perioden för att flytta verifikationen dit.'
}
if (structured.code === 'OB_COMPANY_LOCK_DATE') {
const details = structured.details as { lockDate?: string } | undefined
return details?.lockDate
? `Bokföringen är låst t.o.m. ${details.lockDate} och ingående balanser kan inte korrigeras. Ta bort eller flytta låsdatumet under Inställningar → Bokföring och försök igen.`
: 'Bokföringen är låst av företagets låsdatum och ingående balanser kan inte korrigeras. Ta bort eller flytta låsdatumet under Inställningar → Bokföring och försök igen.'
}
if (structured.code === 'MEANINGLESS_CORRECTION') {
const details = structured.details as { reason?: string } | undefined
if (details?.reason === 'no_date_change') {
+29 -9
View File
@@ -247,6 +247,22 @@ interface ErrorResponseContext {
interface MinimalLogger {
error: (msg: string, ...args: unknown[]) => void
warn?: (msg: string, ...args: unknown[]) => void
}
/**
* 4xx outcomes are expected request failures (auth, validation, domain
* guards) — log them at warn so Vercel's runtime-error clustering surfaces
* only genuine 5xx. Falls back to error when the caller's logger has no warn.
*/
function logAtLevel(
log: MinimalLogger,
httpStatus: number,
msg: string,
...args: unknown[]
): void {
if (httpStatus < 500 && log.warn) log.warn(msg, ...args)
else log.error(msg, ...args)
}
function entryFor(code: string): StructuredErrorEntry {
@@ -317,8 +333,8 @@ export function errorResponse(
// the structured details each typed error class carries.
if (isBookkeepingError(err)) {
const { code, details } = extractBookkeepingDetails(err)
log.error(code, err as Error, { requestId: ctx.requestId })
const entry = entryFor(code)
logAtLevel(log, entry.httpStatus, code, err as Error, { requestId: ctx.requestId })
return buildResponse(code, entry, ctx.requestId, details ?? ctx.details)
}
@@ -329,7 +345,7 @@ export function errorResponse(
message: i.message,
code: i.code,
}))
log.error('validation failed', err as Error, {
logAtLevel(log, 400, 'validation failed', err as Error, {
requestId: ctx.requestId,
issueCount: issues.length,
})
@@ -341,23 +357,27 @@ export function errorResponse(
// 3. Postgres errors
if (isPostgresError(err)) {
const mapped = postgresCodeToStructured(err.code)
if (mapped) {
const entry = entryFor(mapped)
logAtLevel(log, entry.httpStatus, 'database error', err as unknown as Error, {
requestId: ctx.requestId,
pgCode: err.code,
})
const details = mergeDetails({ pgCode: err.code }, ctx.details)
return buildResponse(mapped, entry, ctx.requestId, details)
}
log.error('database error', err as unknown as Error, {
requestId: ctx.requestId,
pgCode: err.code,
})
if (mapped) {
const entry = entryFor(mapped)
const details = mergeDetails({ pgCode: err.code }, ctx.details)
return buildResponse(mapped, entry, ctx.requestId, details)
}
}
// 4. Errors with a known structured code on them
const code = extractCode(err)
if (code && getErrorEntry(code)) {
const entry = entryFor(code)
log.error(`${code}`, err instanceof Error ? err : new Error(String(err)), { requestId: ctx.requestId })
const status = ctx.status ?? entry.httpStatus
logAtLevel(log, status, `${code}`, err instanceof Error ? err : new Error(String(err)), { requestId: ctx.requestId })
return buildResponse(code, { ...entry, httpStatus: status }, ctx.requestId, ctx.details)
}
@@ -472,8 +492,8 @@ export function errorResponseFromCode(
ctx: ErrorResponseContext & { reason?: string } = {},
): NextResponse {
const entry = entryFor(code)
log.error(code, ctx.reason ?? entry.message_en, { requestId: ctx.requestId })
const status = ctx.status ?? entry.httpStatus
logAtLevel(log, status, code, ctx.reason ?? entry.message_en, { requestId: ctx.requestId })
return buildResponse(
code,
{
+21
View File
@@ -1308,6 +1308,17 @@ const OPENING_BALANCE_IMPORT: Record<string, StructuredErrorEntry> = {
message_sv: 'Räkenskapsperioden är låst.',
message_en: 'Fiscal period is locked.',
},
OB_COMPANY_LOCK_DATE: {
httpStatus: 409,
message_sv:
'Bokföringen är låst t.o.m. ett låsdatum som täcker periodens start — ingående balanser kan inte korrigeras. Ta bort eller flytta låsdatumet under Inställningar → Bokföring och försök igen.',
message_en:
'The company-wide bookkeeping lock date covers the period start — opening balances cannot be corrected. Remove or move the lock date under Settings → Bookkeeping and try again.',
remediation: {
description:
'Clear or move the bookkeeping lock date (company_settings.bookkeeping_locked_through) to a date before the period start, then retry the correction.',
},
},
OB_PERIOD_ALREADY_HAS_BALANCES: {
httpStatus: 409,
message_sv: 'Räkenskapsperioden har redan ingående balanser.',
@@ -1870,6 +1881,16 @@ const SALARY: Record<string, StructuredErrorEntry> = {
message_sv: 'Lönekörningen måste vara markerad som betald för bokföring.',
message_en: 'Salary run must be marked paid before booking.',
},
SALARY_PAYSLIPS_SEND_INVALID_STATUS: {
httpStatus: 400,
message_sv: 'Lönespecifikationer kan bara skickas efter godkännande.',
message_en: 'Payslips can only be sent after the salary run is approved.',
},
SALARY_PAYSLIPS_NO_EMPLOYEES: {
httpStatus: 400,
message_sv: 'Inga anställda i lönekörningen.',
message_en: 'No employees in the salary run.',
},
AGI_GENERATE_NOT_BOOKABLE: {
httpStatus: 400,
message_sv: 'AGI kan endast genereras för lönekörningar i status review, approved, paid, booked eller corrected.',
@@ -14,6 +14,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { executeSIEImport } from '../sie-import'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { findUntransferredResults } from '@/lib/reports/imbalance-diagnosis'
import type { ParsedSIEFile, AccountMapping } from '../types'
import type { SupabaseClient } from '@supabase/supabase-js'
@@ -22,6 +23,10 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
reverseEntry: vi.fn(),
}))
vi.mock('@/lib/reports/imbalance-diagnosis', () => ({
findUntransferredResults: vi.fn(async () => []),
}))
// --- Helpers ---
type QueuedResult = { data?: unknown; error?: unknown; count?: number | null }
@@ -276,3 +281,78 @@ describe('executeSIEImport: derived IB from #UB -1 (issue #675)', () => {
expect(result.openingBalanceEntryId).toBeNull()
})
})
// --- Untransferred prior-year results (post-import walk) ---
// Prod incident: a multi-year migration where one middle year's file lacked
// the omföring av årets resultat. Every later year's derived IB inherited
// the residual as a permanent balansräkning differens. The import must
// surface the culprit years — as warning strings for the wizard and as
// structured details for UIs that don't render warnings.
describe('executeSIEImport — untransferred prior-year results', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const culprit = {
fiscal_period_id: 'fp-0',
period_name: 'Räkenskapsår 2024/2025',
pl_net: 97,
}
it('surfaces culprit years as warnings and structured details', async () => {
vi.mocked(findUntransferredResults).mockResolvedValue([culprit])
const supabase = buildRoutingSupabase(standardQueues())
const result = await executeSIEImport(
supabase,
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
standardOptions,
)
expect(result.success).toBe(true)
expect(result.warnings.join(' ')).toMatch(
/Resultatet för Räkenskapsår 2024\/2025 .* har inte förts om till eget kapital/
)
expect(result.details?.untransferredResults).toEqual([culprit])
})
it('adds nothing when every prior year transferred its result', async () => {
vi.mocked(findUntransferredResults).mockResolvedValue([])
const supabase = buildRoutingSupabase(standardQueues())
const result = await executeSIEImport(
supabase,
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
standardOptions,
)
expect(result.success).toBe(true)
expect(result.warnings.join(' ')).not.toMatch(/förts om till eget kapital/)
expect(result.details?.untransferredResults).toBeUndefined()
})
it('a diagnosis failure never fails the import', async () => {
vi.mocked(findUntransferredResults).mockRejectedValue(new Error('boom'))
const supabase = buildRoutingSupabase(standardQueues())
const result = await executeSIEImport(
supabase,
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
standardOptions,
)
expect(result.success).toBe(true)
expect(result.errors).toEqual([])
expect(result.details?.untransferredResults).toBeUndefined()
})
})
+83
View File
@@ -535,6 +535,89 @@ describe('validateSIEFile', () => {
})
})
// --- validateSIEFile: missing omföring av årets resultat ---
// A completed fiscal year whose vouchers leave a residual on P&L accounts
// never moved its result to equity. Later imported years derive IB from
// balance-sheet accounts only, so the residual becomes a permanent
// balansräkning differens (prod incident: 97 kr across three years).
const OMFORING_HEADER = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Omföring AB"',
'#KONTO 1930 "Företagskonto"',
'#KONTO 3001 "Försäljning"',
'#KONTO 8999 "Årets resultat"',
'#KONTO 2099 "Årets resultat"',
]
const OMFORING_MISSING_VOUCHERS = [
'#VER A 1 20240401 "Försäljning"',
'{',
'#TRANS 1930 {} 97.00',
'#TRANS 3001 {} -97.00',
'}',
]
const OMFORING_PRESENT_VOUCHERS = [
...OMFORING_MISSING_VOUCHERS,
'#VER A 2 20250228 "Omföring av årets resultat"',
'{',
'#TRANS 8999 {} 97.00',
'#TRANS 2099 {} -97.00',
'}',
]
describe('validateSIEFile — untransferred result (omföring saknas)', () => {
it('warns when a completed year leaves a residual on P&L accounts', () => {
const content = [
...OMFORING_HEADER,
'#RAR 0 20240301 20250228',
...OMFORING_MISSING_VOUCHERS,
].join('\n')
const validation = validateSIEFile(parseSIEFile(content))
const warning = validation.warnings.find((w) => w.includes('omföring av årets resultat'))
expect(warning).toBeDefined()
expect(warning).toContain('97.00 kr')
// A missing omföring is a data-quality heads-up, never an import blocker.
expect(validation.valid).toBe(true)
})
it('does not warn when the file contains the result transfer', () => {
const content = [
...OMFORING_HEADER,
'#RAR 0 20240301 20250228',
...OMFORING_PRESENT_VOUCHERS,
].join('\n')
const validation = validateSIEFile(parseSIEFile(content))
expect(
validation.warnings.some((w) => w.includes('omföring av årets resultat'))
).toBe(false)
})
it('does not warn for a running fiscal year', () => {
const content = [
...OMFORING_HEADER,
// Fiscal year end far in the future — the running year legitimately
// carries its result on class 3-8 until bokslut.
'#RAR 0 20990101 20991231',
...OMFORING_MISSING_VOUCHERS.map((line) =>
line.replace('20240401', '20990401')
),
].join('\n')
const validation = validateSIEFile(parseSIEFile(content))
expect(
validation.warnings.some((w) => w.includes('omföring av årets resultat'))
).toBe(false)
})
})
// --- Fix 2: Windows-1252 encoding detection and decoding ---
describe('detectEncoding: #FORMAT PC8 detection', () => {
+24
View File
@@ -38,6 +38,8 @@ import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
import { populateTemplatesFromSieVouchers } from '@/lib/bookkeeping/counterparty-templates'
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
import { findUntransferredResults } from '@/lib/reports/imbalance-diagnosis'
import { formatCurrency } from '@/lib/utils'
/**
* Format a date to ISO date string (YYYY-MM-DD)
@@ -2604,6 +2606,28 @@ export async function executeSIEImport(
failedBatches: voucherRetryStats.failedBatches,
}
// Untransferred prior-year results — the root cause of "balansräkningen
// balanserar inte" after multi-year migrations. Any non-latest fiscal
// year whose P&L doesn't net to zero (its omföring av årets resultat is
// missing) corrupts every later derived opening balance by exactly that
// residual. Checked against the DB (not the file) so it also catches
// gaps introduced across separate per-year imports. Non-fatal: a
// diagnosis failure never fails the import.
try {
const untransferred = await findUntransferredResults(supabase, companyId)
if (untransferred.length > 0 && result.details) {
result.details.untransferredResults = untransferred
for (const culprit of untransferred) {
result.warnings.push(
`Resultatet för ${culprit.period_name} (${formatCurrency(culprit.pl_net, 'SEK', { minimumFractionDigits: 2 })}) har inte förts om till eget kapital — ` +
'senare års balansräkning visar en differens tills omföringen bokförs i det året.'
)
}
}
} catch (diagnosisError) {
console.error('[sie-import] untransferred-results check failed (non-fatal):', diagnosisError)
}
// Set success before finalizing
result.success = result.errors.length === 0
+26
View File
@@ -1103,6 +1103,32 @@ export function validateSIEFile(parsed: ParsedSIEFile): ValidationResult {
warnings.push(`Ingående balanser balanserar inte (differens: ${ibTotal.toFixed(2)} kr). En automatisk justeringspost mot konto 2099 skapas vid import.`)
}
// Completed fiscal year whose vouchers leave a residual on P&L accounts:
// the year's result was never transferred to equity (omföring saknas).
// Later years derive their opening balance from balance-sheet accounts
// only, so the residual becomes a permanent balansräkning differens for
// every subsequent year. SIE amounts are debit-positive, so the class 3-8
// sum is the un-transferred result with flipped sign.
const currentFiscalYear = parsed.header.fiscalYears.find((fy) => fy.yearIndex === 0)
if (currentFiscalYear?.end && currentFiscalYear.end < formatLocalDate(new Date())) {
const plResidual = parsed.vouchers.reduce(
(sum, voucher) =>
sum +
voucher.lines.reduce(
(lineSum, line) =>
lineSum + (isBalanceSheetAccount(line.account) ? 0 : line.amount),
0
),
0
)
if (Math.abs(plResidual) > 0.01) {
warnings.push(
`Räkenskapsåret är avslutat men filen saknar omföring av årets resultat (${Math.abs(plResidual).toFixed(2)} kr ligger kvar på resultatkonton). ` +
`Om senare räkenskapsår importeras kommer balansräkningen att visa en differens på ${Math.abs(plResidual).toFixed(2)} kr tills omföringen bokförs.`
)
}
}
// Add parse issues as errors/warnings
for (const issue of parsed.issues) {
if (issue.severity === 'error') {
+14
View File
@@ -299,6 +299,20 @@ export interface ImportResultDetails {
accountsAdjusted: number
}
/**
* Non-latest fiscal years whose P&L doesn't net to zero — their result
* was never transferred to equity (omföring av årets resultat saknas).
* Each corrupts every later derived opening balance by exactly pl_net,
* which surfaces as a balansräkning differens. Structurally identical to
* UntransferredResult in @/types.
*/
untransferredResults?: Array<{
fiscal_period_id: string
period_name: string
/** Class 3-8 net (credit-positive = profit), rounded to öre. */
pl_net: number
}>
/** Number of batches that needed retries (0 = clean run) */
retriedBatches: number
@@ -4,11 +4,20 @@ vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
vi.mock('../imbalance-diagnosis', () => ({
findUntransferredResults: vi.fn(),
buildImbalanceDiagnosis: vi.fn(),
}))
import { generateBalanceSheet } from '../balance-sheet'
import { generateTrialBalance } from '../trial-balance'
import { findUntransferredResults, buildImbalanceDiagnosis } from '../imbalance-diagnosis'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
const mockFindUntransferred = vi.mocked(findUntransferredResults)
const mockBuildDiagnosis = vi.mocked(buildImbalanceDiagnosis)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = {} as any
@@ -215,6 +224,81 @@ describe('generateBalanceSheet', () => {
expect(bankSection.rows.map(r => r.account_number)).toEqual(['1930', '1950'])
})
it('attaches imbalance_diagnosis when the report does not balance', async () => {
const q = createQueuedMockSupabase()
// Period fetch inside the diagnosis path
q.enqueue({ data: { period_start: '2025-03-01' } })
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_class: 1, closing_debit: 1097 }),
makeRow({ account_number: '2440', account_class: 2, closing_credit: 1000 }),
],
totalDebit: 1097,
totalCredit: 1000,
isBalanced: false,
})
const culprit = {
fiscal_period_id: 'p2',
period_name: 'Räkenskapsår 2024/2025',
pl_net: 97,
}
const diagnosis = {
differens: 97,
untransferred_results: [culprit],
message: 'Differensen beror på att resultatet för Räkenskapsår 2024/2025 …',
}
mockFindUntransferred.mockResolvedValue([culprit])
mockBuildDiagnosis.mockReturnValue(diagnosis)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateBalanceSheet(q.supabase as any, 'company-1', 'period-3')
expect(report.total_assets - report.total_equity_liabilities).toBe(97)
expect(mockFindUntransferred).toHaveBeenCalledWith(q.supabase, 'company-1', {
beforePeriodStart: '2025-03-01',
})
expect(mockBuildDiagnosis).toHaveBeenCalledWith([culprit], 97)
expect(report.imbalance_diagnosis).toEqual(diagnosis)
})
it('omits imbalance_diagnosis when the report balances', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_class: 1, closing_debit: 1000 }),
makeRow({ account_number: '2440', account_class: 2, closing_credit: 1000 }),
],
totalDebit: 1000,
totalCredit: 1000,
isBalanced: true,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.imbalance_diagnosis).toBeUndefined()
expect(mockFindUntransferred).not.toHaveBeenCalled()
})
it('still returns the report when the diagnosis lookup fails', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: { period_start: '2025-03-01' } })
mockTrialBalance.mockResolvedValue({
rows: [makeRow({ account_number: '1930', account_class: 1, closing_debit: 500 })],
totalDebit: 500,
totalCredit: 0,
isBalanced: false,
})
mockFindUntransferred.mockRejectedValue(new Error('boom'))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateBalanceSheet(q.supabase as any, 'company-1', 'period-1')
expect(report.total_assets).toBe(500)
expect(report.imbalance_diagnosis).toBeUndefined()
})
it('uses Math.round for monetary precision on subtotals', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
@@ -4,12 +4,20 @@ vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
vi.mock('../imbalance-diagnosis', () => ({
findUntransferredResults: vi.fn(),
buildImbalanceDiagnosis: vi.fn(),
}))
import { generateBalansrapport } from '../balansrapport'
import { generateTrialBalance } from '../trial-balance'
import { findUntransferredResults, buildImbalanceDiagnosis } from '../imbalance-diagnosis'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
const mockFindUntransferred = vi.mocked(findUntransferredResults)
const mockBuildDiagnosis = vi.mocked(buildImbalanceDiagnosis)
beforeEach(() => {
vi.clearAllMocks()
@@ -235,6 +243,67 @@ describe('generateBalansrapport', () => {
expect(report.is_balanced).toBe(false)
})
it('attaches imbalance_diagnosis when the trial balance does not balance', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2025-03-01', period_end: '2026-02-28' },
error: null,
})
mockTrialBalance.mockResolvedValueOnce({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 1097 }),
makeRow({ account_number: '2440', account_name: 'Lev.skuld', account_class: 2, closing_credit: 1000 }),
],
totalDebit: 1097,
totalCredit: 1000,
isBalanced: false,
})
const culprit = {
fiscal_period_id: 'p2',
period_name: 'Räkenskapsår 2024/2025',
pl_net: 97,
}
const diagnosis = {
differens: 97,
untransferred_results: [culprit],
message: 'Differensen beror på att resultatet för Räkenskapsår 2024/2025 …',
}
mockFindUntransferred.mockResolvedValue([culprit])
mockBuildDiagnosis.mockReturnValue(diagnosis)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-3')
expect(mockFindUntransferred).toHaveBeenCalledWith(q.supabase, 'company-1', {
beforePeriodStart: '2025-03-01',
})
expect(mockBuildDiagnosis).toHaveBeenCalledWith([culprit], 97)
expect(report.imbalance_diagnosis).toEqual(diagnosis)
})
it('omits imbalance_diagnosis when the trial balance balances', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31' },
error: null,
})
mockTrialBalance.mockResolvedValueOnce(
tb([
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 1000 }),
makeRow({ account_number: '2440', account_name: 'Lev.skuld', account_class: 2, closing_credit: 1000 }),
])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-1')
expect(report.imbalance_diagnosis).toBeUndefined()
expect(mockFindUntransferred).not.toHaveBeenCalled()
})
it('ignores P&L accounts (class 3-8)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
import { findUntransferredResults, buildImbalanceDiagnosis } from '../imbalance-diagnosis'
import { generateTrialBalance } from '../trial-balance'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { TrialBalanceRow, UntransferredResult } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
beforeEach(() => {
vi.clearAllMocks()
})
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
return {
account_number: '1930',
account_name: 'Bank',
account_class: 1,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 0,
closing_debit: 0,
closing_credit: 0,
...overrides,
}
}
function tb(rows: TrialBalanceRow[]) {
return { rows, totalDebit: 0, totalCredit: 0, isBalanced: true }
}
const PERIODS = [
{ id: 'p1', name: 'Räkenskapsår 2023/2024', period_start: '2023-03-01' },
{ id: 'p2', name: 'Räkenskapsår 2024/2025', period_start: '2024-03-01' },
{ id: 'p3', name: 'Räkenskapsår 2025/2026', period_start: '2025-03-01' },
]
describe('findUntransferredResults', () => {
it('flags a non-latest year whose P&L does not net to zero', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: PERIODS })
// p1: P&L nets to zero (result was transferred)
mockTrialBalance.mockResolvedValueOnce(
tb([
makeRow({ account_number: '3001', account_class: 3, closing_credit: 1000 }),
makeRow({ account_number: '8999', account_class: 8, closing_debit: 1000 }),
])
)
// p2: 97 kr profit left on P&L accounts (omföring missing)
mockTrialBalance.mockResolvedValueOnce(
tb([
makeRow({ account_number: '3001', account_class: 3, closing_credit: 197 }),
makeRow({ account_number: '5010', account_class: 5, closing_debit: 100 }),
])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await findUntransferredResults(q.supabase as any, 'company-1')
expect(mockTrialBalance).toHaveBeenCalledTimes(2)
expect(result).toEqual([
{ fiscal_period_id: 'p2', period_name: 'Räkenskapsår 2024/2025', pl_net: 97 },
])
})
it('never flags the chronologically last period (running year)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: PERIODS })
// Both candidates (p1, p2) net to zero; p3 is never checked even though
// a running year always carries its result on class 3-8.
mockTrialBalance.mockResolvedValue(tb([]))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await findUntransferredResults(q.supabase as any, 'company-1')
expect(result).toEqual([])
expect(mockTrialBalance).toHaveBeenCalledTimes(2)
const checkedPeriodIds = mockTrialBalance.mock.calls.map((c) => c[2])
expect(checkedPeriodIds).toEqual(['p1', 'p2'])
})
it('respects the beforePeriodStart cutoff', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: PERIODS })
mockTrialBalance.mockResolvedValue(tb([]))
// Diagnosing p2 → only p1 can affect its opening balance.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await findUntransferredResults(q.supabase as any, 'company-1', {
beforePeriodStart: '2024-03-01',
})
expect(mockTrialBalance).toHaveBeenCalledTimes(1)
expect(mockTrialBalance.mock.calls[0][2]).toBe('p1')
})
it('ignores residuals below the öre tolerance', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: [PERIODS[0], PERIODS[1]] })
mockTrialBalance.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 0.004 })])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await findUntransferredResults(q.supabase as any, 'company-1')
expect(result).toEqual([])
})
it('returns empty without checking anything when fewer than two periods exist', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: [PERIODS[0]] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await findUntransferredResults(q.supabase as any, 'company-1')
expect(result).toEqual([])
expect(mockTrialBalance).not.toHaveBeenCalled()
})
it('only counts class 3-8 rows toward the residual', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: [PERIODS[0], PERIODS[1]] })
mockTrialBalance.mockResolvedValueOnce(
tb([
makeRow({ account_number: '1930', account_class: 1, closing_debit: 5000 }),
makeRow({ account_number: '2440', account_class: 2, closing_credit: 5000 }),
makeRow({ account_number: '3001', account_class: 3, closing_credit: 97 }),
])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await findUntransferredResults(q.supabase as any, 'company-1')
expect(result).toHaveLength(1)
expect(result[0].pl_net).toBe(97)
})
})
describe('buildImbalanceDiagnosis', () => {
const culprit: UntransferredResult = {
fiscal_period_id: 'p2',
period_name: 'Räkenskapsår 2024/2025',
pl_net: 97,
}
it('returns null when the differens is below one öre', () => {
expect(buildImbalanceDiagnosis([culprit], 0.004)).toBeNull()
expect(buildImbalanceDiagnosis([], 0)).toBeNull()
})
it('names the culprit year and amount in the single-year message', () => {
const diagnosis = buildImbalanceDiagnosis([culprit], 97)
expect(diagnosis).not.toBeNull()
expect(diagnosis!.differens).toBe(97)
expect(diagnosis!.untransferred_results).toEqual([culprit])
expect(diagnosis!.message).toContain('Räkenskapsår 2024/2025')
expect(diagnosis!.message).toContain('97,00')
expect(diagnosis!.message).toContain('förts om till eget kapital')
expect(diagnosis!.message).toContain('8999')
})
it('lists every culprit year in the multi-year message', () => {
const other: UntransferredResult = {
fiscal_period_id: 'p1',
period_name: 'Räkenskapsår 2023/2024',
pl_net: -50,
}
const diagnosis = buildImbalanceDiagnosis([other, culprit], 47)
expect(diagnosis!.message).toContain('Räkenskapsår 2023/2024')
expect(diagnosis!.message).toContain('Räkenskapsår 2024/2025')
expect(diagnosis!.message).toContain('respektive år')
})
it('falls back to a generic message when no culprit was found', () => {
const diagnosis = buildImbalanceDiagnosis([], 97)
expect(diagnosis).not.toBeNull()
expect(diagnosis!.untransferred_results).toEqual([])
expect(diagnosis!.message).toContain('Ingen orsak kunde fastställas automatiskt')
})
})
+37 -5
View File
@@ -1,6 +1,12 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from './trial-balance'
import type { BalanceSheetReport, BalanceSheetSection, TrialBalanceRow } from '@/types'
import { findUntransferredResults, buildImbalanceDiagnosis } from './imbalance-diagnosis'
import type {
BalanceImbalanceDiagnosis,
BalanceSheetReport,
BalanceSheetSection,
TrialBalanceRow,
} from '@/types'
/**
* Generate Balance Sheet (Balansräkning)
@@ -89,15 +95,41 @@ export async function generateBalanceSheet(
})
}
const totalAssets = assetSections.reduce((sum, s) => sum + s.subtotal, 0)
const totalEquityLiabilities = equityLiabilitySections.reduce((sum, s) => sum + s.subtotal, 0)
const totalAssets =
Math.round(assetSections.reduce((sum, s) => sum + s.subtotal, 0) * 100) / 100
const totalEquityLiabilities =
Math.round(equityLiabilitySections.reduce((sum, s) => sum + s.subtotal, 0) * 100) / 100
// Explain a broken balance instead of leaving a bare differens. The usual
// cause after multi-year migrations is a prior year whose result was never
// transferred to equity (see imbalance-diagnosis.ts). Only runs on the
// unbalanced path and must never break the report itself.
let imbalanceDiagnosis: BalanceImbalanceDiagnosis | undefined
const differens = Math.round((totalAssets - totalEquityLiabilities) * 100) / 100
if (Math.abs(differens) >= 0.01) {
try {
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
const untransferred = await findUntransferredResults(supabase, companyId, {
beforePeriodStart: period?.period_start,
})
imbalanceDiagnosis = buildImbalanceDiagnosis(untransferred, differens) ?? undefined
} catch {
// Best-effort diagnosis only — the report still renders without it.
}
}
return {
asset_sections: assetSections.filter((s) => s.rows.length > 0),
total_assets: Math.round(totalAssets * 100) / 100,
total_assets: totalAssets,
equity_liability_sections: equityLiabilitySections.filter((s) => s.rows.length > 0),
total_equity_liabilities: Math.round(totalEquityLiabilities * 100) / 100,
total_equity_liabilities: totalEquityLiabilities,
period: { start: '', end: '' },
...(imbalanceDiagnosis ? { imbalance_diagnosis: imbalanceDiagnosis } : {}),
}
}
+20
View File
@@ -1,6 +1,8 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from './trial-balance'
import { findUntransferredResults, buildImbalanceDiagnosis } from './imbalance-diagnosis'
import type {
BalanceImbalanceDiagnosis,
BalansrapportReport,
BalansrapportRow,
BalansrapportGroup,
@@ -96,6 +98,23 @@ export async function generateBalansrapport(
// After year-end close posts the result into 2099, the residual is 0.
const beraknatResultat = round2(totalAssetsUb + totalEquityLiabilitiesUb)
// An unbalanced trial balance here means the opening balance itself is
// broken — double-entry guarantees period activity balances. Explain why
// (usually a prior year's untransferred result) instead of leaving a bare
// "Balanserar ej". Best-effort: a diagnosis failure never breaks the report.
let imbalanceDiagnosis: BalanceImbalanceDiagnosis | undefined
if (!trialBalance.isBalanced) {
const differens = round2(trialBalance.totalDebit - trialBalance.totalCredit)
try {
const untransferred = await findUntransferredResults(supabase, companyId, {
beforePeriodStart: period.period_start,
})
imbalanceDiagnosis = buildImbalanceDiagnosis(untransferred, differens) ?? undefined
} catch {
// Best-effort diagnosis only.
}
}
return {
groups,
total_assets_ub: totalAssetsUb,
@@ -103,6 +122,7 @@ export async function generateBalansrapport(
beraknat_resultat: beraknatResultat,
is_balanced: trialBalance.isBalanced,
period: { start: effectiveFromDate, end: effectiveToDate },
...(imbalanceDiagnosis ? { imbalance_diagnosis: imbalanceDiagnosis } : {}),
}
}
+117
View File
@@ -0,0 +1,117 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from './trial-balance'
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
import { formatCurrency } from '@/lib/utils'
import type { BalanceImbalanceDiagnosis, UntransferredResult } from '@/types'
/**
* Find fiscal years whose result was never transferred to equity.
*
* A period is a culprit when a chronologically later period exists AND its
* P&L accounts (class 3-8) do not net to zero. The running (latest) year
* legitimately carries its result on class 3-8 until bokslut, so it is
* never flagged.
*
* Why this signal and not validateBalanceContinuity(): when a period has no
* opening_balance entry, its IB is derived by summing all prior class 1-2
* lines (compute_prior_opening_balances). Prior-period UB and derived IB
* then match per-account BY CONSTRUCTION — the continuity check passes even
* though the balance sheet is broken. The invariant that actually breaks is
* "every non-latest year's P&L nets to zero": an untransferred result makes
* every later derived IB unbalanced by exactly that residual.
*
* Cost: one trial balance per candidate period — callers only invoke this
* when a report is already unbalanced or right after an SIE import.
*/
export async function findUntransferredResults(
supabase: SupabaseClient,
companyId: string,
options?: { beforePeriodStart?: string }
): Promise<UntransferredResult[]> {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id, name, period_start')
.eq('company_id', companyId)
.order('period_start', { ascending: true })
if (!Array.isArray(periods) || periods.length < 2) {
return []
}
// Every period except the chronologically last is a candidate; an
// explicit beforePeriodStart narrows to periods that can actually affect
// the caller's report (later years never leak backwards into earlier IB).
let candidates = periods.slice(0, -1) as Array<{
id: string
name: string
period_start: string
}>
if (options?.beforePeriodStart) {
const cutoff = options.beforePeriodStart
candidates = candidates.filter((p) => p.period_start < cutoff)
}
const culprits: UntransferredResult[] = []
for (const period of candidates) {
const { rows } = await generateTrialBalance(supabase, companyId, period.id)
const plNet = roundOre(
rows
.filter((r) => r.account_class >= 3 && r.account_class <= 8)
.reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0)
)
if (Math.abs(plNet) >= ORE_TOLERANCE) {
culprits.push({
fiscal_period_id: period.id,
period_name: period.name,
pl_net: plNet,
})
}
}
return culprits
}
function sek(amount: number): string {
return formatCurrency(amount, 'SEK', { minimumFractionDigits: 2 })
}
/**
* Build the user-facing explanation for an unbalanced balance report.
* Returns null when the differens is below one öre (nothing to explain).
* The message is Swedish — user-facing domain messages are Swedish.
*/
export function buildImbalanceDiagnosis(
untransferred: UntransferredResult[],
differens: number
): BalanceImbalanceDiagnosis | null {
const rounded = roundOre(differens)
if (Math.abs(rounded) < 0.01) {
return null
}
if (untransferred.length === 0) {
return {
differens: rounded,
untransferred_results: [],
message:
`Balansrapporten balanserar inte (differens ${sek(Math.abs(rounded))}). ` +
'Ingen orsak kunde fastställas automatiskt — kontrollera tidigare års bokslut och ingående balanser.',
}
}
const yearList = untransferred
.map((u) => `${u.period_name} (${sek(u.pl_net)})`)
.join(', ')
const message =
untransferred.length === 1
? `Differensen beror på att resultatet för ${yearList} aldrig har förts om till eget kapital. ` +
'Bokför omföring av årets resultat (konto 8999 mot eget kapital, t.ex. 2099) i det året.'
: `Differensen beror på att resultatet för följande räkenskapsår aldrig har förts om till eget kapital: ${yearList}. ` +
'Bokför omföring av årets resultat (konto 8999 mot eget kapital, t.ex. 2099) i respektive år.'
return {
differens: rounded,
untransferred_results: untransferred,
message,
}
}
@@ -456,6 +456,11 @@ export function BalansrapportPDF({ report, company, generatedAt }: Balansrapport
{report.is_balanced ? 'Balanserar' : 'Balanserar ej'}
</Text>
</View>
{!report.is_balanced && report.imbalance_diagnosis && (
<Text style={{ fontSize: 8, color: '#666', marginTop: 4 }}>
{report.imbalance_diagnosis.message}
</Text>
)}
</View>
<FooterBlock company={company} generatedAt={generatedAt} />
+143
View File
@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createSalaryRunWithEmployees } from '../create-run'
vi.mock('../account-mapping', () => ({
getLineItemAccount: vi.fn(() => '7210'),
}))
/**
* Purpose-built mock: records inserts per table, supports the chains
* create-run uses (insert().select().single(), select().eq().eq(),
* delete().eq().eq()).
*/
function mockDb(opts: {
employees?: Array<Record<string, unknown>>
failLineItems?: boolean
}) {
const inserts: Record<string, Array<Record<string, unknown>>> = {}
const deletes: string[] = []
const client = {
from: (table: string) => ({
insert: (row: Record<string, unknown>) => {
inserts[table] = inserts[table] || []
inserts[table].push(row)
if (table === 'salary_line_items') {
const result = opts.failLineItems
? { error: { message: 'line item boom' } }
: { error: null }
return Promise.resolve(result)
}
return {
select: () => ({
single: async () => ({
data: { id: `${table}-${inserts[table].length}`, ...row },
error: null,
}),
}),
}
},
select: () => ({
eq: () => ({
eq: async () => ({ data: opts.employees ?? [], error: null }),
}),
}),
delete: () => ({
eq: () => ({
eq: async () => {
deletes.push(table)
return { data: null, error: null }
},
}),
}),
}),
}
return { client: client as unknown as SupabaseClient, inserts, deletes }
}
const monthlyEmployee = {
id: 'emp-1',
salary_type: 'monthly',
monthly_salary: 35000,
employment_degree: 100,
employment_type: 'employee',
employment_start: '2025-01-01',
employment_end: null,
tax_table_number: 33,
tax_column: 1,
}
describe('createSalaryRunWithEmployees', () => {
beforeEach(() => vi.clearAllMocks())
it('passes voucher_series and notes through to the run insert', async () => {
const { client, inserts } = mockDb({ employees: [monthlyEmployee] })
await createSalaryRunWithEmployees(client, 'company-1', 'user-1', {
periodYear: 2026,
periodMonth: 6,
paymentDate: '2026-06-25',
voucherSeries: 'L',
notes: 'Juni',
})
expect(inserts.salary_runs[0]).toMatchObject({
voucher_series: 'L',
notes: 'Juni',
period_year: 2026,
period_month: 6,
})
})
it('omits voucher_series/notes when not provided (DB defaults apply — MCP path unchanged)', async () => {
const { client, inserts } = mockDb({ employees: [monthlyEmployee] })
await createSalaryRunWithEmployees(client, 'company-1', 'user-1', {
periodYear: 2026,
periodMonth: 6,
paymentDate: '2026-06-25',
})
expect(inserts.salary_runs[0]).not.toHaveProperty('voucher_series')
expect(inserts.salary_runs[0]).not.toHaveProperty('notes')
})
it('filters employees whose employment does not overlap the period', async () => {
const { client, inserts } = mockDb({
employees: [
monthlyEmployee,
{ ...monthlyEmployee, id: 'emp-future', employment_start: '2026-08-01' },
{ ...monthlyEmployee, id: 'emp-ended', employment_end: '2026-05-31' },
],
})
const result = await createSalaryRunWithEmployees(client, 'company-1', 'user-1', {
periodYear: 2026,
periodMonth: 6,
paymentDate: '2026-06-25',
})
expect(result.employeeCount).toBe(1)
expect(inserts.salary_run_employees).toHaveLength(1)
expect(inserts.salary_run_employees[0].employee_id).toBe('emp-1')
})
it('compensating-deletes the run when a child insert fails', async () => {
const { client, deletes } = mockDb({
employees: [monthlyEmployee],
failLineItems: true,
})
await expect(
createSalaryRunWithEmployees(client, 'company-1', 'user-1', {
periodYear: 2026,
periodMonth: 6,
paymentDate: '2026-06-25',
}),
).rejects.toThrow('line item boom')
expect(deletes).toContain('salary_runs')
})
})
+10 -1
View File
@@ -21,7 +21,13 @@ export async function createSalaryRunWithEmployees(
supabase: SupabaseClient,
companyId: string,
userId: string,
params: { periodYear: number; periodMonth: number; paymentDate: string },
params: {
periodYear: number
periodMonth: number
paymentDate: string
voucherSeries?: string
notes?: string
},
): Promise<CreateSalaryRunResult> {
const { data: run, error: runError } = await supabase
.from('salary_runs')
@@ -31,6 +37,9 @@ export async function createSalaryRunWithEmployees(
period_year: params.periodYear,
period_month: params.periodMonth,
payment_date: params.paymentDate,
// Omitted → DB defaults ('A' / NULL) so the MCP commit path is unchanged.
...(params.voucherSeries ? { voucher_series: params.voucherSeries } : {}),
...(params.notes ? { notes: params.notes } : {}),
})
.select()
.single()
@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from 'vitest'
vi.mock('@/lib/salary/personnummer', () => ({
decryptPersonnummer: vi.fn((v: string) => v),
maskPersonnummer: vi.fn(() => '19900101-****'),
}))
import { buildPayslipData, payslipFileName } from '../build-payslip-data'
const run = { period_year: 2026, period_month: 6, payment_date: '2026-06-25' }
const employee = {
first_name: 'Anna',
last_name: 'Exempelsson',
personnummer: 'enc',
employment_type: 'employee',
tax_table_number: 33,
tax_column: 1,
clearing_number: '8327',
bank_account_number: '9876543',
}
function sre(overrides: Record<string, unknown> = {}) {
return {
gross_salary: 35000,
tax_withheld: 8000,
tax_withheld_override: null,
avgifter_rate: 0.3142,
avgifter_amount: 10997,
avgifter_amount_override: null,
avgifter_basis_override: null,
override_reason: null,
net_salary: 27000,
vacation_accrual: 4200,
vacation_accrual_avgifter: 1319.74,
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
calculation_breakdown: { steps: [{ label: 'Bruttolön', formula: '35000', output: 35000 }] },
line_items: [
{ description: 'Grundlön', amount: 35000, sort_order: 0 },
],
...overrides,
}
}
describe('buildPayslipData', () => {
it('assembles the payslip without overrides', () => {
const data = buildPayslipData({ run, sre: sre(), employee, company: { name: 'Bolaget AB', org_number: '5560000000' } })
expect(data.grossSalary).toBe(35000)
expect(data.taxWithheld).toBe(8000)
expect(data.netSalary).toBe(27000)
expect(data.taxReference).toBe('Tabell 33, kol 1')
expect(data.employmentType).toBe('Anställd')
expect(data.bankAccount).toBe('8327-****6543')
expect(data.totalEmployerCost).toBe(35000 + 10997 + 4200 + 1319.74)
expect(data.breakdownSteps).toHaveLength(1)
expect(data.personnummerMasked).toBe('19900101-****')
})
it('coalesces tax/avgifter overrides and adjusts net accordingly', () => {
const data = buildPayslipData({
run,
sre: sre({
tax_withheld_override: 7000,
avgifter_amount_override: 9000,
override_reason: 'jämkning',
}),
employee,
company: { name: 'Bolaget AB', org_number: null },
})
// 1000 kr less tax withheld → 1000 kr more net
expect(data.taxWithheld).toBe(7000)
expect(data.netSalary).toBe(28000)
expect(data.avgifterAmount).toBe(9000)
expect(data.totalEmployerCost).toBe(35000 + 9000 + 4200 + 1319.74)
// Engine steps stay, override rows appended with the reason
const labels = (data.breakdownSteps ?? []).map(s => s.label)
expect(labels).toContain('Manuell justering: Skatteavdrag')
expect(labels).toContain('Manuell justering: Arbetsgivaravgifter')
const overrideRow = (data.breakdownSteps ?? []).find(
s => s.label === 'Manuell justering: Skatteavdrag',
)
expect(overrideRow?.formula).toBe('jämkning')
})
it('falls back to schablon tax reference and omits bank account when data missing', () => {
const data = buildPayslipData({
run,
sre: sre(),
employee: {
...employee,
tax_table_number: null,
clearing_number: null,
bank_account_number: null,
},
company: { name: 'Bolaget AB', org_number: null },
})
expect(data.taxReference).toBe('Schablon 30%')
expect(data.bankAccount).toBeUndefined()
expect(data.companyOrgNumber).toBe('')
})
it('sorts line items by sort_order', () => {
const data = buildPayslipData({
run,
sre: sre({
line_items: [
{ description: 'Förmån', amount: 500, sort_order: 2 },
{ description: 'Grundlön', amount: 35000, sort_order: 0 },
],
}),
employee,
company: { name: 'Bolaget AB', org_number: null },
})
expect(data.lineItems.map(li => li.description)).toEqual(['Grundlön', 'Förmån'])
})
})
describe('payslipFileName', () => {
it('builds the period-stamped filename', () => {
expect(payslipFileName(run, employee)).toBe('lonespec_Exempelsson_Anna_2026-06.pdf')
})
})
+190
View File
@@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
generatePayslipToken,
hashPayslipToken,
isValidPayslipTokenFormat,
resolvePayslipToken,
rotateLinkForEmployee,
revokeLinksForRun,
} from '../links'
describe('payslip token primitives', () => {
it('generates a 43-char base64url token whose hash matches hashPayslipToken', () => {
const { token, hash } = generatePayslipToken()
expect(isValidPayslipTokenFormat(token)).toBe(true)
expect(token).toHaveLength(43)
expect(hash).toBe(hashPayslipToken(token))
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('generates unique tokens', () => {
const a = generatePayslipToken()
const b = generatePayslipToken()
expect(a.token).not.toBe(b.token)
expect(a.hash).not.toBe(b.hash)
})
it('rejects malformed token formats', () => {
expect(isValidPayslipTokenFormat('')).toBe(false)
expect(isValidPayslipTokenFormat('short')).toBe(false)
expect(isValidPayslipTokenFormat('a'.repeat(44))).toBe(false)
expect(isValidPayslipTokenFormat('!'.repeat(43))).toBe(false)
// Path traversal / injection shapes never reach the DB
expect(isValidPayslipTokenFormat('../'.repeat(14) + 'x')).toBe(false)
})
})
// Minimal purpose-built client mocks — the chains used by links.ts are
// narrow enough that hand-rolling beats the generic queued mock here.
function resolveClient(row: unknown) {
const updates: Array<Record<string, unknown>> = []
const client = {
from: () => ({
select: () => ({
eq: () => ({ maybeSingle: async () => ({ data: row }) }),
}),
update: (u: Record<string, unknown>) => {
updates.push(u)
return { eq: async () => ({ data: null, error: null }) }
},
}),
}
return { client: client as unknown as SupabaseClient, updates }
}
const VALID = 'A'.repeat(43)
describe('resolvePayslipToken', () => {
beforeEach(() => vi.clearAllMocks())
it('rejects invalid format without touching the database', async () => {
const from = vi.fn()
const client = { from } as unknown as SupabaseClient
const result = await resolvePayslipToken(client, 'not-a-token')
expect(result).toEqual({ ok: false, reason: 'invalid_format' })
expect(from).not.toHaveBeenCalled()
})
it('returns not_found for an unknown token', async () => {
const { client } = resolveClient(null)
const result = await resolvePayslipToken(client, VALID)
expect(result).toEqual({ ok: false, reason: 'not_found' })
})
it('returns revoked before expired for a revoked link', async () => {
const { client, updates } = resolveClient({
id: 'link-1',
revoked_at: '2026-01-01T00:00:00Z',
expires_at: '2020-01-01T00:00:00Z',
access_count: 0,
})
const result = await resolvePayslipToken(client, VALID)
expect(result).toEqual({ ok: false, reason: 'revoked' })
expect(updates).toHaveLength(0)
})
it('returns expired for a past expires_at', async () => {
const { client } = resolveClient({
id: 'link-1',
revoked_at: null,
expires_at: '2020-01-01T00:00:00Z',
access_count: 0,
})
const result = await resolvePayslipToken(client, VALID)
expect(result).toEqual({ ok: false, reason: 'expired' })
})
it('resolves a live link and bumps access tracking', async () => {
const future = new Date(Date.now() + 60_000).toISOString()
const row = {
id: 'link-1',
company_id: 'company-1',
salary_run_id: 'run-1',
employee_id: 'emp-1',
token_hash: hashPayslipToken(VALID),
revoked_at: null,
expires_at: future,
access_count: 3,
}
const { client, updates } = resolveClient(row)
const result = await resolvePayslipToken(client, VALID)
expect(result.ok).toBe(true)
if (result.ok) expect(result.link.id).toBe('link-1')
expect(updates).toHaveLength(1)
expect(updates[0].access_count).toBe(4)
expect(updates[0].last_accessed_at).toBeTruthy()
})
})
describe('rotateLinkForEmployee', () => {
it('upserts a fresh hash with cleared revocation and returns the raw token', async () => {
let upserted: Record<string, unknown> | null = null
let conflictTarget: string | undefined
const client = {
from: () => ({
upsert: async (row: Record<string, unknown>, opts: { onConflict: string }) => {
upserted = row
conflictTarget = opts.onConflict
return { error: null }
},
}),
} as unknown as SupabaseClient
const { token } = await rotateLinkForEmployee(client, {
companyId: 'company-1',
salaryRunId: 'run-1',
employeeId: 'emp-1',
userId: 'user-1',
})
expect(isValidPayslipTokenFormat(token)).toBe(true)
expect(conflictTarget).toBe('salary_run_id,employee_id')
const row = upserted as unknown as Record<string, unknown>
expect(row).not.toBeNull()
expect(row.token_hash).toBe(hashPayslipToken(token))
expect(row.revoked_at).toBeNull()
expect(new Date(row.expires_at as string).getTime()).toBeGreaterThan(Date.now())
// The raw token must never be part of the persisted row
expect(Object.values(row)).not.toContain(token)
})
it('throws when the upsert fails', async () => {
const client = {
from: () => ({ upsert: async () => ({ error: { message: 'boom' } }) }),
} as unknown as SupabaseClient
await expect(
rotateLinkForEmployee(client, {
companyId: 'c',
salaryRunId: 'r',
employeeId: 'e',
userId: 'u',
}),
).rejects.toThrow('boom')
})
})
describe('revokeLinksForRun', () => {
it('stamps revoked_at only on live links for the run', async () => {
const calls: Array<{ update: Record<string, unknown>; eq: unknown[]; is: unknown[] }> = []
const client = {
from: () => ({
update: (u: Record<string, unknown>) => ({
eq: (...eqArgs: unknown[]) => ({
is: async (...isArgs: unknown[]) => {
calls.push({ update: u, eq: eqArgs, is: isArgs })
return { data: null, error: null }
},
}),
}),
}),
} as unknown as SupabaseClient
await revokeLinksForRun(client, 'run-1')
expect(calls).toHaveLength(1)
expect(calls[0].update.revoked_at).toBeTruthy()
expect(calls[0].eq).toEqual(['salary_run_id', 'run-1'])
expect(calls[0].is).toEqual(['revoked_at', null])
})
})
+145
View File
@@ -0,0 +1,145 @@
/**
* Shared PayslipData assembly.
*
* The per-employee PDF route and the payslip send/link surfaces must render
* identical payslips — override coalescing, breakdown steps and masking live
* here so the logic can't drift between callers.
*/
import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
const EMPLOYMENT_LABELS: Record<string, string> = {
employee: 'Anställd',
company_owner: 'Företagsledare',
board_member: 'Styrelseledamot',
}
export interface PayslipRunSource {
period_year: number
period_month: number
payment_date: string
}
export interface PayslipEmployeeSource {
first_name: string
last_name: string
personnummer: string
employment_type: string
tax_table_number: number | null
tax_column: number
clearing_number: string | null
bank_account_number: string | null
}
/** salary_run_employees row with joined line_items — loose shape by design
* (the callers select `*`), narrowed field-by-field below. */
export type PayslipSreSource = Record<string, unknown> & {
line_items?: Array<Record<string, unknown>> | null
}
export function buildPayslipData(params: {
run: PayslipRunSource
sre: PayslipSreSource
employee: PayslipEmployeeSource
company: { name: string; org_number: string | null }
}): PayslipData {
const { run, sre, employee: emp, company } = params
const lineItems: PayslipLineItem[] = ((sre.line_items || []) as Array<Record<string, unknown>>)
.sort((a, b) => ((a.sort_order as number) || 0) - ((b.sort_order as number) || 0))
.map(li => ({
description: li.description as string,
quantity: li.quantity as number | undefined,
unitPrice: li.unit_price as number | undefined,
amount: li.amount as number,
}))
let taxReference = 'Schablon 30%'
if (emp.tax_table_number) {
taxReference = `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}`
}
// Engine-computed breakdown rows stay for transparency; manual override
// rows are appended so the breakdown matches the displayed totals.
const breakdown = sre.calculation_breakdown as {
steps?: Array<{ label: string; formula: string; output: number }>
} | null
const baseSteps = breakdown?.steps ?? []
const overrideSteps: Array<{ label: string; formula: string; output: number }> = []
const reason = (sre.override_reason as string | null) || 'manuell justering'
if (sre.tax_withheld_override !== null && sre.tax_withheld_override !== undefined) {
overrideSteps.push({
label: 'Manuell justering: Skatteavdrag',
formula: reason,
output: Number(sre.tax_withheld_override),
})
}
if (sre.avgifter_basis_override !== null && sre.avgifter_basis_override !== undefined) {
overrideSteps.push({
label: 'Manuell justering: Avgiftsunderlag',
formula: reason,
output: Number(sre.avgifter_basis_override),
})
}
if (sre.avgifter_amount_override !== null && sre.avgifter_amount_override !== undefined) {
overrideSteps.push({
label: 'Manuell justering: Arbetsgivaravgifter',
formula: reason,
output: Number(sre.avgifter_amount_override),
})
}
const breakdownSteps = baseSteps.length > 0 || overrideSteps.length > 0
? [...baseSteps, ...overrideSteps]
: undefined
let bankAccount: string | undefined
if (emp.clearing_number && emp.bank_account_number) {
const lastDigits = emp.bank_account_number.slice(-4)
bankAccount = `${emp.clearing_number}-****${lastDigits}`
}
// Honor advanced-mode per-employee overrides so the employee sees the same
// effective values that are booked and AGI-reported.
const grossSalary = sre.gross_salary as number
const taxWithheld = sre.tax_withheld as number
const effectiveTax = (sre.tax_withheld_override as number | null) ?? taxWithheld
const effectiveAvgifter =
(sre.avgifter_amount_override as number | null) ?? (sre.avgifter_amount as number)
const effectiveNet = (sre.net_salary as number) + (taxWithheld - effectiveTax)
const vacationAccrual = sre.vacation_accrual as number
const vacationAccrualAvgifter = sre.vacation_accrual_avgifter as number
return {
companyName: company.name,
companyOrgNumber: company.org_number || '',
employeeName: `${emp.first_name} ${emp.last_name}`,
personnummerMasked: maskPersonnummer(decryptPersonnummer(emp.personnummer)),
employmentType: EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type,
periodYear: run.period_year,
periodMonth: run.period_month,
paymentDate: run.payment_date,
lineItems,
grossSalary,
taxWithheld: effectiveTax,
netSalary: effectiveNet,
taxReference,
avgifterRate: sre.avgifter_rate as number,
avgifterAmount: effectiveAvgifter,
vacationAccrual,
vacationAccrualAvgifter,
totalEmployerCost: grossSalary + effectiveAvgifter + vacationAccrual + vacationAccrualAvgifter,
ytdGross: sre.ytd_gross as number,
ytdTax: sre.ytd_tax as number,
ytdNet: sre.ytd_net as number,
bankAccount,
breakdownSteps,
}
}
export function payslipFileName(
run: PayslipRunSource,
emp: Pick<PayslipEmployeeSource, 'first_name' | 'last_name'>,
): string {
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
return `lonespec_${emp.last_name}_${emp.first_name}_${periodLabel}.pdf`
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Payslip link email — Swedish-only (employee-facing, same rationale as
* customer emails in .claude/rules/i18n.md).
*
* The email deliberately carries NO salary data and NO attachment: just the
* secure link. Names are user-authored input and get escaped.
*/
import { escapeHtml, sanitizeSubjectLine } from '@/lib/email/user-text'
import { PAYSLIP_LINK_TTL_DAYS } from '@/lib/salary/payslips/links'
export const MONTH_NAMES_SV = [
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
] as const
export function buildPayslipLinkEmail(params: {
employeeFirstName: string
companyName: string
periodYear: number
periodMonth: number
paymentDate: string
url: string
}): { subject: string; html: string; text: string } {
const monthName = MONTH_NAMES_SV[params.periodMonth - 1]
const subject = sanitizeSubjectLine(
`Lönespecifikation ${monthName} ${params.periodYear} — ${params.companyName}`,
)
const firstName = escapeHtml(params.employeeFirstName)
const companyName = escapeHtml(params.companyName)
const url = escapeHtml(params.url)
const html = `<p>Hej ${firstName},</p>
<p>Din lönespecifikation för ${monthName} ${params.periodYear} finns att hämta via länken nedan.</p>
<p><a href="${url}">Visa lönespecifikation</a></p>
<p>Utbetalningsdag: ${escapeHtml(params.paymentDate)}<br>
Länken är personlig och giltig i ${PAYSLIP_LINK_TTL_DAYS} dagar.</p>
<p>Med vänliga hälsningar,<br>${companyName}</p>`
const text = `Hej ${params.employeeFirstName},
Din lönespecifikation för ${monthName} ${params.periodYear} finns att hämta här:
${params.url}
Utbetalningsdag: ${params.paymentDate}
Länken är personlig och giltig i ${PAYSLIP_LINK_TTL_DAYS} dagar.
Med vänliga hälsningar,
${params.companyName}`
return { subject, html, text }
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Secure payslip links — token lifecycle for emailed payslip URLs.
*
* Payslips are delivered as links, never attachments (salary data +
* personnummer must not live in inboxes). The token IS the authentication
* for the public /payslip/[token] surface, so it is treated as a credential:
* 32 random bytes, sha256-hashed at rest (raw token exists only in the
* outgoing email), one live link per (salary_run, employee), rotated on
* every resend and revoked when the run is corrected.
*/
import { createHash, randomBytes } from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
/** Links stay valid 90 days from (re)send; resending re-arms the expiry. */
export const PAYSLIP_LINK_TTL_DAYS = 90
// 32 bytes base64url = 43 chars, alphabet [A-Za-z0-9_-].
const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
export interface PayslipLinkRow {
id: string
company_id: string
salary_run_id: string
employee_id: string
token_hash: string
expires_at: string
revoked_at: string | null
access_count: number
}
export type ResolvePayslipTokenResult =
| { ok: true; link: PayslipLinkRow }
| { ok: false; reason: 'invalid_format' | 'not_found' | 'expired' | 'revoked' }
export function generatePayslipToken(): { token: string; hash: string } {
const token = randomBytes(32).toString('base64url')
return { token, hash: hashPayslipToken(token) }
}
export function hashPayslipToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
export function isValidPayslipTokenFormat(token: string): boolean {
return TOKEN_RE.test(token)
}
/**
* Create or rotate the link for one employee in a run. Rotation overwrites
* token_hash, so any previously emailed link stops resolving — exactly one
* live token per (salary_run_id, employee_id) at any time.
*
* Returns the RAW token (for the email); it is never persisted.
*/
export async function rotateLinkForEmployee(
supabase: SupabaseClient,
params: {
companyId: string
salaryRunId: string
employeeId: string
userId: string
},
): Promise<{ token: string }> {
const { token, hash } = generatePayslipToken()
const expiresAt = new Date(
Date.now() + PAYSLIP_LINK_TTL_DAYS * 24 * 60 * 60 * 1000,
).toISOString()
const { error } = await supabase
.from('salary_payslip_links')
.upsert(
{
company_id: params.companyId,
salary_run_id: params.salaryRunId,
employee_id: params.employeeId,
user_id: params.userId,
token_hash: hash,
expires_at: expiresAt,
revoked_at: null,
},
{ onConflict: 'salary_run_id,employee_id' },
)
if (error) {
throw new Error(`Kunde inte skapa lönebeskedslänk: ${error.message}`)
}
return { token }
}
/**
* Revoke every live link for a run. Used when the run is corrected —
* the storno replaces the payslips, so old links must stop resolving.
*/
export async function revokeLinksForRun(
supabase: SupabaseClient,
salaryRunId: string,
): Promise<void> {
await supabase
.from('salary_payslip_links')
.update({ revoked_at: new Date().toISOString() })
.eq('salary_run_id', salaryRunId)
.is('revoked_at', null)
}
/**
* Resolve a raw token from the public surface. Service-role client — code is
* the only guard here: strict hash equality, then revocation/expiry checks.
* Touches access tracking on success. Never log the raw token.
*/
export async function resolvePayslipToken(
serviceClient: SupabaseClient,
token: string,
): Promise<ResolvePayslipTokenResult> {
if (!isValidPayslipTokenFormat(token)) {
return { ok: false, reason: 'invalid_format' }
}
const { data: link } = await serviceClient
.from('salary_payslip_links')
.select('id, company_id, salary_run_id, employee_id, token_hash, expires_at, revoked_at, access_count')
.eq('token_hash', hashPayslipToken(token))
.maybeSingle()
if (!link) return { ok: false, reason: 'not_found' }
if (link.revoked_at) return { ok: false, reason: 'revoked' }
if (new Date(link.expires_at) < new Date()) return { ok: false, reason: 'expired' }
await serviceClient
.from('salary_payslip_links')
.update({
last_accessed_at: new Date().toISOString(),
access_count: (link.access_count ?? 0) + 1,
})
.eq('id', link.id)
return { ok: true, link: link as PayslipLinkRow }
}
+29 -11
View File
@@ -45,6 +45,22 @@ export async function updateSession(request: NextRequest) {
// Get the pathname
const pathname = request.nextUrl.pathname
// If the refresh token is stale/invalid, clear the session cookies so the
// browser stops sending them on every request, INCLUDING /api requests,
// which previously returned before this cleanup and replayed the dead
// token forever. Skip on auth routes, the callback needs PKCE cookies
// intact. scope: 'local' only clears cookies: the refresh token is already
// dead server-side, and the default global-revoke round-trip re-triggers
// the failed refresh, the exact AuthApiError this cleans up after.
if (authError && !user && !pathname.startsWith('/auth')) {
try {
await supabase.auth.signOut({ scope: 'local' })
} catch (signOutError) {
// Expected session expiry, not a runtime error.
console.warn('[middleware] session cleanup after stale refresh token failed', signOutError)
}
}
// ── API routes ──────────────────────────────────────────────────────────
// API routes authenticate themselves (requireAuth, API-key Bearer, cron
// secret, webhook signatures). Middleware runs on them for ONE reason: to
@@ -73,13 +89,6 @@ export async function updateSession(request: NextRequest) {
return supabaseResponse
}
// If the refresh token is stale/invalid, clear the session cookies
// so the browser stops sending them on every request.
// Skip on auth routes: the callback needs PKCE cookies intact.
if (authError && !user && !pathname.startsWith('/auth')) {
await supabase.auth.signOut()
}
// Invite pages: accessible to everyone, signed in or not. A user who
// already has an account and is signed in should still be able to land on
// /invite/[token] to accept the invite with one click (see
@@ -89,6 +98,14 @@ export async function updateSession(request: NextRequest) {
return supabaseResponse
}
// Public payslip pages, the token in the URL is the authentication
// (resolved server-side against salary_payslip_links). Employees have no
// account; bouncing them to /login would make every emailed payslip link
// dead. See app/payslip/[token]/page.tsx.
if (pathname.startsWith('/payslip')) {
return supabaseResponse
}
// Reset-password is reachable in both auth states. The recovery flow lands
// here with a fresh session (created by the OTP exchange in /auth/callback)
// precisely so the user can call supabase.auth.updateUser({ password }). If
@@ -222,12 +239,13 @@ export async function updateSession(request: NextRequest) {
return supabaseResponse
}
// Enrichment lives in the user-keyed `bankid_enrichment` table (migration
// 20260506160000), it cannot live in extension_data, which is
// company-scoped, and the user has no company yet on this path.
const { data: enrichmentRow } = await supabase
.from('extension_data')
.select('id')
.from('bankid_enrichment')
.select('user_id')
.eq('user_id', user.id)
.eq('extension_id', 'tic')
.eq('key', 'bankid_enrichment')
.maybeSingle()
const destination = enrichmentRow ? '/select-company' : '/onboarding'
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTaxDeadlinesForUser } from '../deadline-generator'
import type { CompanySettingsForDeadlines } from '../deadline-config'
const SETTINGS: CompanySettingsForDeadlines = {
entity_type: 'aktiebolag',
moms_period: 'monthly',
f_skatt: true,
vat_registered: true,
pays_salaries: true,
fiscal_year_start_month: 1,
}
// Future year so generated dates are never skipped as "in the past".
const FUTURE_YEAR = new Date().getFullYear() + 1
/**
* Recording mock: captures the order of insert/delete operations and the
* insert payload, so the tests can assert the insert-first/delete-after
* ordering that prevents regeneration failures from wiping deadlines.
*/
function makeRecordingSupabase(opts: { insertError?: { code: string; message: string } } = {}) {
const calls: string[] = []
let insertPayload: Array<Record<string, unknown>> | null = null
const from = vi.fn(() => {
const chain: Record<string, ReturnType<typeof vi.fn>> = {}
const self = () => chain
chain.insert = vi.fn((rows: Array<Record<string, unknown>>) => {
calls.push('insert')
insertPayload = rows
return {
select: vi.fn(async () =>
opts.insertError
? { data: null, error: opts.insertError }
: { data: rows.map((_, i) => ({ id: `new-${i}` })), error: null }
),
}
})
chain.delete = vi.fn(() => {
calls.push('delete')
return chain
})
chain.eq = vi.fn(self)
chain.gte = vi.fn(self)
chain.lte = vi.fn(self)
chain.not = vi.fn((...args: unknown[]) => {
calls.push(`not(${String(args[2]).slice(0, 20)}…)`)
return chain
})
chain.select = vi.fn(async () => ({ data: [{ id: 'old-1' }, { id: 'old-2' }], error: null }))
return chain
})
return {
supabase: { from } as unknown as SupabaseClient,
calls,
getInsertPayload: () => insertPayload,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('generateTaxDeadlinesForUser', () => {
it('inserts replacement rows before deleting the old set', async () => {
const { supabase, calls } = makeRecordingSupabase()
const result = await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, [FUTURE_YEAR])
expect(calls[0]).toBe('insert')
expect(calls[1]).toBe('delete')
expect(result.created).toBeGreaterThan(0)
expect(result.deleted).toBe(2)
})
it('excludes the newly inserted rows from the delete', async () => {
const { supabase, calls } = makeRecordingSupabase()
await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, [FUTURE_YEAR])
expect(calls.some((c) => c.startsWith('not('))).toBe(true)
})
it('builds rows owned by company_id, without a user_id field', async () => {
const { supabase, getInsertPayload } = makeRecordingSupabase()
await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, [FUTURE_YEAR])
const rows = getInsertPayload()
expect(rows).not.toBeNull()
for (const row of rows!) {
expect(row.company_id).toBe('company-1')
expect('user_id' in row).toBe(false)
expect(row.source).toBe('system')
expect(row.is_auto_generated).toBe(true)
}
})
it('does not delete existing deadlines when the insert fails', async () => {
const { supabase, calls } = makeRecordingSupabase({
insertError: { code: '23502', message: 'null value in column "user_id"' },
})
await expect(
generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, [FUTURE_YEAR])
).rejects.toMatchObject({ code: '23502' })
expect(calls).toContain('insert')
expect(calls).not.toContain('delete')
})
})
+30 -20
View File
@@ -69,26 +69,9 @@ export async function generateTaxDeadlinesForUser(
// Get applicable deadline configs based on settings
const applicableConfigs = getApplicableDeadlineConfigs(settings)
// Delete existing system-generated deadlines for these years
const startDate = `${Math.min(...years)}-01-01`
const endDate = `${Math.max(...years)}-12-31`
const { data: deletedData, error: deleteError } = await supabase
.from('deadlines')
.delete()
.eq('company_id', companyId)
.eq('source', 'system')
.gte('due_date', startDate)
.lte('due_date', endDate)
.select('id')
if (deleteError) {
log.error('Error deleting existing deadlines:', deleteError)
throw deleteError
}
const deletedCount = deletedData?.length || 0
// Generate new deadlines
const deadlines: Array<{
company_id: string
@@ -156,21 +139,48 @@ export async function generateTaxDeadlinesForUser(
}
}
// Insert new deadlines
// Insert the replacement rows BEFORE deleting the old set. A failed insert
// then leaves the previous deadlines intact — the old delete-first order
// meant any insert failure (like the 23502 user_id regression) wiped the
// company's tax deadlines without replacing them.
let newIds: string[] = []
if (deadlines.length > 0) {
const { error: insertError } = await supabase
const { data: insertedData, error: insertError } = await supabase
.from('deadlines')
.insert(deadlines)
.select('id')
if (insertError) {
log.error('Error inserting deadlines:', insertError)
throw insertError
}
newIds = (insertedData ?? []).map((d: { id: string }) => d.id)
}
// Delete the superseded system-generated deadlines for these years,
// excluding the rows just inserted.
let deleteQuery = supabase
.from('deadlines')
.delete()
.eq('company_id', companyId)
.eq('source', 'system')
.gte('due_date', startDate)
.lte('due_date', endDate)
if (newIds.length > 0) {
deleteQuery = deleteQuery.not('id', 'in', `(${newIds.join(',')})`)
}
const { data: deletedData, error: deleteError } = await deleteQuery.select('id')
if (deleteError) {
log.error('Error deleting existing deadlines:', deleteError)
throw deleteError
}
return {
created: deadlines.length,
deleted: deletedCount,
deleted: deletedData?.length || 0,
}
}
+24 -14
View File
@@ -298,6 +298,12 @@ export async function ingestTransactions(
// no date stamps every row at today's rate, which is wrong for historical
// imports (issue #442). fetchExchangeRate already falls back to the last
// 7 days when the requested day is a weekend/holiday.
//
// Concurrency is bounded: a 90-day first-sync backfill of a foreign-
// currency account used to fire every pair at Riksbanken simultaneously
// and got the whole batch rate-limited. Passing `supabase` gives
// fetchExchangeRate the persistent exchange_rates cache, so repeat dates
// cost a DB lookup instead of an API call.
if (!options?.rawInsertOnly) {
const uniquePairs = new Map<string, { currency: Currency; date: string }>()
for (const t of rawTransactions) {
@@ -311,22 +317,26 @@ export async function ingestTransactions(
if (uniquePairs.size > 0) {
const pairs = Array.from(uniquePairs.entries())
const settled = await Promise.allSettled(
pairs.map(([, { currency, date }]) =>
fetchExchangeRate(currency, new Date(date))
const RATE_FETCH_CONCURRENCY = 4
for (let i = 0; i < pairs.length; i += RATE_FETCH_CONCURRENCY) {
const chunk = pairs.slice(i, i + RATE_FETCH_CONCURRENCY)
const settled = await Promise.allSettled(
chunk.map(([, { currency, date }]) =>
fetchExchangeRate(currency, new Date(date), supabase)
)
)
)
for (let i = 0; i < pairs.length; i++) {
const [key] = pairs[i]
const outcome = settled[i]
if (outcome.status === 'fulfilled' && outcome.value) {
exchangeRatesByDate.set(key, outcome.value)
for (let j = 0; j < chunk.length; j++) {
const [key] = chunk[j]
const outcome = settled[j]
if (outcome.status === 'fulfilled' && outcome.value) {
exchangeRatesByDate.set(key, outcome.value)
}
// A null/rejected outcome leaves the key unset: the transaction is
// inserted without amount_sek/exchange_rate and remains repairable
// via /api/transactions/[id]/refresh-exchange-rate. Rates are never
// made up, fetchExchangeRate's last resort is the most recent
// CACHED observation, not a hardcoded number.
}
// Network failures resolve inside fetchExchangeRate to getFallbackRate()
// (non-null, today's date), so they still populate the key. The key
// only stays unset when the API returns an empty observation array
// or the promise rejects outright; in that case amount_sek and
// exchange_rate remain null on the inserted transaction.
}
}
}