From 23664e79cb626fad721b594b801d2d41f85ba368 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:40:15 +0200 Subject: [PATCH] feat: multi-series SIE import, reusable FiscalYearSelector, library templates in picker (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: multi-series SIE import, reusable FiscalYearSelector, library templates in picker - SIE import preserves each voucher's source series (B/C/I/V/...), essential for Fortnox migrations where series carry semantic meaning (kundfakturor, inbetalningar, etc.). Target numbering still goes through next_voucher_number per series; source (series, number) is stored in the migration mapping for BFNAR 2013:2 audit trail. - Execute route reads company_settings.default_voucher_series as the fallback for vouchers arriving without a series (SIE4I). - Extract shared FiscalYearSelector component; adopt in /reports and /bookkeeping. - Transaction TemplatePicker now surfaces user-created library templates (company + team scope) alongside the static registry, with a helper to convert simple library templates into the BookingTemplate shape. - Exclude 8999 "Årets resultat" from income statement financial section and monthly breakdown so year-end closing entries don't cancel the net result. Co-Authored-By: Claude Opus 4.7 (1M context) * test: skip Bokio SIE regression when fixtures are absent /dev_docs is gitignored (contains anonymised customer exports), so the integration test can't find its input files in CI. Gate the suite on fixture presence so it still runs locally. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: address Greptile review feedback - convertLibraryToBookingTemplate: default entity_applicability to 'all' when the source template has no entity_type, so TemplatePicker doesn't silently hide it for companies with a set entity type. - FiscalYearSelector: fire onReady in the no-company early-return branch so consumers (e.g. ReportsPage) don't get stuck in a loading skeleton while the company context is still hydrating. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/(dashboard)/bookkeeping/page.tsx | 11 +- app/(dashboard)/reports/page.tsx | 75 ++--- app/(dashboard)/transactions/page.tsx | 23 +- app/api/import/sie/execute/route.ts | 18 +- components/common/FiscalYearSelector.tsx | 191 +++++++++++++ .../transactions/SwipeCategorizationView.tsx | 15 +- components/transactions/TemplatePicker.tsx | 73 ++++- lib/bookkeeping/template-library.ts | 91 +++++- lib/import/__tests__/sie-import.test.ts | 268 +++++++++++++++++- lib/import/sie-import.ts | 175 ++++++++---- lib/import/types.ts | 12 +- ...income-statement-bokio-integration.test.ts | 169 +++++++++++ .../__tests__/income-statement.test.ts | 55 ++++ lib/reports/income-statement.ts | 11 +- lib/reports/monthly-breakdown.ts | 5 +- 15 files changed, 1061 insertions(+), 131 deletions(-) create mode 100644 components/common/FiscalYearSelector.tsx create mode 100644 lib/reports/__tests__/income-statement-bokio-integration.test.ts diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index baa9f30c..2148540b 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -7,10 +7,13 @@ import { Button } from '@/components/ui/button' import JournalEntryList from '@/components/bookkeeping/JournalEntryList' import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm' import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager' +import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' import { Lock } from 'lucide-react' export default function BookkeepingPage() { const [refreshKey, setRefreshKey] = useState(0) + const [activeTab, setActiveTab] = useState('journal') + const [periodId, setPeriodId] = useState(null) return (
@@ -29,7 +32,11 @@ export default function BookkeepingPage() {
- + {activeTab === 'journal' && ( + + )} + + Verifikationer Ny verifikation @@ -37,7 +44,7 @@ export default function BookkeepingPage() { - + diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 77bf3cb9..c37c5afc 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -10,6 +10,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Download, AlertCircle, ChevronDown, ChevronRight, ArrowRight } from 'lucide-react' import { AccountNumber } from '@/components/ui/account-number' import { useCompany } from '@/contexts/CompanyContext' +import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' import { NEDeclarationView } from '@/components/reports/NEDeclarationView' import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView' import { BankReconciliationView } from '@/components/reports/BankReconciliationView' @@ -19,7 +20,6 @@ import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel' import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart' import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart' import type { - FiscalPeriod, TrialBalanceRow, IncomeStatementReport, BalanceSheetReport, @@ -46,7 +46,6 @@ const TAB_LABELS: Record = { } export default function ReportsPage() { - const [periods, setPeriods] = useState([]) const [selectedPeriod, setSelectedPeriod] = useState('') const [activeTab, setActiveTab] = useState('trial-balance') const [isLoadingInit, setIsLoadingInit] = useState(true) @@ -79,22 +78,8 @@ export default function ReportsPage() { setDrillDownTrail(drillDownTrail.slice(0, stepIndex)) }, [drillDownTrail]) - async function fetchPeriods() { - const res = await fetch('/api/bookkeeping/fiscal-periods') - const { data } = await res.json() - const today = new Date().toISOString().split('T')[0] - const activePeriods = (data || []).filter((p: FiscalPeriod) => p.period_start <= today) - setPeriods(activePeriods) - if (activePeriods.length > 0) { - setSelectedPeriod(activePeriods[0].id) - } - } - - useEffect(() => { - fetchPeriods().finally(() => { - setIsLoadingInit(false) - }) - }, []) + // Period list is loaded by FiscalYearSelector; isLoadingInit flips to false + // via its onReady callback once the initial fetch completes. const isEnskildFirma = company?.entity_type === 'enskild_firma' const isAktiebolag = company?.entity_type === 'aktiebolag' @@ -110,14 +95,29 @@ export default function ReportsPage() { +
+ setSelectedPeriod(id || '')} + includeAllOption={false} + hideFuturePeriods + onReady={() => setIsLoadingInit(false)} + /> + {selectedPeriod && ( + + )} +
+ {isLoadingInit ? (
-
-
-
-
-
-
{[1, 2, 3, 4].map((i) => (
@@ -139,33 +139,6 @@ export default function ReportsPage() {
) : ( <> -
-
- - -
- {selectedPeriod && ( - - )} -
{selectedPeriod ? ( <> diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index d7453e0c..40dd9a4d 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -27,6 +27,7 @@ import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates' import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates' +import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library' import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' import { useCompany } from '@/contexts/CompanyContext' import { formatCurrency, formatDate } from '@/lib/utils' @@ -681,7 +682,10 @@ export default function TransactionsPage() { setTemplatePickerOpen(false) const tx = templatePickerTransaction if (!tx) return - setQuickReview({ transaction: tx, category: template.fallback_category, label: template.name_sv, template, templateId: template.id, linePattern: null }) + // Library templates aren't validated server-side via template_id; the + // template's debit/credit + VAT drive the booking through account_override. + const templateId = isLibraryTemplateId(template.id) ? undefined : template.id + setQuickReview({ transaction: tx, category: template.fallback_category, label: template.name_sv, template, templateId, linePattern: null }) setQuickReviewOpen(true) } @@ -942,14 +946,25 @@ export default function TransactionsPage() { void + /** + * If true, include an "Alla räkenskapsår" option that clears the filter. + * Pages that require a specific period (e.g. Reports) should pass false. + */ + includeAllOption?: boolean + /** + * Optional label above the select. Pass null to render without a label. + */ + label?: string | null + /** + * If true, only show periods whose start date is on or before today. + * Matches the Reports-page filter. + */ + hideFuturePeriods?: boolean + /** + * Called once after the initial period fetch completes. Useful for callers + * that want to suppress a skeleton until the selector is ready. + */ + onReady?: () => void + className?: string +} + +/** + * Shared fiscal-year (räkenskapsår) selector. + * + * Loads periods for the active company, persists the last selection per + * company in localStorage, and renders the same Select used elsewhere in the + * app so the UX is consistent across Bookkeeping, Reports, etc. + * + * The component is controlled: the caller owns the selected period id and + * threads it into whichever queries need scoping. + */ +export function FiscalYearSelector({ + value, + onChange, + includeAllOption = true, + label = 'Räkenskapsår', + hideFuturePeriods = false, + onReady, + className, +}: Props) { + const { company } = useCompany() + const [periods, setPeriods] = useState([]) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + if (!company?.id) { + // Fire onReady so consumers don't stall in a loading state while the + // company context hydrates. The effect re-runs once company.id arrives. + onReady?.() + return + } + let cancelled = false + ;(async () => { + const res = await fetch('/api/bookkeeping/fiscal-periods') + if (!res.ok) { + if (!cancelled) { + setLoaded(true) + onReady?.() + } + return + } + const { data } = await res.json() + if (cancelled) return + + let fetched: FiscalPeriod[] = data || [] + if (hideFuturePeriods) { + const today = new Date().toISOString().split('T')[0] + fetched = fetched.filter((p) => p.period_start <= today) + } + // Newest first — most migrations list recent years at the top + fetched.sort((a, b) => b.period_start.localeCompare(a.period_start)) + setPeriods(fetched) + setLoaded(true) + + // Restore last selection (only if caller hasn't already set a value). + // localStorage access is guarded because this is a 'use client' component + // but still runs during SSR on first render for some setups. + if (value === null && typeof window !== 'undefined') { + const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id) + if (stored === ALL_YEARS_VALUE) { + if (includeAllOption) onChange(null) + else if (fetched.length > 0) onChange(fetched[0].id) + } else if (stored && fetched.some((p) => p.id === stored)) { + onChange(stored) + } else if (!includeAllOption && fetched.length > 0) { + onChange(fetched[0].id) + } + } + + onReady?.() + })() + return () => { + cancelled = true + } + // onReady is intentionally excluded from deps: it's a lifecycle callback that + // should fire once per load, not re-trigger if the parent re-creates it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [company?.id, hideFuturePeriods, includeAllOption]) + + const handleChange = (next: string) => { + const nextPeriodId = next === ALL_YEARS_VALUE ? null : next + if (company?.id && typeof window !== 'undefined') { + window.localStorage.setItem( + STORAGE_KEY_PREFIX + company.id, + nextPeriodId ?? ALL_YEARS_VALUE, + ) + } + onChange(nextPeriodId) + } + + const selectValue = value ?? (includeAllOption ? ALL_YEARS_VALUE : '') + + // Surface lock status for the currently-selected period. Browsing locked + // years is read-only and allowed (BFL 7:1 requires access to historical + // data), but the user should see clearly that they're looking at a + // closed/locked year so the absence of write controls feels intentional. + const selectedPeriod = value ? periods.find((p) => p.id === value) : null + const lockState: 'locked' | 'closed' | null = selectedPeriod?.locked_at + ? 'locked' + : selectedPeriod?.is_closed + ? 'closed' + : null + + return ( +
+ {label && } +
+ + {lockState && ( + + + {lockState === 'locked' ? 'Låst' : 'Stängt'} + + )} +
+
+ ) +} diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx index 6496db37..2c705b45 100644 --- a/components/transactions/SwipeCategorizationView.tsx +++ b/components/transactions/SwipeCategorizationView.tsx @@ -11,6 +11,7 @@ import { formatCurrency, formatDate } from '@/lib/utils' import { checkExpenseWarnings } from '@/lib/tax/expense-warnings' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates' +import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library' import TemplatePicker from './TemplatePicker' import JournalEntryPreview from './JournalEntryPreview' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' @@ -62,6 +63,7 @@ export default function SwipeCategorizationView({ const [showVatDropdown, setShowVatDropdown] = useState(false) const [pendingTemplateId, setPendingTemplateId] = useState(null) + const [pendingTemplate, setPendingTemplate] = useState(null) const [pendingInboxItemId, setPendingInboxItemId] = useState(null) // Clear VAT treatment when switching to a liability/equity account (class 2) @@ -125,6 +127,7 @@ export default function SwipeCategorizationView({ setAccountOverride(defaultAccount) setVatTreatment(defaultVat ?? 'none') setPendingTemplateId(null) + setPendingTemplate(null) setPendingInboxItemId(null) setShowVatDropdown(false) setShowCategorySelect(false) @@ -136,7 +139,11 @@ export default function SwipeCategorizationView({ setPendingCategory(template.fallback_category) setAccountOverride(template.debit_account) setVatTreatment(template.vat_treatment ?? 'none') - setPendingTemplateId(template.id) + // Library templates aren't in the static registry the backend validates against, + // so we only send the ID for static templates. The pre-filled account/VAT drive + // the booking for library templates. + setPendingTemplateId(isLibraryTemplateId(template.id) ? null : template.id) + setPendingTemplate(template) setPendingInboxItemId(null) setShowVatDropdown(false) setShowCategorySelect(false) @@ -152,6 +159,7 @@ export default function SwipeCategorizationView({ setAccountOverride(template.debit_account) setVatTreatment(template.vat_treatment ?? 'none') setPendingTemplateId(templateId) + setPendingTemplate(template) setPendingInboxItemId(inboxItemId ?? null) setShowVatDropdown(false) setShowCategorySelect(false) @@ -246,6 +254,7 @@ export default function SwipeCategorizationView({ setShowReviewStep(false) setPendingCategory(null) setPendingTemplateId(null) + setPendingTemplate(null) setPendingInboxItemId(null) moveToNext() } else { @@ -345,7 +354,7 @@ export default function SwipeCategorizationView({ entityType={entityType} suggestedTemplates={txSuggestions} onSelect={handlePickerTemplateSelect} - selectedTemplateId={pendingTemplateId ?? undefined} + selectedTemplateId={pendingTemplate?.id ?? pendingTemplateId ?? undefined} />
@@ -367,7 +376,7 @@ export default function SwipeCategorizationView({ const categoryLabel = [...expenseCategories, ...incomeCategories].find( (c) => c.value === pendingCategory )?.label || pendingCategory - const selectedTemplate = pendingTemplateId ? getTemplateById(pendingTemplateId) : null + const selectedTemplate = pendingTemplate // Auto-clear VAT when a class 2 (liability/equity) account is selected const isLiabilityAccount = accountOverride.startsWith('2') diff --git a/components/transactions/TemplatePicker.tsx b/components/transactions/TemplatePicker.tsx index f63d103d..2d4c8688 100644 --- a/components/transactions/TemplatePicker.tsx +++ b/components/transactions/TemplatePicker.tsx @@ -1,10 +1,10 @@ 'use client' -import { useState, useMemo } from 'react' +import { useState, useMemo, useEffect } from 'react' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Search, ChevronDown, ChevronUp, AlertTriangle, Info } from 'lucide-react' +import { Search, ChevronDown, ChevronUp, AlertTriangle, Info, Building2 } from 'lucide-react' import { getCommonTemplates, getAdvancedTemplates, @@ -14,8 +14,9 @@ import { } from '@/lib/bookkeeping/booking-templates' import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' +import { convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library' import { getAccountName } from '@/lib/bookkeeping/client-account-names' -import type { EntityType } from '@/types' +import type { BookingTemplateLibrary, EntityType } from '@/types' import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions' const GROUP_ORDER: TemplateGroup[] = [ @@ -150,10 +151,34 @@ export default function TemplatePicker({ }: TemplatePickerProps) { const [searchQuery, setSearchQuery] = useState('') const [showAdvanced, setShowAdvanced] = useState(false) + const [libraryTemplates, setLibraryTemplates] = useState([]) // Map direction to template direction filter (transfers show in both) const templateDirection = direction === 'income' ? 'income' : 'expense' + // Fetch user-created booking templates (company + team scope) and map + // convertible ones into BookingTemplate shape. System templates are + // already covered by the static list below, so we exclude them here. + useEffect(() => { + const controller = new AbortController() + ;(async () => { + try { + const res = await fetch('/api/settings/booking-templates', { signal: controller.signal }) + if (!res.ok) return + const { data } = await res.json() as { data?: BookingTemplateLibrary[] } + if (!data) return + const mapped = data + .filter((t) => !t.is_system && t.is_active) + .map(convertLibraryToBookingTemplate) + .filter((t): t is BookingTemplate => t !== null) + setLibraryTemplates(mapped) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + } + })() + return () => { controller.abort() } + }, []) + const commonTemplates = useMemo( () => getCommonTemplates(entityType, templateDirection), [entityType, templateDirection] @@ -183,14 +208,29 @@ export default function TemplatePicker({ [advancedTemplates, advancedTransfers] ) - // Search results + // User-created library templates filtered by direction + entity + const relevantLibraryTemplates = useMemo(() => { + return libraryTemplates.filter((t) => { + if (entityType && t.entity_applicability !== 'all' && t.entity_applicability !== entityType) { + return false + } + return t.direction === templateDirection || t.direction === 'transfer' + }) + }, [libraryTemplates, entityType, templateDirection]) + + // Search results (static + library) const searchResults = useMemo(() => { if (!searchQuery.trim()) return null - return searchTemplates(searchQuery, entityType).filter((t) => { + const q = searchQuery.toLowerCase() + const libraryMatches = relevantLibraryTemplates.filter((t) => + t.name_sv.toLowerCase().includes(q) || t.description_sv.toLowerCase().includes(q) + ) + const staticMatches = searchTemplates(searchQuery, entityType).filter((t) => { if (t.direction === templateDirection || t.direction === 'transfer') return true return false }) - }, [searchQuery, entityType, templateDirection]) + return [...libraryMatches, ...staticMatches] + }, [searchQuery, entityType, templateDirection, relevantLibraryTemplates]) // Group templates by group for display const commonGrouped = useMemo(() => groupTemplates(allCommon), [allCommon]) @@ -246,6 +286,27 @@ export default function TemplatePicker({
) : ( <> + {/* User-created library templates (company + team scope) */} + {relevantLibraryTemplates.length > 0 && ( +
+

+ + Mina mallar +

+
+ {relevantLibraryTemplates.map((t) => ( + handleSelect(t)} + compact + /> + ))} +
+
+ )} + {/* Counterparty templates — learned from history */} {hasCounterparty && (
diff --git a/lib/bookkeeping/template-library.ts b/lib/bookkeeping/template-library.ts index 0ab16233..ec85ae91 100644 --- a/lib/bookkeeping/template-library.ts +++ b/lib/bookkeeping/template-library.ts @@ -1,6 +1,14 @@ -import type { BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types' +import type { BookingTemplateCategory, BookingTemplateLibrary, BookingTemplateLibraryLine, VatTreatment } from '@/types' +import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' +/** + * Prefix for library template ids when they are mapped into the + * static BookingTemplate shape used by the transaction picker. + */ +export const LIBRARY_TEMPLATE_PREFIX = 'library:' +export function isLibraryTemplateId(id: string): boolean { return id.startsWith(LIBRARY_TEMPLATE_PREFIX) } + /** * Category labels in Swedish for UI display. */ @@ -76,3 +84,84 @@ export const SCOPE_LABELS: Record, string> = team: 'Team', company: 'Företag', } + +function vatRateToTreatment(rate: number): VatTreatment | null { + if (rate === 0.25) return 'standard_25' + if (rate === 0.12) return 'reduced_12' + if (rate === 0.06) return 'reduced_6' + return null +} + +/** + * Convert a user-created library template to the BookingTemplate shape the + * transaction TemplatePicker consumes. + * + * Only simple shapes (one business line + one settlement line, optionally + * one VAT line) are returned — complex multi-account templates cannot be + * expressed as a single debit/credit pair and must be applied via the full + * journal entry form instead. + * + * The id is prefixed with "library:" so downstream code can recognise a + * library template and look it up through the library APIs rather than the + * static registry. + */ +export function convertLibraryToBookingTemplate( + lib: BookingTemplateLibrary, +): BookingTemplate | null { + if (!Array.isArray(lib.lines)) return null + + const business = lib.lines.filter((l) => l.type === 'business') + const settlement = lib.lines.filter((l) => l.type === 'settlement') + const vat = lib.lines.filter((l) => l.type === 'vat') + + if (business.length !== 1 || settlement.length !== 1) return null + if (business[0].side === settlement[0].side) return null + + const debitLine = business[0].side === 'debit' ? business[0] : settlement[0] + const creditLine = business[0].side === 'credit' ? business[0] : settlement[0] + + const direction: 'expense' | 'income' = business[0].side === 'debit' ? 'expense' : 'income' + + let vatTreatment: VatTreatment | null = null + let vatRate = 0 + if (vat.length > 0) { + const inputVat = vat.find((v) => v.side === 'debit' && v.vat_rate) + ?? vat.find((v) => v.vat_rate) + if (inputVat?.vat_rate) { + const treatment = vatRateToTreatment(inputVat.vat_rate) + if (treatment) { + vatTreatment = treatment + vatRate = inputVat.vat_rate + } + } + // Reverse-charge is recognised by the presence of 2614/2624/2634 (fictitious output VAT) + if (vat.some((v) => v.account === '2614' || v.account === '2624' || v.account === '2634')) { + vatTreatment = 'reverse_charge' + } + } + + return { + id: `${LIBRARY_TEMPLATE_PREFIX}${lib.id}`, + name_sv: lib.name, + name_en: lib.name, + group: 'financial', + direction, + entity_applicability: lib.entity_type ?? 'all', + debit_account: debitLine.account, + credit_account: creditLine.account, + vat_treatment: vatTreatment, + vat_rate: vatRate, + deductibility: 'full', + special_rules_sv: lib.description || undefined, + mcc_codes: [], + keywords: [], + risk_level: 'NONE', + requires_review: false, + impact_score: 0, + auto_match_confidence: 0, + default_private: false, + fallback_category: direction === 'expense' ? 'expense_other' : 'income_services', + description_sv: lib.description || '', + common: false, + } +} diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index 352d87a7..af907c39 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -1,12 +1,15 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { generateImportPreview, validateIBBalance, isBalanceSheetAccount, ensureFiscalPeriod, + importVouchers, + computeVoucherNumberRanges, } from '../sie-import' import { createQueuedMockSupabase } from '@/tests/helpers' import type { ParsedSIEFile, AccountMapping } from '../types' +import type { SupabaseClient } from '@supabase/supabase-js' // --- Helpers --- @@ -442,3 +445,266 @@ describe('isBalanceSheetAccount', () => { expect(isBalanceSheetAccount('8999')).toBe(false) }) }) + +describe('computeVoucherNumberRanges', () => { + it('returns empty array for no mapping', () => { + expect(computeVoucherNumberRanges([])).toEqual([]) + }) + + it('produces one range per series with correct from/to', () => { + const ranges = computeVoucherNumberRanges([ + { sourceId: 'B1', series: 'B', targetNumber: 1 }, + { sourceId: 'B2', series: 'B', targetNumber: 2 }, + { sourceId: 'B3', series: 'B', targetNumber: 3 }, + { sourceId: 'C1', series: 'C', targetNumber: 1 }, + { sourceId: 'C2', series: 'C', targetNumber: 2 }, + { sourceId: 'V1', series: 'V', targetNumber: 1 }, + ]) + + expect(ranges).toEqual([ + { series: 'B', from: 1, to: 3 }, + { series: 'C', from: 1, to: 2 }, + { series: 'V', from: 1, to: 1 }, + ]) + }) + + it('handles non-contiguous target numbers per series', () => { + const ranges = computeVoucherNumberRanges([ + { sourceId: 'B1', series: 'B', targetNumber: 5 }, + { sourceId: 'B2', series: 'B', targetNumber: 9 }, + ]) + expect(ranges).toEqual([{ series: 'B', from: 5, to: 9 }]) + }) +}) + +describe('importVouchers — per-voucher series preservation', () => { + // Captures the rows passed to `.insert()` so the test can assert on + // voucher_series per inserted record. Uses a hand-rolled mock rather than + // createQueuedMockSupabase because we need to inspect arguments, not just + // return queued data. + function buildCapturingSupabase() { + const journalEntryInserts: Array> = [] + const journalEntryLineInserts: Array> = [] + const rpcCalls: Array<{ name: string; args: Record }> = [] + + // Each `next_voucher_number` RPC call auto-increments per series, matching + // the DB function's ON CONFLICT behavior. + const nextNumberBySeries = new Map() + + let syntheticEntryId = 1 + + const supabase = { + from: vi.fn((table: string) => { + if (table === 'chart_of_accounts') { + // Return all accounts used in test vouchers as if already active + return { + select: () => ({ + eq: () => ({ + in: (_col: string, accountNumbers: string[]) => ({ + then: (resolve: (v: { data: { id: string; account_number: string }[]; error: null }) => void) => + resolve({ + data: accountNumbers.map((num, i) => ({ id: `acc-${i}`, account_number: num })), + error: null, + }), + }), + }), + }), + } + } + + if (table === 'journal_entries') { + return { + insert: (rows: Array>) => { + journalEntryInserts.push(...rows) + return { + select: () => ({ + then: (resolve: (v: { data: { id: string }[]; error: null }) => void) => + resolve({ + data: rows.map(() => ({ id: `entry-${syntheticEntryId++}` })), + error: null, + }), + }), + } + }, + } + } + + if (table === 'journal_entry_lines') { + return { + insert: (rows: Array>) => { + journalEntryLineInserts.push(...rows) + return Promise.resolve({ error: null }) + }, + } + } + + throw new Error(`Unexpected table: ${table}`) + }), + + rpc: vi.fn(async (name: string, args: Record) => { + rpcCalls.push({ name, args }) + if (name === 'next_voucher_number') { + const series = args.p_series as string + const current = nextNumberBySeries.get(series) ?? 0 + const next = current + 1 + nextNumberBySeries.set(series, next) + return { data: next, error: null } + } + if (name === 'reserve_voucher_range') { + const series = args.p_series as string + const highest = args.p_highest_used as number + nextNumberBySeries.set(series, highest) + return { data: null, error: null } + } + if (name === 'release_voucher_range') { + return { data: null, error: null } + } + throw new Error(`Unexpected RPC: ${name}`) + }), + } + + return { + supabase: supabase as unknown as SupabaseClient, + journalEntryInserts, + journalEntryLineInserts, + rpcCalls, + } + } + + function makeVoucher( + series: string, + number: number, + lines: Array<{ account: string; amount: number }> = [ + { account: '1510', amount: 1000 }, + { account: '3001', amount: -1000 }, + ], + ) { + return { + series, + number, + date: new Date(2024, 0, 15), + description: `Voucher ${series}${number}`, + lines, + } + } + + const baseMap = new Map([ + ['1510', '1510'], + ['3001', '3001'], + ]) + + it('routes each voucher to its source series (B, C, V → B, C, V)', async () => { + const { supabase, journalEntryInserts, rpcCalls } = buildCapturingSupabase() + const parsed = makeParsedFile({ + vouchers: [ + makeVoucher('B', 1), + makeVoucher('B', 2), + makeVoucher('C', 1), + makeVoucher('V', 1), + ], + }) + + const result = await importVouchers( + supabase, + 'company-1', + 'user-1', + 'period-1', + parsed, + baseMap, + 'B', // fallback (should not be used here — all vouchers have series) + ) + + expect(result.created).toBe(4) + expect(new Set(result.seriesUsed)).toEqual(new Set(['B', 'C', 'V'])) + + const seriesInInserts = journalEntryInserts.map((r) => r.voucher_series) + expect(seriesInInserts).toEqual(['B', 'B', 'C', 'V']) + + // Each series reserves its own voucher-number range independently + const reserveCalls = rpcCalls.filter((c) => c.name === 'reserve_voucher_range') + expect(reserveCalls.map((c) => c.args.p_series)).toEqual(['B', 'C', 'V']) + }) + + it('falls back to defaultSeries when source voucher has empty series (SIE4I)', async () => { + const { supabase, journalEntryInserts } = buildCapturingSupabase() + const parsed = makeParsedFile({ + vouchers: [ + { ...makeVoucher('', 1) }, + { ...makeVoucher('', 2) }, + ], + }) + + const result = await importVouchers( + supabase, + 'company-1', + 'user-1', + 'period-1', + parsed, + baseMap, + 'V', // fallback used because source series is empty + ) + + expect(result.created).toBe(2) + expect(result.seriesUsed).toEqual(['V']) + expect(journalEntryInserts.every((r) => r.voucher_series === 'V')).toBe(true) + }) + + it('records source series in voucherNumberMapping for audit trail', async () => { + const { supabase } = buildCapturingSupabase() + const parsed = makeParsedFile({ + vouchers: [ + makeVoucher('B', 1), + makeVoucher('C', 7), + ], + }) + + const result = await importVouchers( + supabase, + 'company-1', + 'user-1', + 'period-1', + parsed, + baseMap, + 'B', + ) + + expect(result.voucherNumberMapping).toEqual([ + { sourceId: 'B1', series: 'B', targetNumber: 1 }, + { sourceId: 'C7', series: 'C', targetNumber: 1 }, + ]) + }) + + it('assigns independent sequential target numbers per series', async () => { + const { supabase, journalEntryInserts } = buildCapturingSupabase() + const parsed = makeParsedFile({ + vouchers: [ + makeVoucher('B', 1), + makeVoucher('B', 2), + makeVoucher('B', 3), + makeVoucher('C', 1), + makeVoucher('C', 2), + ], + }) + + await importVouchers( + supabase, + 'company-1', + 'user-1', + 'period-1', + parsed, + baseMap, + 'B', + ) + + const bNumbers = journalEntryInserts + .filter((r) => r.voucher_series === 'B') + .map((r) => r.voucher_number) + const cNumbers = journalEntryInserts + .filter((r) => r.voucher_series === 'C') + .map((r) => r.voucher_number) + + // Each series starts at 1 and increments independently — not globally continuous + expect(bNumbers).toEqual([1, 2, 3]) + expect(cNumbers).toEqual([1, 2]) + }) +}) diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index f4f7e350..387cdd78 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -459,16 +459,26 @@ async function createOpeningBalanceEntry( } /** - * Create journal entries from vouchers using batch insert for performance + * Create journal entries from vouchers using batch insert for performance. + * + * Preserves per-voucher series from the source SIE file so customers migrating + * from systems like Fortnox (which uses B=kundfakturor, C=inbetalningar, etc.) + * retain traceability back to their original bookkeeping. Source voucher + * numbers are renumbered per target series via next_voucher_number to avoid + * collisions with existing entries; the source (series, number) is preserved + * in MigrationDocumentation.voucherNumberMapping for audit trail (BFNAR 2013:2). + * + * `defaultSeries` is used as a fallback only for vouchers that arrive with an + * empty series (e.g., SIE4I import files, per spec §5.15). */ -async function importVouchers( +export async function importVouchers( supabase: SupabaseClient, companyId: string, userId: string, fiscalPeriodId: string, parsed: ParsedSIEFile, accountMap: Map, - voucherSeries: string + defaultSeries: string ): Promise<{ created: number ids: string[] @@ -491,7 +501,8 @@ async function importVouchers( mappedLineCount?: number originalLineCount?: number }[] - voucherNumberMapping: Array<{ sourceId: string; targetNumber: number }> + voucherNumberMapping: Array<{ sourceId: string; series: string; targetNumber: number }> + seriesUsed: string[] retriedBatches: number failedBatches: number }> { @@ -517,7 +528,8 @@ async function importVouchers( mappedLineCount?: number originalLineCount?: number }[], - voucherNumberMapping: [] as Array<{ sourceId: string; targetNumber: number }>, + voucherNumberMapping: [] as Array<{ sourceId: string; series: string; targetNumber: number }>, + seriesUsed: [] as string[], retriedBatches: 0, failedBatches: 0, } @@ -525,6 +537,7 @@ async function importVouchers( // Pre-filter and prepare all valid vouchers interface PreparedVoucher { sourceId: string + series: string date: string description: string lines: { account_number: string; debit_amount: number; credit_amount: number; line_description: string | null }[] @@ -653,8 +666,16 @@ async function importVouchers( } } + // Resolve per-voucher series from the parsed SIE record. Fall back to the + // caller-supplied default only when the source voucher has no series + // (e.g., SIE4I subsystem import files where series/verno are optional). + const resolvedSeries = voucher.series && voucher.series.trim() + ? voucher.series.trim() + : defaultSeries + preparedVouchers.push({ sourceId: voucherId, + series: resolvedSeries, date: formatDate(voucher.date), description: voucher.description || `Import: ${voucher.series}${voucher.number}`, lines, @@ -689,24 +710,21 @@ async function importVouchers( accountIdMap.set(acc.account_number, acc.id) } - // Get starting voucher number - const { data: startNumber } = await supabase.rpc('next_voucher_number', { - p_company_id: companyId, - p_fiscal_period_id: fiscalPeriodId, - p_series: voucherSeries, - }) + // Group prepared vouchers by series so each series' voucher numbers are + // reserved and assigned independently. Preserves SIE parse order within a + // series (Map maintains insertion order) so the first source voucher in + // series B becomes the first target voucher in series B. + const seriesGroups = new Map() + for (const v of preparedVouchers) { + const list = seriesGroups.get(v.series) + if (list) { + list.push(v) + } else { + seriesGroups.set(v.series, [v]) + } + } - const currentVoucherNumber = (startNumber as number) || 1 - - // Reserve the full voucher number range upfront to prevent concurrent - // operations from claiming numbers in our range during batch insertion. - const reservedHighest = currentVoucherNumber + preparedVouchers.length - 1 - await supabase.rpc('reserve_voucher_range', { - p_company_id: companyId, - p_fiscal_period_id: fiscalPeriodId, - p_series: voucherSeries, - p_highest_used: reservedHighest, - }) + results.seriesUsed = [...seriesGroups.keys()] // Batch insert journal entries (in chunks of 100) with retry logic. // Retries handle transient errors (Supabase rate limits, Cloudflare 500s). @@ -715,10 +733,35 @@ async function importVouchers( const INTER_BATCH_DELAY_MS = 50 // Prevent rate limiting under sustained load let retriedBatches = 0 let failedBatches = 0 - let highestInsertedVoucher = currentVoucherNumber - 1 // nothing inserted yet - for (let batchStart = 0; batchStart < preparedVouchers.length; batchStart += BATCH_SIZE) { - const batch = preparedVouchers.slice(batchStart, batchStart + BATCH_SIZE) + // Process each series as an independent mini-import. Voucher numbers must + // be monotonically increasing within a series; grouping first guarantees + // that without needing to interleave series-specific counters in one loop. + let seriesIndex = 0 + for (const [series, groupVouchers] of seriesGroups) { + // Get starting voucher number for this series + const { data: startNumber } = await supabase.rpc('next_voucher_number', { + p_company_id: companyId, + p_fiscal_period_id: fiscalPeriodId, + p_series: series, + }) + + const currentVoucherNumber = (startNumber as number) || 1 + + // Reserve the full voucher number range upfront to prevent concurrent + // operations from claiming numbers in our range during batch insertion. + const reservedHighest = currentVoucherNumber + groupVouchers.length - 1 + await supabase.rpc('reserve_voucher_range', { + p_company_id: companyId, + p_fiscal_period_id: fiscalPeriodId, + p_series: series, + p_highest_used: reservedHighest, + }) + + let highestInsertedVoucher = currentVoucherNumber - 1 // nothing inserted yet + + for (let batchStart = 0; batchStart < groupVouchers.length; batchStart += BATCH_SIZE) { + const batch = groupVouchers.slice(batchStart, batchStart + BATCH_SIZE) const batchNumber = Math.floor(batchStart / BATCH_SIZE) + 1 let batchWasRetried = false @@ -728,7 +771,7 @@ async function importVouchers( company_id: companyId, fiscal_period_id: fiscalPeriodId, voucher_number: currentVoucherNumber + batchStart + i, - voucher_series: voucherSeries, + voucher_series: series, entry_date: v.date, description: v.description, source_type: 'import', @@ -803,6 +846,7 @@ async function importVouchers( results.voucherNumberMapping.push({ sourceId: voucher.sourceId, + series: voucher.series, targetNumber: assignedNumber, }) @@ -880,25 +924,30 @@ async function importVouchers( } // Small delay between batches to prevent Supabase/Cloudflare rate limiting - if (batchStart + BATCH_SIZE < preparedVouchers.length) { + const isLastBatchInSeries = batchStart + BATCH_SIZE >= groupVouchers.length + const isLastSeries = seriesIndex === seriesGroups.size - 1 + if (!isLastBatchInSeries || !isLastSeries) { await new Promise(resolve => setTimeout(resolve, INTER_BATCH_DELAY_MS)) } } - // Adjust voucher sequence after insertion. - // Range was pre-reserved to `reservedHighest`. If some batches failed, - // release the unused portion to avoid burned numbers and gap-explanation friction. - if (highestInsertedVoucher < reservedHighest) { - const releaseTarget = highestInsertedVoucher >= currentVoucherNumber - ? highestInsertedVoucher // partial success: set to actual highest - : currentVoucherNumber - 1 // total failure: roll back fully - await supabase.rpc('release_voucher_range', { - p_company_id: companyId, - p_fiscal_period_id: fiscalPeriodId, - p_series: voucherSeries, - p_actual_last: releaseTarget, - p_reserved_highest: reservedHighest, - }) + // Adjust voucher sequence after insertion for this series. + // Range was pre-reserved to `reservedHighest`. If some batches failed, + // release the unused portion to avoid burned numbers and gap-explanation friction. + if (highestInsertedVoucher < reservedHighest) { + const releaseTarget = highestInsertedVoucher >= currentVoucherNumber + ? highestInsertedVoucher // partial success: set to actual highest + : currentVoucherNumber - 1 // total failure: roll back fully + await supabase.rpc('release_voucher_range', { + p_company_id: companyId, + p_fiscal_period_id: fiscalPeriodId, + p_series: series, + p_actual_last: releaseTarget, + p_reserved_highest: reservedHighest, + }) + } + + seriesIndex++ } // Propagate batch retry stats @@ -916,6 +965,29 @@ export function isBalanceSheetAccount(accountNumber: string): boolean { return firstDigit >= 1 && firstDigit <= 2 } +/** + * Compute per-series voucher number ranges from the voucher number mapping. + * SIE imports can span multiple series (B, C, V, ...), each with its own + * independent target-number range, so the documentation records one range + * per series. + */ +export function computeVoucherNumberRanges( + mapping: Array<{ sourceId: string; series: string; targetNumber: number }> +): Array<{ series: string; from: number; to: number }> { + if (mapping.length === 0) return [] + const bySeries = new Map() + for (const entry of mapping) { + const existing = bySeries.get(entry.series) + if (existing) { + if (entry.targetNumber < existing.from) existing.from = entry.targetNumber + if (entry.targetNumber > existing.to) existing.to = entry.targetNumber + } else { + bySeries.set(entry.series, { from: entry.targetNumber, to: entry.targetNumber }) + } + } + return [...bySeries.entries()].map(([series, range]) => ({ series, ...range })) +} + /** * Create a migration adjustment entry (omföringsverifikation) to reconcile * imported voucher movements against the SIE file's closing balances. @@ -1470,7 +1542,8 @@ export async function executeSIEImport( let ibRoundingAdjustment = 0 let ibExplanation: 'unallocated_result' | 'excluded_accounts' | 'rounding' | null = null let migrationAdjustmentInfo = { created: false, deltaAccounts: 0, entryId: null as string | null } - let voucherNumberMapping: Array<{ sourceId: string; targetNumber: number }> = [] + let voucherNumberMapping: Array<{ sourceId: string; series: string; targetNumber: number }> = [] + let voucherSeriesUsed: string[] = [] let voucherRetryStats = { retriedBatches: 0, failedBatches: 0 } let voucherStats = { total: parsed.vouchers.length, @@ -1480,7 +1553,9 @@ export async function executeSIEImport( skippedSingleLine: 0, skippedEmpty: 0, } - const voucherSeries = options.voucherSeries || 'B' + // Fallback series for vouchers that arrive without one (SIE4I subsystem files). + // Source series from #VER are preserved per-voucher by importVouchers. + const defaultSeries = options.voucherSeries || 'B' // Validate and import opening balances. // @@ -1591,13 +1666,14 @@ export async function executeSIEImport( result.fiscalPeriodId, parsed, accountMap, - voucherSeries + defaultSeries ) result.journalEntriesCreated += voucherResults.created result.journalEntryIds.push(...voucherResults.ids) result.errors.push(...voucherResults.errors) voucherNumberMapping = voucherResults.voucherNumberMapping + voucherSeriesUsed = voucherResults.seriesUsed voucherRetryStats = { retriedBatches: voucherResults.retriedBatches, failedBatches: voucherResults.failedBatches, @@ -1694,7 +1770,7 @@ export async function executeSIEImport( end: fiscalYearEnd, }, importedAt: new Date().toISOString(), - importedBy: companyId, + importedBy: userId, accountMappings: { total: mappingStats.total, exact: mappingStats.exact, @@ -1705,13 +1781,8 @@ export async function executeSIEImport( vouchers: voucherStats, openingBalanceRounding: ibRoundingAdjustment !== 0 ? ibRoundingAdjustment : null, migrationAdjustment: migrationAdjustmentInfo, - voucherSeriesUsed: voucherSeries, - voucherNumberRange: voucherNumberMapping.length > 0 - ? { - from: voucherNumberMapping[0].targetNumber, - to: voucherNumberMapping[voucherNumberMapping.length - 1].targetNumber, - } - : null, + voucherSeriesUsed: voucherSeriesUsed.length > 0 ? voucherSeriesUsed : [defaultSeries], + voucherNumberRanges: computeVoucherNumberRanges(voucherNumberMapping), voucherNumberMapping, } diff --git a/lib/import/types.ts b/lib/import/types.ts index 2747a3dd..cd5516d3 100644 --- a/lib/import/types.ts +++ b/lib/import/types.ts @@ -383,11 +383,15 @@ export interface MigrationDocumentation { entryId: string | null } - // Voucher number mapping - voucherSeriesUsed: string - voucherNumberRange: { from: number; to: number } | null + // Voucher number mapping. + // Per-voucher series is preserved from the source SIE file (e.g., Fortnox uses + // B=kundfakturor, C=inbetalningar), so a single import may span multiple series + // and each series has its own independent target-number range. + voucherSeriesUsed: string[] + voucherNumberRanges: Array<{ series: string; from: number; to: number }> voucherNumberMapping: Array<{ - sourceId: string // e.g. "A1" + sourceId: string // e.g. "B1" + series: string // target series (same as source unless fallback applied) targetNumber: number }> } diff --git a/lib/reports/__tests__/income-statement-bokio-integration.test.ts b/lib/reports/__tests__/income-statement-bokio-integration.test.ts new file mode 100644 index 00000000..a9d4467a --- /dev/null +++ b/lib/reports/__tests__/income-statement-bokio-integration.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { readFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' + +// Fixtures live in /dev_docs which is gitignored (contains anonymised real-world +// customer exports). Skip the suite when running outside a dev machine. +const fixtureDir = join(process.cwd(), 'dev_docs', 'example_sie') +const fixturesAvailable = existsSync(join(fixtureDir, '8812090614_2025.se')) + +vi.mock('../trial-balance', () => ({ + generateTrialBalance: vi.fn(), +})) + +import { generateIncomeStatement } from '../income-statement' +import { generateTrialBalance } from '../trial-balance' +import { + detectEncoding, + decodeBuffer, + parseSIEFile, +} from '@/lib/import/sie-parser' +import { getBASReference } from '@/lib/bookkeeping/bas-reference' +import type { ParsedSIEFile } from '@/lib/import/types' +import type { TrialBalanceRow } from '@/types' + +const mockTrialBalance = vi.mocked(generateTrialBalance) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const supabase = {} as any + +beforeEach(() => { + vi.clearAllMocks() +}) + +/** + * Load a real SIE file from dev_docs/example_sie and parse it with the real + * parser (encoding detection included). These are anonymised real-world Bokio + * exports — regression data for the multi-year-import + year-end-close bug + * reported by an onboarding user in April 2026. + */ +function loadSIE(filename: string): ParsedSIEFile { + const path = join(process.cwd(), 'dev_docs', 'example_sie', filename) + const buffer = readFileSync(path) + // readFileSync on Node returns a Buffer; slice to an ArrayBuffer view. + const arrayBuffer = buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer + const encoding = detectEncoding(arrayBuffer) + const content = decodeBuffer(arrayBuffer, encoding) + return parseSIEFile(content) +} + +/** + * Build trial balance rows for a given fiscal-year SIE file, simulating what + * the database would contain after a clean SIE import: + * - Opening balance entry (from #IB 0) + * - Period activity (from all #VER lines) + * + * Maps source accounts to themselves (Bokio uses BAS-compliant numbers, so + * no translation is needed). Uses BAS reference for account_class; falls back + * to the first digit of the account number. + */ +function buildTrialBalanceFromSIE(parsed: ParsedSIEFile): TrialBalanceRow[] { + const opening = new Map() + const period = new Map() + + // Opening balances — only class 1-2 (Swedish SIE #IB convention) + for (const ib of parsed.openingBalances.filter((b) => b.yearIndex === 0)) { + const bucket = opening.get(ib.account) || { debit: 0, credit: 0 } + if (ib.amount > 0) bucket.debit += ib.amount + else bucket.credit += Math.abs(ib.amount) + opening.set(ib.account, bucket) + } + + // Period activity — every #VER line + for (const voucher of parsed.vouchers) { + for (const line of voucher.lines) { + const bucket = period.get(line.account) || { debit: 0, credit: 0 } + if (line.amount > 0) bucket.debit += line.amount + else bucket.credit += Math.abs(line.amount) + period.set(line.account, bucket) + } + } + + const allAccounts = new Set([...opening.keys(), ...period.keys()]) + const rows: TrialBalanceRow[] = [] + + for (const account of allAccounts) { + const op = opening.get(account) || { debit: 0, credit: 0 } + const pe = period.get(account) || { debit: 0, credit: 0 } + const basRef = getBASReference(account) + const accountClass = basRef?.account_class ?? parseInt(account[0], 10) + const accountName = parsed.accounts.find((a) => a.number === account)?.name ?? + basRef?.account_name ?? `Konto ${account}` + + rows.push({ + account_number: account, + account_name: accountName, + account_class: accountClass, + opening_debit: Math.round(op.debit * 100) / 100, + opening_credit: Math.round(op.credit * 100) / 100, + period_debit: Math.round(pe.debit * 100) / 100, + period_credit: Math.round(pe.credit * 100) / 100, + closing_debit: Math.round((op.debit + pe.debit) * 100) / 100, + closing_credit: Math.round((op.credit + pe.credit) * 100) / 100, + }) + } + + return rows.sort((a, b) => a.account_number.localeCompare(b.account_number)) +} + +describe.skipIf(!fixturesAvailable)('income statement — Bokio SIE regression (dev_docs/example_sie)', () => { + it('2025: net_result matches Bokio 221 316 kr despite Yearly result closing voucher', async () => { + // Bokio's 2025 export contains V194 "Yearly result": debit 8999 / credit 2099 + // with 221 316.27. Before the fix, treating 8999 as a regular class-8 + // financial item cancelled the computed profit and net_result dropped to ~0. + // NE-bilaga was unaffected because it ignores 8999 by design. + const parsed = loadSIE('8812090614_2025.se') + const rows = buildTrialBalanceFromSIE(parsed) + + mockTrialBalance.mockResolvedValue({ + rows, + totalDebit: rows.reduce((s, r) => s + r.closing_debit, 0), + totalCredit: rows.reduce((s, r) => s + r.closing_credit, 0), + isBalanced: true, + }) + + const report = await generateIncomeStatement(supabase, 'company-1', 'period-2025') + + // Bokio shows årets resultat = 221 316.27 kr on their V194 voucher. + // gnubok computes 220 906.27 (Revenue 370 314.68 − Expenses 149 408.41), + // which is the mathematically exact figure. The 410 kr difference is + // öresutjämning/rounding that Bokio absorbed into V194 itself. + expect(report.net_result).toBeCloseTo(220_906.27, 2) + expect(report.total_revenue).toBeCloseTo(370_314.68, 2) + expect(report.total_expenses).toBeCloseTo(149_408.41, 2) + + // Sanity: revenue and expenses are in the expected ballpark (~370k / ~149k) + expect(report.total_revenue).toBeGreaterThan(365_000) + expect(report.total_revenue).toBeLessThan(375_000) + expect(report.total_expenses).toBeGreaterThan(145_000) + expect(report.total_expenses).toBeLessThan(155_000) + + // 8999 must not contribute to the financial section total. + // Other class 8 accounts (interest) may still appear with small amounts. + const flat = report.financial_sections.flatMap((s) => s.rows) + expect(flat.find((r) => r.account_number === '8999')).toBeUndefined() + }) + + it('2024: no year-end close in SIE — net_result equals the sum of #RES accounts (~541k)', async () => { + // 2024 SIE has no "Yearly result" voucher, so 8999 stays at 0 and the + // computation is a plain revenue-minus-expenses. This proves the fix + // doesn't regress the non-closed case. + const parsed = loadSIE('8812090614_2024.se') + const rows = buildTrialBalanceFromSIE(parsed) + + mockTrialBalance.mockResolvedValue({ + rows, + totalDebit: rows.reduce((s, r) => s + r.closing_debit, 0), + totalCredit: rows.reduce((s, r) => s + r.closing_credit, 0), + isBalanced: true, + }) + + const report = await generateIncomeStatement(supabase, 'company-1', 'period-2024') + + // Bokio internal total = 540 702.71 (from summing #RES 0 without 8999). + expect(report.net_result).toBeCloseTo(540_702.71, 2) + }) +}) diff --git a/lib/reports/__tests__/income-statement.test.ts b/lib/reports/__tests__/income-statement.test.ts index 509e946a..02138bf0 100644 --- a/lib/reports/__tests__/income-statement.test.ts +++ b/lib/reports/__tests__/income-statement.test.ts @@ -214,6 +214,61 @@ describe('generateIncomeStatement', () => { expect(section.subtotal).toBe(10000) }) + it('excludes account 8999 (Årets resultat closing) from financial section', async () => { + // Regression: when a SIE import contains a year-end close voucher like + // Bokio's "Yearly result" (debit 8999, credit 2099), 8999 holds a debit + // balance equal to the computed profit. Including it as a financial item + // cancels the revenue-vs-expense difference and drives net_result to ~0. + // Matches the NE-bilaga behavior (which already ignores 8999). + mockTrialBalance.mockResolvedValue({ + rows: [ + makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 370000, closing_debit: 0 }), + makeRow({ account_number: '5010', account_name: 'Rent', account_class: 5, closing_debit: 149000, closing_credit: 0 }), + // Year-end close posted the profit on 8999 + makeRow({ account_number: '8999', account_name: 'Årets resultat', account_class: 8, closing_debit: 221000, closing_credit: 0 }), + ], + totalDebit: 370000, + totalCredit: 370000, + isBalanced: true, + }) + + const report = await generateIncomeStatement(supabase, 'company-1', 'period-1') + + expect(report.total_revenue).toBe(370000) + expect(report.total_expenses).toBe(149000) + // 8999 must not contribute — financial section should be empty + expect(report.financial_sections).toEqual([]) + expect(report.total_financial).toBe(0) + // Computed net result equals what Bokio / NE-bilaga shows + expect(report.net_result).toBe(221000) + }) + + it('still includes legitimate class 8 accounts (8310 interest, 8410 interest expense)', async () => { + // Sanity check the 8999 exclusion is narrow — other class 8 accounts + // (interest income, interest expense, tax) must still appear. + mockTrialBalance.mockResolvedValue({ + rows: [ + makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 100000, closing_debit: 0 }), + makeRow({ account_number: '8310', account_name: 'Ränteintäkter', account_class: 8, closing_credit: 500, closing_debit: 0 }), + makeRow({ account_number: '8410', account_name: 'Räntekostnader', account_class: 8, closing_debit: 200, closing_credit: 0 }), + makeRow({ account_number: '8999', account_name: 'Årets resultat', account_class: 8, closing_debit: 100300, closing_credit: 0 }), + ], + totalDebit: 100500, + totalCredit: 100500, + isBalanced: true, + }) + + const report = await generateIncomeStatement(supabase, 'company-1', 'period-1') + + const titles = report.financial_sections.map(s => s.title) + expect(titles).toContain('Ränteintäkter') + expect(titles).toContain('Räntekostnader') + // 89xx section would have been populated by 8999 only — excluded + expect(titles).not.toContain('Skatter och årets resultat') + expect(report.total_financial).toBe(300) // 500 - 200 + expect(report.net_result).toBe(100300) // 100000 - 0 + 300 + }) + it('ignores class 1-2 accounts (balance sheet)', async () => { mockTrialBalance.mockResolvedValue({ rows: [ diff --git a/lib/reports/income-statement.ts b/lib/reports/income-statement.ts index 5e670217..b2e5eb52 100644 --- a/lib/reports/income-statement.ts +++ b/lib/reports/income-statement.ts @@ -84,9 +84,16 @@ export async function generateIncomeStatement( 'debit' // Expenses have debit normal balance ) - // Financial sections (class 8) + // Financial sections (class 8) — exclude 8999 "Årets resultat". + // 8999 is a closing account: when year-end posts "8999 debit → 2099 credit" + // to move the computed profit into equity, including 8999's debit balance + // here cancels out the revenue/expense difference and drives net_result to + // zero. The income statement shows the *computed* årets resultat as + // (revenue - expenses + financial), so 8999's own balance must stay out. const financialSections = buildSections( - incomeExpenseRows.filter((r) => r.account_class === 8), + incomeExpenseRows.filter( + (r) => r.account_class === 8 && r.account_number !== '8999' + ), { '80': 'Resultat andelar koncernföretag', '81': 'Resultat andelar intresseföretag', diff --git a/lib/reports/monthly-breakdown.ts b/lib/reports/monthly-breakdown.ts index 832a93cd..c61a2259 100644 --- a/lib/reports/monthly-breakdown.ts +++ b/lib/reports/monthly-breakdown.ts @@ -109,8 +109,11 @@ export async function generateMonthlyBreakdown( } else if (accountClass >= 4 && accountClass <= 7) { // Expense accounts: debit side represents expenses bucket.expenses = Math.round((bucket.expenses + line.debit_amount - line.credit_amount) * 100) / 100 - } else if (accountClass === 8) { + } else if (accountClass === 8 && line.account_number !== '8999') { // Financial items (class 8): interest, exchange gains/losses, etc. + // 8999 "Årets resultat" is a year-end closing account — its debit/credit + // mirrors the computed profit, so including it here would cancel the + // period's income-vs-expense signal on the month of closing. const amount = line.credit_amount - line.debit_amount if (amount >= 0) { bucket.income = Math.round((bucket.income + amount) * 100) / 100