2c2743eb79
* 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>
1466 lines
50 KiB
TypeScript
1466 lines
50 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
parseSIEFile,
|
|
validateSIEFile,
|
|
detectEncoding,
|
|
decodeBuffer,
|
|
getEffectiveOpeningBalances,
|
|
hasOpeningBalanceVoucherCandidate,
|
|
} from '../sie-parser'
|
|
|
|
// --- SIE content fixtures ---
|
|
|
|
const MINIMAL_SIE = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#PROGRAM "TestProg" "1.0"',
|
|
'#FORMAT PC8',
|
|
'#GEN 20240101',
|
|
'#FNAMN "Test AB"',
|
|
'#ORGNR 5566778899',
|
|
'#VALUTA SEK',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 3001 "Försäljning varor 25%"',
|
|
].join('\n')
|
|
|
|
const SIE_WITH_BALANCES = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Balans AB"',
|
|
'#ORGNR 1234567890',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 2440 "Leverantörsskulder"',
|
|
'#IB 0 1510 50000.00',
|
|
'#IB 0 1930 100000.00',
|
|
'#IB 0 2440 -150000.00',
|
|
'#UB 0 1510 75000.00',
|
|
'#UB 0 1930 125000.00',
|
|
'#UB 0 2440 -200000.00',
|
|
].join('\n')
|
|
|
|
const SIE_WITH_VOUCHERS = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Voucher AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#KONTO 2611 "Utgående moms 25%"',
|
|
'#VER A 1 20240115 "Faktura 1001"',
|
|
'{',
|
|
'#TRANS 1510 {} 12500.00',
|
|
'#TRANS 3001 {} -10000.00',
|
|
'#TRANS 2611 {} -2500.00',
|
|
'}',
|
|
'#VER A 2 20240220 "Inbetalning faktura 1001"',
|
|
'{',
|
|
'#TRANS 1930 {} 12500.00',
|
|
'#TRANS 1510 {} -12500.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const SIE_TYPE_1 = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 1',
|
|
'#FNAMN "SIE1 AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#IB 0 1510 50000.00',
|
|
'#UB 0 1510 75000.00',
|
|
].join('\n')
|
|
|
|
const SIE_WITH_SRU = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "SRU AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#SRU 1510 7251',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#SRU 3001 7410',
|
|
].join('\n')
|
|
|
|
const SIE_UNBALANCED_VOUCHER = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Obalanserad AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#VER A 1 20240115 "Obalanserad verifikation"',
|
|
'{',
|
|
'#TRANS 1510 {} 10000.00',
|
|
'#TRANS 3001 {} -5000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const SIE_WITH_OBJECT_LIST = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Objects AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 5010 "Lokalhyra"',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#VER A 1 20240115 "Hyra januari"',
|
|
'{',
|
|
'#TRANS 5010 {1 "Kontor"} 15000.00',
|
|
'#TRANS 1930 {} -15000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
// SIE file where all VER/TRANS fields are quoted (common from some accounting programs)
|
|
const SIE_QUOTED_FIELDS = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Quoted AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#KONTO 2611 "Utgående moms 25%"',
|
|
'#VER "A" "1" "20240115" "Faktura 1001"',
|
|
'{',
|
|
'#TRANS "1510" {} "12500.00"',
|
|
'#TRANS "3001" {} "-10000.00"',
|
|
'#TRANS "2611" {} "-2500.00"',
|
|
'}',
|
|
].join('\n')
|
|
|
|
// SIE file with empty series (some programs use "" for series)
|
|
const SIE_EMPTY_SERIES = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Empty Series AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#VER "" 1 20240115 "No series"',
|
|
'{',
|
|
'#TRANS 1930 {} 10000.00',
|
|
'#TRANS 3001 {} -10000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
// SIE file with { on same line as #VER
|
|
const SIE_BRACE_ON_VER_LINE = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Brace AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#VER A 1 20240115 "Inline brace" {',
|
|
'#TRANS 1930 {} 10000.00',
|
|
'#TRANS 3001 {} -10000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
// --- parseSIEFile tests ---
|
|
|
|
describe('parseSIEFile', () => {
|
|
describe('header parsing', () => {
|
|
it('parses SIE type', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.sieType).toBe(4)
|
|
})
|
|
|
|
it('parses company name from #FNAMN', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.companyName).toBe('Test AB')
|
|
})
|
|
|
|
it('parses org number from #ORGNR', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.orgNumber).toBe('5566778899')
|
|
})
|
|
|
|
it('parses fiscal year from #RAR', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.fiscalYears).toHaveLength(1)
|
|
expect(result.header.fiscalYears[0].yearIndex).toBe(0)
|
|
expect(result.header.fiscalYears[0].start).toBe('2024-01-01')
|
|
expect(result.header.fiscalYears[0].end).toBe('2024-12-31')
|
|
})
|
|
|
|
it('parses currency from #VALUTA', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.currency).toBe('SEK')
|
|
})
|
|
|
|
it('defaults currency to SEK when not specified', () => {
|
|
const content = '#FLAGGA 0\n#SIETYP 4\n#FNAMN "Test"\n#RAR 0 20240101 20241231'
|
|
const result = parseSIEFile(content)
|
|
expect(result.header.currency).toBe('SEK')
|
|
})
|
|
|
|
it('parses program info', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.program).toBe('TestProg')
|
|
expect(result.header.programVersion).toBe('1.0')
|
|
})
|
|
|
|
it('parses generated date', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.generatedDate).toBe('2024-01-01')
|
|
})
|
|
|
|
it('parses SIE type 1', () => {
|
|
const result = parseSIEFile(SIE_TYPE_1)
|
|
expect(result.header.sieType).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('account parsing', () => {
|
|
it('parses #KONTO with number and name', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.accounts).toHaveLength(3)
|
|
expect(result.accounts[0]).toEqual({ number: '1510', name: 'Kundfordringar' })
|
|
expect(result.accounts[1]).toEqual({ number: '1930', name: 'Företagskonto' })
|
|
})
|
|
|
|
it('parses #SRU codes onto accounts', () => {
|
|
const result = parseSIEFile(SIE_WITH_SRU)
|
|
const account1510 = result.accounts.find((a) => a.number === '1510')
|
|
expect(account1510?.sruCode).toBe('7251')
|
|
const account3001 = result.accounts.find((a) => a.number === '3001')
|
|
expect(account3001?.sruCode).toBe('7410')
|
|
})
|
|
})
|
|
|
|
describe('balance parsing', () => {
|
|
it('parses opening balances (#IB) with positive amounts', () => {
|
|
const result = parseSIEFile(SIE_WITH_BALANCES)
|
|
const ib1510 = result.openingBalances.find((b) => b.account === '1510')
|
|
expect(ib1510?.amount).toBe(50000)
|
|
expect(ib1510?.yearIndex).toBe(0)
|
|
})
|
|
|
|
it('parses opening balances (#IB) with negative amounts', () => {
|
|
const result = parseSIEFile(SIE_WITH_BALANCES)
|
|
const ib2440 = result.openingBalances.find((b) => b.account === '2440')
|
|
expect(ib2440?.amount).toBe(-150000)
|
|
})
|
|
|
|
it('parses closing balances (#UB)', () => {
|
|
const result = parseSIEFile(SIE_WITH_BALANCES)
|
|
expect(result.closingBalances).toHaveLength(3)
|
|
const ub1930 = result.closingBalances.find((b) => b.account === '1930')
|
|
expect(ub1930?.amount).toBe(125000)
|
|
})
|
|
})
|
|
|
|
describe('voucher parsing', () => {
|
|
it('parses #VER with series, number, date, description', () => {
|
|
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
|
expect(result.vouchers).toHaveLength(2)
|
|
|
|
const v1 = result.vouchers[0]
|
|
expect(v1.series).toBe('A')
|
|
expect(v1.number).toBe(1)
|
|
expect(v1.date).toEqual(new Date(2024, 0, 15))
|
|
expect(v1.description).toBe('Faktura 1001')
|
|
})
|
|
|
|
it('parses #TRANS lines within a voucher', () => {
|
|
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
|
const v1 = result.vouchers[0]
|
|
|
|
expect(v1.lines).toHaveLength(3)
|
|
expect(v1.lines[0]).toMatchObject({ account: '1510', amount: 12500 })
|
|
expect(v1.lines[1]).toMatchObject({ account: '3001', amount: -10000 })
|
|
expect(v1.lines[2]).toMatchObject({ account: '2611', amount: -2500 })
|
|
})
|
|
|
|
it('handles object lists in braces and captures them as dimensions', () => {
|
|
const result = parseSIEFile(SIE_WITH_OBJECT_LIST)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
|
|
const v = result.vouchers[0]
|
|
expect(v.lines).toHaveLength(2)
|
|
expect(v.lines[0]).toMatchObject({ account: '5010', amount: 15000 })
|
|
// The object list is data, not noise: lossless import (PR5).
|
|
expect(v.lines[0].dimensions).toEqual({ '1': 'Kontor' })
|
|
expect(v.lines[1]).toMatchObject({ account: '1930', amount: -15000 })
|
|
// Empty object list {} → no dimensions key at all.
|
|
expect(v.lines[1].dimensions).toBeUndefined()
|
|
})
|
|
|
|
it('parses multi-pair object lists with quoted codes and canonical keys', () => {
|
|
const sie = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#RAR 0 20240101 20241231',
|
|
'#VER A 1 20240115 "Projektköp"',
|
|
'{',
|
|
'#TRANS 5010 {"1" "KS 01" 06 "P001"} 15000.00',
|
|
'#TRANS 1930 {} -15000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(sie)
|
|
// '06' canonicalizes to '6' (matches normalizeLineDimensions); quoted
|
|
// codes may contain spaces.
|
|
expect(result.vouchers[0].lines[0].dimensions).toEqual({ '1': 'KS 01', '6': 'P001' })
|
|
})
|
|
|
|
it('warns on a malformed (odd-field) object list but keeps the line', () => {
|
|
const sie = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#RAR 0 20240101 20241231',
|
|
'#VER A 1 20240115 "Trasig objektlista"',
|
|
'{',
|
|
'#TRANS 5010 {6} 100.00',
|
|
'#TRANS 1930 {} -100.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(sie)
|
|
expect(result.vouchers[0].lines[0]).toMatchObject({ account: '5010', amount: 100 })
|
|
expect(result.vouchers[0].lines[0].dimensions).toBeUndefined()
|
|
expect(result.issues.some((i) => i.severity === 'warning' && i.message.toLowerCase().includes('objektlista'))).toBe(true)
|
|
})
|
|
|
|
it('surfaces OIB/OUB drops and dimension presence as info issues', () => {
|
|
const sie = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#RAR 0 20240101 20241231',
|
|
'#DIM 6 "Projekt"',
|
|
'#OIB 0 1930 {6 "P001"} 5000.00',
|
|
'#OUB 0 1930 {6 "P001"} 7000.00',
|
|
'#VER A 1 20240115 "Taggad"',
|
|
'{',
|
|
'#TRANS 5010 {6 "P001"} 100.00',
|
|
'#TRANS 1930 {} -100.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(sie)
|
|
const infos = result.issues.filter((i) => i.severity === 'info').map((i) => i.message)
|
|
expect(infos.some((m) => m.includes('2 objektbalansrader'))).toBe(true)
|
|
expect(infos.some((m) => m.includes('dimensionsdata'))).toBe(true)
|
|
// Silence preserved for files without any dimension data.
|
|
const plain = parseSIEFile(['#FLAGGA 0', '#SIETYP 4', '#RAR 0 20240101 20241231'].join('\n'))
|
|
expect(plain.issues.some((i) => i.tag === 'DIM' || i.tag === 'OIB')).toBe(false)
|
|
})
|
|
|
|
it('parses #DIM, #UNDERDIM and #OBJEKT into the registry arrays', () => {
|
|
const sie = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#RAR 0 20240101 20241231',
|
|
'#DIM 1 "Kostnadsställe"',
|
|
'#DIM 6 "Projekt"',
|
|
'#UNDERDIM 2 "Kostnadsbärare" 1',
|
|
'#OBJEKT 1 "KS01" "Butiken"',
|
|
'#OBJEKT 6 "P001" "Villa Almgren"',
|
|
'#OBJEKT 6 "P002" ""',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(sie)
|
|
expect(result.dimensions).toEqual([
|
|
{ sieDimNo: 1, name: 'Kostnadsställe' },
|
|
{ sieDimNo: 6, name: 'Projekt' },
|
|
{ sieDimNo: 2, name: 'Kostnadsbärare', parentSieDimNo: 1 },
|
|
])
|
|
expect(result.dimensionValues).toEqual([
|
|
{ sieDimNo: 1, code: 'KS01', name: 'Butiken' },
|
|
{ sieDimNo: 6, code: 'P001', name: 'Villa Almgren' },
|
|
// Nameless objekt falls back to its code.
|
|
{ sieDimNo: 6, code: 'P002', name: 'P002' },
|
|
])
|
|
})
|
|
|
|
it('parses quoted VER fields (series, number, date)', () => {
|
|
const result = parseSIEFile(SIE_QUOTED_FIELDS)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
|
|
const v = result.vouchers[0]
|
|
expect(v.series).toBe('A')
|
|
expect(v.number).toBe(1)
|
|
expect(v.date).toEqual(new Date(2024, 0, 15))
|
|
expect(v.description).toBe('Faktura 1001')
|
|
expect(v.lines).toHaveLength(3)
|
|
expect(v.lines[0]).toMatchObject({ account: '1510', amount: 12500 })
|
|
expect(v.lines[1]).toMatchObject({ account: '3001', amount: -10000 })
|
|
expect(v.lines[2]).toMatchObject({ account: '2611', amount: -2500 })
|
|
|
|
const errors = result.issues.filter((i) => i.severity === 'error')
|
|
expect(errors).toHaveLength(0)
|
|
})
|
|
|
|
it('allows empty series in VER', () => {
|
|
const result = parseSIEFile(SIE_EMPTY_SERIES)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
|
|
const v = result.vouchers[0]
|
|
expect(v.series).toBe('')
|
|
expect(v.number).toBe(1)
|
|
expect(v.lines).toHaveLength(2)
|
|
|
|
const errors = result.issues.filter((i) => i.severity === 'error')
|
|
expect(errors).toHaveLength(0)
|
|
})
|
|
|
|
it('handles { on same line as #VER', () => {
|
|
const result = parseSIEFile(SIE_BRACE_ON_VER_LINE)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
|
|
const v = result.vouchers[0]
|
|
expect(v.series).toBe('A')
|
|
expect(v.number).toBe(1)
|
|
expect(v.lines).toHaveLength(2)
|
|
|
|
const errors = result.issues.filter((i) => i.severity === 'error')
|
|
expect(errors).toHaveLength(0)
|
|
})
|
|
|
|
it('detects unbalanced vouchers as errors', () => {
|
|
const result = parseSIEFile(SIE_UNBALANCED_VOUCHER)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
|
|
const errors = result.issues.filter((i) => i.severity === 'error')
|
|
expect(errors.length).toBeGreaterThanOrEqual(1)
|
|
expect(errors.some((e) => e.message.includes('balanserar inte'))).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('statistics', () => {
|
|
it('calculates account count', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.stats.totalAccounts).toBe(3)
|
|
})
|
|
|
|
it('calculates voucher count', () => {
|
|
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
|
expect(result.stats.totalVouchers).toBe(2)
|
|
})
|
|
|
|
it('calculates transaction line count', () => {
|
|
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
|
// Voucher 1: 3 lines, Voucher 2: 2 lines
|
|
expect(result.stats.totalTransactionLines).toBe(5)
|
|
})
|
|
|
|
it('sets fiscal year start/end from RAR 0', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.stats.fiscalYearStart).toBe('2024-01-01')
|
|
expect(result.stats.fiscalYearEnd).toBe('2024-12-31')
|
|
})
|
|
|
|
it('returns null fiscal year dates when no RAR', () => {
|
|
const content = '#FLAGGA 0\n#SIETYP 4\n#FNAMN "Test"'
|
|
const result = parseSIEFile(content)
|
|
expect(result.stats.fiscalYearStart).toBeNull()
|
|
expect(result.stats.fiscalYearEnd).toBeNull()
|
|
})
|
|
})
|
|
})
|
|
|
|
// --- validateSIEFile tests ---
|
|
|
|
describe('validateSIEFile', () => {
|
|
it('returns valid for a complete SIE file', () => {
|
|
const parsed = parseSIEFile(SIE_WITH_VOUCHERS)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.valid).toBe(true)
|
|
expect(validation.errors).toHaveLength(0)
|
|
})
|
|
|
|
it('adds error for unbalanced vouchers', () => {
|
|
const parsed = parseSIEFile(SIE_UNBALANCED_VOUCHER)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.valid).toBe(false)
|
|
expect(validation.errors.some((e) => e.includes('balanserar inte'))).toBe(true)
|
|
})
|
|
|
|
it('no longer warns about accounts referenced in #IB since parser auto-adds them', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#IB 0 9999 50000.00',
|
|
].join('\n')
|
|
|
|
const parsed = parseSIEFile(content)
|
|
// Parser now auto-adds 9999 to accounts list from #IB data
|
|
expect(parsed.accounts.map((a) => a.number)).toContain('9999')
|
|
|
|
const validation = validateSIEFile(parsed)
|
|
// No warning since account was auto-added by the parser
|
|
expect(validation.warnings.some((w) => w.includes('9999') && w.includes('not defined'))).toBe(false)
|
|
})
|
|
|
|
it('adds error for missing #RAR', () => {
|
|
const content = '#FLAGGA 0\n#SIETYP 4\n#FNAMN "Test"\n#KONTO 1510 "Kund"'
|
|
const parsed = parseSIEFile(content)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.valid).toBe(false)
|
|
expect(validation.errors.some((e) => e.includes('fiscal year') || e.includes('#RAR'))).toBe(true)
|
|
})
|
|
|
|
it('adds warning for unbalanced opening balances', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#IB 0 1510 50000.00',
|
|
].join('\n')
|
|
|
|
const parsed = parseSIEFile(content)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.warnings.some((w) => w.includes('Ingående balanser balanserar inte'))).toBe(true)
|
|
})
|
|
|
|
it('passes with balanced opening balances', () => {
|
|
const parsed = parseSIEFile(SIE_WITH_BALANCES)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
// IB: 50000 + 100000 + (-150000) = 0 → balanced
|
|
const ibWarning = validation.warnings.find((w) => w.includes('Ingående balanser balanserar inte'))
|
|
expect(ibWarning).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
// --- 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', () => {
|
|
it('ignores #FORMAT PC8 and detects UTF-8 from byte patterns', () => {
|
|
// #FORMAT PC8 is unreliable: most cloud software (Fortnox, Bokio etc.)
|
|
// exports UTF-8 but still declares #FORMAT PC8.
|
|
// UTF-8 encoded: "Företagskonto" → 0xC3 0xB6 for ö
|
|
const text = '#FLAGGA 0\n#FORMAT PC8\n#FNAMN "Företagskonto"\n'
|
|
const encoder = new TextEncoder() // TextEncoder outputs UTF-8
|
|
const buf = encoder.encode(text)
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('utf8')
|
|
})
|
|
|
|
it('detects Win-1252 when actual byte values are in Win-1252 range', () => {
|
|
// Win-1252 bytes for Swedish chars: ö=0xF6, ä=0xE4, å=0xE5
|
|
const prefix = new TextEncoder().encode('#FORMAT PC8\n#FNAMN F')
|
|
const buf = new Uint8Array(prefix.length + 3)
|
|
buf.set(prefix)
|
|
buf[prefix.length] = 0xf6 // ö in Win-1252
|
|
buf[prefix.length + 1] = 0xe4 // ä in Win-1252
|
|
buf[prefix.length + 2] = 0xe5 // å in Win-1252
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('windows1252')
|
|
})
|
|
|
|
it('returns utf8 for pure ASCII files (no high bytes)', () => {
|
|
const text = '#FLAGGA 0\n#FORMAT PC8\n#SIETYP 4\n'
|
|
const encoder = new TextEncoder()
|
|
const buf = encoder.encode(text)
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('utf8')
|
|
})
|
|
})
|
|
|
|
describe('detectEncoding: range-based discrimination', () => {
|
|
it('detects CP437 when bytes are in 0x80-0x9F range only', () => {
|
|
// 0x84=ä, 0x86=å, 0x94=ö in CP437: all in 0x80-0x9F
|
|
const buf = new Uint8Array([0x23, 0x84, 0x86, 0x94, 0x84, 0x86])
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('cp437')
|
|
})
|
|
|
|
it('detects Win-1252 when bytes are in 0xC0-0xFF range only', () => {
|
|
// 0xE4=ä, 0xE5=å, 0xF6=ö in Win-1252: all in 0xC0-0xFF
|
|
const buf = new Uint8Array([0x23, 0xe4, 0xe5, 0xf6, 0xe4, 0xe5])
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('windows1252')
|
|
})
|
|
|
|
it('does not double-count UTF-8 continuation bytes as CP437', () => {
|
|
// UTF-8: ä = C3 A4, å = C3 A5, ö = C3 B6
|
|
// Without skipping, 0xA4/0xA5/0xB6 are NOT in CP437 map so no false count,
|
|
// but 0x84/0x85 ARE in CP437 map: test that C3 84 (Ä in UTF-8) is not
|
|
// counted as CP437 0x84 (ä)
|
|
const buf = new Uint8Array([
|
|
0x23, // #
|
|
0xc3, 0x84, // Ä in UTF-8
|
|
0xc3, 0x85, // Å in UTF-8
|
|
0xc3, 0x96, // Ö in UTF-8
|
|
0xc3, 0xa4, // ä in UTF-8
|
|
0xc3, 0xa5, // å in UTF-8
|
|
])
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('utf8')
|
|
})
|
|
})
|
|
describe('detectEncoding: Windows-1252', () => {
|
|
it('detects Windows-1252 when Swedish chars use Win-1252 byte values', () => {
|
|
// Build a buffer with Windows-1252 encoded Swedish text: "#FNAMN Företag"
|
|
// å=0xE5, ä=0xE4, ö=0xF6 in Windows-1252 (NOT in CP437 map)
|
|
const text = '#FNAMN F'
|
|
const encoder = new TextEncoder()
|
|
const prefix = encoder.encode(text)
|
|
// Add ö (0xF6) r (0x72) e (0x65) t (0x74) a (0x61) g (0x67)
|
|
const buf = new Uint8Array(prefix.length + 6)
|
|
buf.set(prefix)
|
|
buf[prefix.length] = 0xf6 // ö in Windows-1252
|
|
buf[prefix.length + 1] = 0x72 // r
|
|
buf[prefix.length + 2] = 0x65 // e
|
|
buf[prefix.length + 3] = 0x74 // t
|
|
buf[prefix.length + 4] = 0x61 // a
|
|
buf[prefix.length + 5] = 0x67 // g
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('windows1252')
|
|
})
|
|
|
|
it('detects UTF-8 BOM even when Windows-1252 bytes are present', () => {
|
|
const buf = new Uint8Array([0xef, 0xbb, 0xbf, 0x23, 0xe5]) // BOM + # + å-win1252
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('utf8')
|
|
})
|
|
|
|
it('detects CP437 when CP437-specific bytes are present', () => {
|
|
// 0x86 = å in CP437 (not in Win-1252 Swedish set)
|
|
const buf = new Uint8Array([0x23, 0x86, 0x86, 0x86])
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('cp437')
|
|
})
|
|
|
|
it('detects UTF-8 multi-byte Swedish chars', () => {
|
|
// å in UTF-8 = C3 A5, ä = C3 A4
|
|
const buf = new Uint8Array([0x23, 0xc3, 0xa5, 0xc3, 0xa4])
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('utf8')
|
|
})
|
|
})
|
|
|
|
describe('decodeBuffer: Windows-1252', () => {
|
|
it('decodes Windows-1252 Swedish characters correctly', () => {
|
|
// "åäö" in Windows-1252 = [0xE5, 0xE4, 0xF6]
|
|
const buf = new Uint8Array([0xe5, 0xe4, 0xf6])
|
|
const result = decodeBuffer(buf.buffer, 'windows1252')
|
|
expect(result).toBe('åäö')
|
|
})
|
|
|
|
it('decodes Windows-1252 uppercase Swedish characters correctly', () => {
|
|
// "ÅÄÖ" in Windows-1252 = [0xC5, 0xC4, 0xD6]
|
|
const buf = new Uint8Array([0xc5, 0xc4, 0xd6])
|
|
const result = decodeBuffer(buf.buffer, 'windows1252')
|
|
expect(result).toBe('ÅÄÖ')
|
|
})
|
|
|
|
it('decodes CP437 Swedish characters correctly', () => {
|
|
// å in CP437 = 0x86, ä = 0x84, ö = 0x94
|
|
const buf = new Uint8Array([0x86, 0x84, 0x94])
|
|
const result = decodeBuffer(buf.buffer, 'cp437')
|
|
expect(result).toBe('åäö')
|
|
})
|
|
})
|
|
|
|
// --- Defensive encoding: handle files where the detector picks the wrong encoding ---
|
|
|
|
describe('detectEncoding: full-buffer scan', () => {
|
|
it('detects Win-1252 even when Swedish chars appear past the legacy 4KB sample boundary', () => {
|
|
// Build a buffer where the first 8000 bytes are pure ASCII header + filler,
|
|
// and the Swedish Win-1252 byte appears only at byte 8000+. The old
|
|
// implementation sampled the first 4000 bytes and would default to UTF-8.
|
|
const filler = new Uint8Array(8000).fill(0x20) // spaces
|
|
const tail = new Uint8Array([
|
|
0x46, 0x4f, 0x52, 0x45, 0x4e, 0x49, 0x4e, 0x47, // FORENING
|
|
0xd6, // Ö in Win-1252 (0xD6), invalid lone UTF-8 byte
|
|
])
|
|
const buf = new Uint8Array(filler.length + tail.length)
|
|
buf.set(filler, 0)
|
|
buf.set(tail, filler.length)
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('windows1252')
|
|
})
|
|
})
|
|
|
|
describe('decodeBuffer: fallback on U+FFFD', () => {
|
|
it('falls back from utf8 to windows1252 when the result has replacement characters', () => {
|
|
// "F" "Ö" "RENING" in Windows-1252: Ö is lone byte 0xD6, not valid UTF-8
|
|
const buf = new Uint8Array([0x46, 0xd6, 0x52, 0x45, 0x4e, 0x49, 0x4e, 0x47])
|
|
const result = decodeBuffer(buf.buffer, 'utf8')
|
|
expect(result).toBe('FÖRENING')
|
|
expect(result.includes('\uFFFD')).toBe(false)
|
|
})
|
|
|
|
it('falls back from utf8 to cp437 when both windows1252 also fails', () => {
|
|
// 0x94 is "ö" in CP437; in Win-1252 it's an unprintable "" but textually different;
|
|
// in UTF-8 it's invalid → U+FFFD. Verify CP437 path is reachable when chosen wrong.
|
|
const buf = new Uint8Array([0x66, 0x94, 0x72]) // f + ö-cp437 + r
|
|
const result = decodeBuffer(buf.buffer, 'utf8')
|
|
// Either windows1252 or cp437 fallback produces a non-FFFD result; both are
|
|
// acceptable here since the byte 0x94 is interpretable in both: what matters
|
|
// is no U+FFFD leaks through.
|
|
expect(result.includes('\uFFFD')).toBe(false)
|
|
})
|
|
|
|
it('returns primary decode unchanged when it contains no U+FFFD', () => {
|
|
const buf = new TextEncoder().encode('Företag').buffer
|
|
const result = decodeBuffer(buf, 'utf8')
|
|
expect(result).toBe('Företag')
|
|
})
|
|
})
|
|
|
|
// --- Fix 3: Invalid date rejection ---
|
|
|
|
describe('parseSIEFile: invalid date handling', () => {
|
|
it('rejects Feb 30 (auto-rolled dates) in #RAR', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20240230', // Feb 30 is invalid
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
// RAR with invalid end date should produce a warning and not add the fiscal year
|
|
expect(result.header.fiscalYears).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.message.includes('Invalid fiscal year dates'))).toBe(true)
|
|
})
|
|
|
|
it('rejects Apr 31 in #VER date', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#VER A 1 20240431 "Invalid date"', // Apr 31 is invalid
|
|
'{',
|
|
'#TRANS 1930 {} 1000.00',
|
|
'#TRANS 3001 {} -1000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
// Voucher should not be created because date is invalid
|
|
expect(result.vouchers).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'error' && i.message.includes('Ogiltig verifikationsdefinition'))).toBe(true)
|
|
})
|
|
|
|
it('accepts valid leap year date Feb 29', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Konto"',
|
|
'#KONTO 3001 "Konto"',
|
|
'#VER A 1 20240229 "Leap year"',
|
|
'{',
|
|
'#TRANS 1930 {} 1000.00',
|
|
'#TRANS 3001 {} -1000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
expect(result.vouchers[0].date).toEqual(new Date(2024, 1, 29))
|
|
})
|
|
|
|
it('rejects Feb 29 in non-leap year', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20230101 20231231',
|
|
'#KONTO 1930 "Konto"',
|
|
'#VER A 1 20230229 "Not a leap year"',
|
|
'{',
|
|
'#TRANS 1930 {} 1000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.vouchers).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.message.includes('Ogiltig verifikationsdefinition'))).toBe(true)
|
|
})
|
|
})
|
|
|
|
// --- Fix 4: Missing amount handling ---
|
|
|
|
describe('parseSIEFile: missing amount handling', () => {
|
|
it('skips #IB with missing amount and adds warning', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#IB 0 1510', // No amount field
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.openingBalances).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Belopp saknas i #IB'))).toBe(true)
|
|
})
|
|
|
|
it('skips #UB with missing amount and adds warning', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#UB 0 1510', // No amount field
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.closingBalances).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Belopp saknas i #UB'))).toBe(true)
|
|
})
|
|
|
|
it('skips #RES with missing amount and adds warning', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#RES 0 3001', // No amount field
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.resultBalances).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Belopp saknas i #RES'))).toBe(true)
|
|
})
|
|
|
|
it('skips #TRANS with missing amount and adds warning', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#VER A 1 20240115 "Test"',
|
|
'{',
|
|
'#TRANS 1930 {}', // No amount field
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
expect(result.vouchers[0].lines).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Belopp saknas i #TRANS'))).toBe(true)
|
|
})
|
|
|
|
it('still parses valid #IB lines alongside missing-amount ones', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#IB 0 1510', // Missing → skipped
|
|
'#IB 0 1930 100000.00', // Valid → kept
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.openingBalances).toHaveLength(1)
|
|
expect(result.openingBalances[0].account).toBe('1930')
|
|
expect(result.openingBalances[0].amount).toBe(100000)
|
|
})
|
|
})
|
|
|
|
// --- Fix B4: Account collection from transaction data ---
|
|
|
|
describe('parseSIEFile: account collection from transaction data', () => {
|
|
it('adds accounts from #TRANS that are missing from #KONTO', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
// 3001 is NOT defined in #KONTO but used in #TRANS
|
|
'#VER A 1 20240115 "Test"',
|
|
'{',
|
|
'#TRANS 1510 {} 10000.00',
|
|
'#TRANS 3001 {} -10000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
// Should have both 1510 (from #KONTO) and 3001 (from #TRANS)
|
|
expect(result.accounts.map((a) => a.number)).toContain('1510')
|
|
expect(result.accounts.map((a) => a.number)).toContain('3001')
|
|
// The auto-added account should have empty name
|
|
const added = result.accounts.find((a) => a.number === '3001')
|
|
expect(added?.name).toBe('')
|
|
})
|
|
|
|
it('adds accounts from #IB that are missing from #KONTO', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#IB 0 1510 50000.00',
|
|
'#IB 0 2440 -50000.00', // 2440 not in #KONTO
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.accounts.map((a) => a.number)).toContain('2440')
|
|
})
|
|
|
|
it('does not duplicate accounts already in #KONTO', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#VER A 1 20240115 "Test"',
|
|
'{',
|
|
'#TRANS 1510 {} 10000.00',
|
|
'#TRANS 3001 {} -10000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
const count1510 = result.accounts.filter((a) => a.number === '1510').length
|
|
expect(count1510).toBe(1)
|
|
})
|
|
|
|
it('adds accounts from #UB and #RES that are missing from #KONTO', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#UB 0 1930 100000.00', // 1930 not in #KONTO
|
|
'#RES 0 3001 -50000.00', // 3001 not in #KONTO
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.accounts.map((a) => a.number)).toContain('1930')
|
|
expect(result.accounts.map((a) => a.number)).toContain('3001')
|
|
})
|
|
})
|
|
|
|
describe('parseSIEFile: tab-separated fields (Bollbok export shape)', () => {
|
|
// Bollbok exports tab-separated SIE files, valid per the SIE 4 spec
|
|
// (separator may be space OR tab). Every record except #RAR uses tabs;
|
|
// #RAR uses spaces. Both 2025 (UTF-8, unquoted #KTYP value) and 2026
|
|
// (CP437, quoted #KTYP value) shapes are exercised here.
|
|
|
|
const BOLLBOK_TAB_2025_SHAPE = [
|
|
'#FLAGGA\t0',
|
|
'#PROGRAM\t"Bollbok"\t2078',
|
|
'#GEN\t20260512\t""',
|
|
'#SIETYP\t4',
|
|
'#ORGNR\t"950406-3679"',
|
|
'#FNAMN\t"Erik Hellqvist "',
|
|
'#RAR 0 20250101 20251231',
|
|
'#KPTYP\tEUBAS97',
|
|
'#KONTO\t1510\t"Kundfordringar"',
|
|
'#KTYP\t1510\tT',
|
|
'#KONTO\t1930\t"Företagskonto"',
|
|
'#KTYP\t1930\tT',
|
|
'#KONTO\t3001\t"Försäljning"',
|
|
'#KTYP\t3001\tI',
|
|
'#IB\t0\t1510\t50000.00',
|
|
'#IB\t0\t1930\t100000.00',
|
|
'#VER\t""\t"1"\t20250116\t"Kundbetalning"\t20260508',
|
|
'{',
|
|
'#TRANS\t1930\t{}\t12500.00',
|
|
'#TRANS\t1510\t{}\t-12500.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const BOLLBOK_TAB_2026_SHAPE = [
|
|
'#FLAGGA\t0',
|
|
'#PROGRAM\t"Bollbok"\t2078',
|
|
'#FORMAT\tPC8',
|
|
'#GEN\t20260514\t""',
|
|
'#SIETYP\t4',
|
|
'#ORGNR\t"950406-3679"',
|
|
'#FNAMN\t"Erik Hellqvist "',
|
|
'#RAR 0 20260101 20261231',
|
|
'#KPTYP\tEUBAS97',
|
|
'#KONTO\t1510\t"Kundfordringar"',
|
|
'#KTYP\t1510\t"T"',
|
|
'#KONTO\t1930\t"Företagskonto"',
|
|
'#KTYP\t1930\t"T"',
|
|
'#IB\t0\t1510\t75000.00',
|
|
'#IB\t0\t1930\t125000.00',
|
|
'#UB\t0\t1510\t75000.00',
|
|
'#UB\t0\t1930\t125000.00',
|
|
].join('\n')
|
|
|
|
it('parses tab-separated #IB into openingBalances (2025 shape)', () => {
|
|
const result = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
|
|
expect(result.openingBalances).toHaveLength(2)
|
|
expect(result.openingBalances).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ account: '1510', amount: 50000 }),
|
|
expect.objectContaining({ account: '1930', amount: 100000 }),
|
|
])
|
|
)
|
|
})
|
|
|
|
it('parses tab-separated #KONTO into accounts (2025 shape)', () => {
|
|
const result = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
|
|
expect(result.accounts.map((a) => a.number)).toEqual(
|
|
expect.arrayContaining(['1510', '1930', '3001'])
|
|
)
|
|
const kund = result.accounts.find((a) => a.number === '1510')
|
|
expect(kund?.name).toBe('Kundfordringar')
|
|
})
|
|
|
|
it('parses tab-separated #VER + #TRANS block (2025 shape)', () => {
|
|
const result = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
const v = result.vouchers[0]
|
|
expect(v.lines).toHaveLength(2)
|
|
expect(v.lines[0]).toMatchObject({ account: '1930', amount: 12500 })
|
|
expect(v.lines[1]).toMatchObject({ account: '1510', amount: -12500 })
|
|
// Verification balances to zero
|
|
expect(v.lines.reduce((sum, l) => sum + l.amount, 0)).toBe(0)
|
|
})
|
|
|
|
it('parses space-separated #RAR even when other records use tabs', () => {
|
|
const result = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
|
|
expect(result.stats.fiscalYearStart).toBe('2025-01-01')
|
|
expect(result.stats.fiscalYearEnd).toBe('2025-12-31')
|
|
})
|
|
|
|
it('accepts both unquoted (2025) and quoted (2026) #KTYP values and stores them without surrounding quotes', () => {
|
|
const result2025 = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
|
|
const result2026 = parseSIEFile(BOLLBOK_TAB_2026_SHAPE)
|
|
// Both shapes parse the chart of accounts without complaint
|
|
expect(result2025.accounts.length).toBeGreaterThan(0)
|
|
expect(result2026.accounts.length).toBeGreaterThan(0)
|
|
// accountType should be the bare letter, never the quoted form
|
|
const acc2025 = result2025.accounts.find((a) => a.number === '1510')
|
|
const acc2026 = result2026.accounts.find((a) => a.number === '1510')
|
|
expect(acc2025?.accountType).toBe('T')
|
|
expect(acc2026?.accountType).toBe('T')
|
|
})
|
|
|
|
it('parses tab-separated opening-only file (2026 shape): IB + UB but no vouchers', () => {
|
|
const result = parseSIEFile(BOLLBOK_TAB_2026_SHAPE)
|
|
expect(result.openingBalances).toHaveLength(2)
|
|
expect(result.closingBalances).toHaveLength(2)
|
|
expect(result.vouchers).toHaveLength(0)
|
|
expect(result.stats.fiscalYearStart).toBe('2026-01-01')
|
|
})
|
|
|
|
it('preserves interior tabs inside quoted field values', () => {
|
|
const content = [
|
|
'#FLAGGA\t0',
|
|
'#SIETYP\t4',
|
|
'#FNAMN\t"Has\ttab inside"',
|
|
'#RAR 0 20240101 20241231',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
// The header name should preserve the embedded tab character
|
|
expect(result.header.companyName).toContain('\t')
|
|
expect(result.header.companyName).toBe('Has\ttab inside')
|
|
})
|
|
|
|
it('treats consecutive separator runs (mixed space+tab) as a single separator', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "T"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO\t \t1510\t"Kund"',
|
|
'#IB \t 0\t \t1510 \t50000.00',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
expect(result.accounts.map((a) => a.number)).toContain('1510')
|
|
expect(result.openingBalances[0]).toMatchObject({ account: '1510', amount: 50000 })
|
|
})
|
|
})
|
|
|
|
describe('parseSIEFile: silent-failure diagnostic warnings', () => {
|
|
it('emits a warning when raw #IB lines exist but none could be parsed', () => {
|
|
// Construct a malformed file where #IB lines are present but unparseable.
|
|
// We do this by referencing #IB records with an explicitly empty account
|
|
// field so parsing succeeds tokenization but rejects the record.
|
|
// Simpler approach: rely on a malformed encoding-like situation by
|
|
// providing #IB lines whose account field is whitespace-only.
|
|
//
|
|
// Instead, prove the diagnostic fires by parsing real-world malformed
|
|
// input: lines that look like #IB but are followed by no useful fields.
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "T"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#IB', // Bare #IB with no fields: won't parse
|
|
'#IB',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
expect(result.openingBalances).toHaveLength(0)
|
|
// Per-line warnings ("Belopp saknas i #IB") share severity+tag with the
|
|
// aggregate diagnostic, so match on the diagnostic message specifically.
|
|
const aggregateWarning = result.issues.find(
|
|
(i) => i.severity === 'warning' && i.tag === 'IB' && i.message.includes('#IB-rader hittades')
|
|
)
|
|
expect(aggregateWarning).toBeTruthy()
|
|
expect(aggregateWarning?.message).toContain('2 #IB-rader')
|
|
expect(aggregateWarning?.message).toContain('fältavskiljare och teckenkodning')
|
|
})
|
|
|
|
it('emits a warning when raw #VER lines exist but no voucher was committed', () => {
|
|
// #VER lines parse the header fine (no per-record error) but the surrounding
|
|
// { } block is missing, so currentVoucher is never pushed onto vouchers.
|
|
// This is the "silent loss" case the aggregate diagnostic is designed for.
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "T"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#VER A 1 20240115 "Test1"',
|
|
'#VER A 2 20240116 "Test2"',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
expect(result.vouchers).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'error' && i.tag === 'VER')).toBe(false)
|
|
const verWarning = result.issues.find(
|
|
(i) => i.severity === 'warning' && i.tag === 'VER' && i.message.includes('#VER-rader hittades')
|
|
)
|
|
expect(verWarning).toBeTruthy()
|
|
expect(verWarning?.message).toContain('2 #VER-rader')
|
|
})
|
|
|
|
it('suppresses the aggregate VER warning when a per-record VER error already exists', () => {
|
|
// Bare #VER lines (no fields) emit per-record 'error'-severity issues with
|
|
// tag='VER'. In that case the aggregate "check separator/encoding" hint is
|
|
// misleading: the parser already pinpointed the real problem: so we
|
|
// suppress it.
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "T"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#VER',
|
|
'#VER',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
expect(result.vouchers).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'error' && i.tag === 'VER')).toBe(true)
|
|
const aggregateVerWarning = result.issues.find(
|
|
(i) => i.severity === 'warning' && i.tag === 'VER' && i.message.includes('#VER-rader hittades')
|
|
)
|
|
expect(aggregateVerWarning).toBeUndefined()
|
|
})
|
|
|
|
it('does NOT emit IB/VER warnings on a normal file with parsed records', () => {
|
|
const result = parseSIEFile(SIE_WITH_BALANCES)
|
|
const spurious = result.issues.filter(
|
|
(i) => i.severity === 'warning' && (i.tag === 'IB' || i.tag === 'VER')
|
|
)
|
|
expect(spurious).toHaveLength(0)
|
|
})
|
|
|
|
it('does NOT emit warnings on a legitimately empty current-year file (no #IB lines)', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Just opened"',
|
|
'#RAR 0 20260101 20261231',
|
|
'#KONTO 1930 "Bank"',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
const spurious = result.issues.filter(
|
|
(i) => i.severity === 'warning' && (i.tag === 'IB' || i.tag === 'VER')
|
|
)
|
|
expect(spurious).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('getEffectiveOpeningBalances: derive IB from #UB -1 (issue #675)', () => {
|
|
// Issue #675 (ro66an): some systems export no #IB 0 records: the current
|
|
// year's IB exists only via the continuity invariant IB(0) = UB(-1).
|
|
const SIE_NO_IB0 = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Continuity AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#RAR -1 20230101 20231231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 2010 "Eget kapital"',
|
|
'#IB -1 1930 9483.08',
|
|
'#UB 0 1930 160406.00',
|
|
'#UB -1 1930 37400.78',
|
|
'#UB -1 2010 -37400.78',
|
|
].join('\n')
|
|
|
|
it('derives current-year IB from #UB -1 when no #IB 0 exists (issue example)', () => {
|
|
const parsed = parseSIEFile(SIE_NO_IB0)
|
|
const { balances, derivedFromPriorYearUB } = getEffectiveOpeningBalances(parsed)
|
|
|
|
expect(derivedFromPriorYearUB).toBe(true)
|
|
expect(balances).toEqual([
|
|
{ yearIndex: 0, account: '1930', amount: 37400.78 },
|
|
{ yearIndex: 0, account: '2010', amount: -37400.78 },
|
|
])
|
|
})
|
|
|
|
it('never uses #IB -1 (previous year IB) as the derivation source', () => {
|
|
const parsed = parseSIEFile(SIE_NO_IB0)
|
|
const { balances } = getEffectiveOpeningBalances(parsed)
|
|
|
|
expect(balances.some((b) => b.amount === 9483.08)).toBe(false)
|
|
})
|
|
|
|
it('returns explicit #IB 0 untouched when present: #UB -1 is never merged in', () => {
|
|
const content = [
|
|
SIE_NO_IB0,
|
|
'#IB 0 1930 37400.78',
|
|
'#IB 0 2010 -37400.78',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
const { balances, derivedFromPriorYearUB } = getEffectiveOpeningBalances(parsed)
|
|
|
|
expect(derivedFromPriorYearUB).toBe(false)
|
|
expect(balances).toHaveLength(2)
|
|
expect(balances.every((b) => b.yearIndex === 0)).toBe(true)
|
|
})
|
|
|
|
it('yields to an opening-balance voucher candidate: no derivation (precedence 2 beats 3)', () => {
|
|
// The voucher serves as IB during import (tagged source_type
|
|
// 'opening_balance'); deriving from #UB -1 as well would double-count.
|
|
// Also the timezone regression test: the voucher date is a local-time
|
|
// Date, so a toISOString()-based comparison would miss the FY start on
|
|
// machines west or east of UTC and wrongly re-enable derivation.
|
|
const content = [
|
|
SIE_NO_IB0,
|
|
'#VER A 1 20240101 "Ingående balans"',
|
|
'{',
|
|
'#TRANS 1930 {} 37400.78',
|
|
'#TRANS 2010 {} -37400.78',
|
|
'}',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
|
|
expect(hasOpeningBalanceVoucherCandidate(parsed)).toBe(true)
|
|
|
|
const { balances, derivedFromPriorYearUB } = getEffectiveOpeningBalances(parsed)
|
|
expect(derivedFromPriorYearUB).toBe(false)
|
|
expect(balances).toEqual([])
|
|
})
|
|
|
|
it('does not treat a share-capital voucher on FY start as an OB candidate', () => {
|
|
const content = [
|
|
SIE_NO_IB0,
|
|
'#VER A 1 20240101 "Insättning aktiekapital ingående balans"',
|
|
'{',
|
|
'#TRANS 1930 {} 25000.00',
|
|
'#TRANS 2081 {} -25000.00',
|
|
'}',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
|
|
expect(hasOpeningBalanceVoucherCandidate(parsed)).toBe(false)
|
|
expect(getEffectiveOpeningBalances(parsed).derivedFromPriorYearUB).toBe(true)
|
|
})
|
|
|
|
it('does not treat a voucher with P&L lines as an OB candidate', () => {
|
|
const content = [
|
|
SIE_NO_IB0,
|
|
'#VER A 1 20240101 "Ingående balans"',
|
|
'{',
|
|
'#TRANS 1930 {} 1000.00',
|
|
'#TRANS 3001 {} -1000.00',
|
|
'}',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
|
|
expect(hasOpeningBalanceVoucherCandidate(parsed)).toBe(false)
|
|
expect(getEffectiveOpeningBalances(parsed).derivedFromPriorYearUB).toBe(true)
|
|
})
|
|
|
|
it('does not treat an IB-worded voucher on another date as an OB candidate', () => {
|
|
const content = [
|
|
SIE_NO_IB0,
|
|
'#VER A 1 20240315 "Ingående balans"',
|
|
'{',
|
|
'#TRANS 1930 {} 1000.00',
|
|
'#TRANS 2010 {} -1000.00',
|
|
'}',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
|
|
expect(hasOpeningBalanceVoucherCandidate(parsed)).toBe(false)
|
|
expect(getEffectiveOpeningBalances(parsed).derivedFromPriorYearUB).toBe(true)
|
|
})
|
|
|
|
it('filters P&L accounts out of the derived set (result accounts open at zero)', () => {
|
|
const content = [
|
|
SIE_NO_IB0,
|
|
'#UB -1 3001 -5000.00',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
const { balances } = getEffectiveOpeningBalances(parsed)
|
|
|
|
expect(balances.some((b) => b.account === '3001')).toBe(false)
|
|
expect(balances).toHaveLength(2)
|
|
})
|
|
|
|
it('returns nothing when neither #IB 0 nor #UB -1 exists', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "First Year AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#IB -1 1930 9483.08',
|
|
'#UB 0 1930 160406.00',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
const { balances, derivedFromPriorYearUB } = getEffectiveOpeningBalances(parsed)
|
|
|
|
expect(derivedFromPriorYearUB).toBe(false)
|
|
expect(balances).toEqual([])
|
|
})
|
|
|
|
it('carries quantity along on derived balances', () => {
|
|
const content = [
|
|
SIE_NO_IB0.replace('#UB -1 1930 37400.78', '#UB -1 1930 37400.78 5'),
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
const { balances } = getEffectiveOpeningBalances(parsed)
|
|
|
|
expect(balances.find((b) => b.account === '1930')?.quantity).toBe(5)
|
|
})
|
|
|
|
describe('validateSIEFile with derived IB', () => {
|
|
it('warns that IB will be derived from #UB -1', () => {
|
|
const parsed = parseSIEFile(SIE_NO_IB0)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.valid).toBe(true)
|
|
expect(validation.warnings.join(' ')).toMatch(/härleds från föregående års utgående balans/i)
|
|
})
|
|
|
|
it('runs the imbalance check on the derived set (unallocated prior-year result)', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Obalans AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
// Derived IB sums to +37400.78: prior-year result never allocated
|
|
'#UB -1 1930 37400.78',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.warnings.join(' ')).toMatch(/balanserar inte/i)
|
|
expect(validation.warnings.join(' ')).toMatch(/37400\.78/)
|
|
})
|
|
|
|
it('does not warn about derivation when explicit #IB 0 exists', () => {
|
|
const content = [SIE_NO_IB0, '#IB 0 1930 37400.78', '#IB 0 2010 -37400.78'].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.warnings.join(' ')).not.toMatch(/härleds från föregående års utgående balans/i)
|
|
})
|
|
})
|
|
})
|