Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries * test: cover annual report depreciation and VAT balances * Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch * fix: show exact invoice delivery details * fix: use currency account in invoice emails * fix: address invoice delivery review feedback * fix: harden invoice delivery and payment accounts * test: assert RLS-denied zero-row updates * fix: close remaining invoice compliance gaps * fix: harden invoice archive authorization * fix: close invoice delivery review findings * fix: verify delivery finalization results * fix: cap combined invoice email recipients * fix: close final invoice compliance findings * fix: prevent stale payment account saves * test: prove invoice delivery isolation * fix: close invoice privacy review findings * test: normalize delivery retention dates
This commit is contained in:
@@ -26,8 +26,9 @@ vi.mock('@/lib/reports/kassaflodesanalys', () => ({
|
||||
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
|
||||
listAssets: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
const mockFetchAllRows = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/supabase/fetch-all', () => ({
|
||||
fetchAllRows: vi.fn().mockResolvedValue([]),
|
||||
fetchAllRows: mockFetchAllRows,
|
||||
}))
|
||||
|
||||
import { buildArsredovisningData } from '../build-data'
|
||||
@@ -274,6 +275,7 @@ function plantStandardReports() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchAllRows.mockResolvedValue([])
|
||||
plantStandardReports()
|
||||
})
|
||||
|
||||
@@ -352,6 +354,43 @@ describe('buildArsredovisningData: K3', () => {
|
||||
})
|
||||
|
||||
describe('buildArsredovisningData: K2 byte-equivalence', () => {
|
||||
it('keeps tax and appropriations in the statutory pre-closing balance', async () => {
|
||||
const supabase = makeSupabase({ accountingFramework: 'k2' })
|
||||
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
|
||||
await buildArsredovisningData(supabase, 'co1', 'fp1')
|
||||
|
||||
expect(mockedTrialBalance).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'co1',
|
||||
'fp1',
|
||||
{ excludeFinalClosingEntry: true },
|
||||
)
|
||||
expect(mockedTrialBalance).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'co1',
|
||||
'fp1',
|
||||
{ excludeYearEndClosing: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses the current-period mapping in the multi-year overview', async () => {
|
||||
mockFetchAllRows.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'fp1',
|
||||
name: '2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
},
|
||||
])
|
||||
const supabase = makeSupabase({ accountingFramework: 'k2' })
|
||||
|
||||
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
|
||||
await buildArsredovisningData(supabase, 'co1', 'fp1')
|
||||
|
||||
const currentPeriodCalls = mockedTrialBalance.mock.calls.filter((call) => call[2] === 'fp1')
|
||||
expect(currentPeriodCalls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('records accounting_framework=k2', async () => {
|
||||
const supabase = makeSupabase({ accountingFramework: 'k2' })
|
||||
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
|
||||
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
type AnnualReportEligibilityResult,
|
||||
type AnnualReportProfile,
|
||||
} from '../compliance-types'
|
||||
import { validateAnnualReportCompleteness } from '../completeness'
|
||||
import {
|
||||
validateAnnualReportCompleteness,
|
||||
validateStatementIntegrity,
|
||||
} from '../completeness'
|
||||
import { mapTrialBalancesToK2 } from '../../ixbrl/k2-mapper'
|
||||
import { buildBrRows, buildRrRows } from '../statement-rows'
|
||||
|
||||
const eligibility: AnnualReportEligibilityResult = {
|
||||
k2_eligible: true,
|
||||
@@ -47,9 +52,16 @@ function report(): ArsredovisningData {
|
||||
total_equity_liabilities: 100,
|
||||
total_assets_previous: null,
|
||||
total_equity_liabilities_previous: null,
|
||||
assets: [{ label: 'Bank', amount: 100 }],
|
||||
assets: [{ label: 'Bank', current: 100, previous: null }],
|
||||
equity_liabilities: [
|
||||
{ label: 'Eget kapital', current: 100, previous: null },
|
||||
{ label: 'Årets resultat', current: 20, previous: null },
|
||||
],
|
||||
},
|
||||
resultatrakning: [{ label: 'Nettoomsättning', amount: 100 }],
|
||||
resultatrakning: [
|
||||
{ label: 'Nettoomsättning', current: 20, previous: null },
|
||||
{ label: 'Årets resultat', current: 20, previous: null, is_total: true },
|
||||
],
|
||||
noter: [{ number: 1, title: 'Principer', body: 'K2' }],
|
||||
signatures: [{ role: 'Styrelseledamot', name: 'Anna Andersson', signed_at: '2026-03-01' }],
|
||||
warnings: [],
|
||||
@@ -157,6 +169,115 @@ describe('validateAnnualReportCompleteness', () => {
|
||||
expect(result.issues.some((issue) => issue.code === 'AR-DIVIDEND-EXCEEDS-EQUITY')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks a version whose income-statement result differs from equity', () => {
|
||||
const value = input('draft')
|
||||
value.report.resultatrakning = [
|
||||
{ label: 'Årets resultat', current: 790_296, previous: null, is_total: true },
|
||||
]
|
||||
value.report.forvaltningsberattelse.resultatdisposition_amounts.current_year_result = 469_542
|
||||
|
||||
const result = validateAnnualReportCompleteness(value)
|
||||
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'AR-RESULT-MISMATCH', severity: 'error' }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('compares annual-report results at whole-ore precision', () => {
|
||||
const value = report()
|
||||
value.resultatrakning = [
|
||||
{ label: 'Årets resultat', current: 0.1 + 0.2, previous: null, is_total: true },
|
||||
]
|
||||
value.balansrakning.equity_liabilities = [
|
||||
{ label: 'Årets resultat', current: 0.3, previous: null },
|
||||
]
|
||||
value.forvaltningsberattelse.resultatdisposition_amounts.current_year_result = 0.3
|
||||
|
||||
expect(validateStatementIntegrity(value)).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'AR-RESULT-MISMATCH' }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks a version when the income statement has no final result row', () => {
|
||||
const value = input('draft')
|
||||
value.report.resultatrakning = [
|
||||
{ label: 'Nettoomsättning', current: 100, previous: null },
|
||||
]
|
||||
|
||||
const result = validateAnnualReportCompleteness(value)
|
||||
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'AR-RESULT-MISSING', severity: 'error' }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('identifies the statutory result by semantic key instead of its K2 label', () => {
|
||||
const value = report()
|
||||
value.resultatrakning = [{
|
||||
label: 'Årets resultat/förlust',
|
||||
semantic_key: 'income_statement_result',
|
||||
current: 20,
|
||||
previous: null,
|
||||
is_total: true,
|
||||
}]
|
||||
value.balansrakning.equity_liabilities = [{
|
||||
label: 'Periodens resultat',
|
||||
semantic_key: 'balance_sheet_current_year_result',
|
||||
current: 20,
|
||||
previous: null,
|
||||
}]
|
||||
|
||||
expect(validateStatementIntegrity(value)).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'AR-RESULT-MISSING' }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('detects a line reclassification between PDF and iXBRL with unchanged totals', () => {
|
||||
const value = input('draft')
|
||||
const full = [
|
||||
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
|
||||
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
|
||||
{ account_number: '2099', account_name: 'Current result', closing_debit: 0, closing_credit: 20 },
|
||||
{ account_number: '3010', account_name: 'Revenue', closing_debit: 20, closing_credit: 20 },
|
||||
]
|
||||
const preClosing = [
|
||||
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
|
||||
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
|
||||
{ account_number: '3010', account_name: 'Revenue', closing_debit: 0, closing_credit: 20 },
|
||||
]
|
||||
const mapping = mapTrialBalancesToK2({ full, preClosing }, null)
|
||||
const balanceRows = buildBrRows(mapping)
|
||||
value.report.resultatrakning = buildRrRows(mapping)
|
||||
value.report.balansrakning.assets = balanceRows.assets
|
||||
value.report.balansrakning.equity_liabilities = balanceRows.equityLiabilities
|
||||
value.report.balansrakning.total_assets = mapping.totals.tillgangar.current
|
||||
value.report.balansrakning.total_equity_liabilities =
|
||||
mapping.totals.egetKapitalSkulder.current
|
||||
const ixbrl = {
|
||||
rr: {
|
||||
...mapping.rr,
|
||||
Nettoomsattning: { current: 0, previous: null },
|
||||
OvrigaRorelseintakter: { current: 20, previous: null },
|
||||
},
|
||||
br: mapping.br,
|
||||
totals: mapping.totals,
|
||||
}
|
||||
|
||||
expect(validateStatementIntegrity(value.report, ixbrl as never)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'AR-IXBRL-STATEMENT-MISMATCH' }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('requires a documented prudence assessment for a positive dividend', () => {
|
||||
const value = input('draft')
|
||||
value.report.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend = 50
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapTrialBalancesToK2, type TrialBalanceRowLike } from '../../ixbrl/k2-mapper'
|
||||
import { calculateSoliditet } from '../build-data'
|
||||
|
||||
function row(
|
||||
account: string,
|
||||
debit: number,
|
||||
credit: number,
|
||||
): TrialBalanceRowLike {
|
||||
return {
|
||||
account_number: account,
|
||||
account_name: `Account ${account}`,
|
||||
closing_debit: debit,
|
||||
closing_credit: credit,
|
||||
}
|
||||
}
|
||||
|
||||
describe('calculateSoliditet', () => {
|
||||
it('returns null when total assets are non-positive', () => {
|
||||
const mapping = mapTrialBalancesToK2({ full: [], preClosing: [] }, null)
|
||||
|
||||
expect(calculateSoliditet(mapping)).toBeNull()
|
||||
})
|
||||
|
||||
it('uses adjusted equity and the sign-reclassified balance-sheet total', () => {
|
||||
const full = [
|
||||
row('1630', 0, 22_985),
|
||||
row('1930', 17_428.36, 0),
|
||||
row('1940', 749_306.35, 0),
|
||||
row('2081', 0, 25_000),
|
||||
row('2099', 0, 469_542.21),
|
||||
row('2125', 0, 197_574),
|
||||
row('2512', 0, 123_180),
|
||||
row('2518', 101_970, 0),
|
||||
row('2641', 1_387.5, 0),
|
||||
row('2891', 0, 23_223),
|
||||
row('2893', 0, 8_588),
|
||||
]
|
||||
const mapping = mapTrialBalancesToK2({ full, preClosing: full }, null)
|
||||
|
||||
expect(mapping.br['OvrigaFordringarKortfristiga'].current).toBe(1_387)
|
||||
expect(mapping.br['KassaBankExklRedovisningsmedel'].current).toBe(766_735)
|
||||
expect(mapping.br['Skatteskulder'].current).toBe(44_195)
|
||||
expect(mapping.br['OvrigaKortfristigaSkulder'].current).toBe(31_811)
|
||||
expect(mapping.br['Periodiseringsfonder'].current).toBe(197_574)
|
||||
expect(mapping.totals.tillgangar.current).toBe(768_122)
|
||||
expect(mapping.totals.egetKapitalSkulder.current).toBe(768_122)
|
||||
expect(calculateSoliditet(mapping)).toBe(84.8)
|
||||
})
|
||||
})
|
||||
@@ -65,6 +65,23 @@ function previousPair(): TrialBalancePair {
|
||||
return { full, preClosing }
|
||||
}
|
||||
|
||||
function reportedAccountsPair(): TrialBalancePair {
|
||||
const full = [
|
||||
tbRow('1250', 'Computers', { debit: 50 }),
|
||||
tbRow('1259', 'Accumulated depreciation', { credit: 10 }),
|
||||
tbRow('1930', 'Bank', { debit: 75 }),
|
||||
tbRow('2081', 'Share capital', { credit: 50 }),
|
||||
tbRow('2099', 'Current-year result', { credit: 90 }),
|
||||
tbRow('2650', 'VAT settlement account', { debit: 25 }),
|
||||
]
|
||||
const preClosing = [
|
||||
...full.filter((row) => row.account_number !== '2099'),
|
||||
tbRow('3010', 'Revenue', { credit: 100 }),
|
||||
tbRow('7833', 'Depreciation of computers', { debit: 10 }),
|
||||
]
|
||||
return { full, preClosing }
|
||||
}
|
||||
|
||||
describe('buildRrRows / buildBrRows — no kontonummer regression', () => {
|
||||
it('no RR or BR label contains a BAS account number', () => {
|
||||
const mapping = mapTrialBalancesToK2(currentPair(), previousPair())
|
||||
@@ -79,6 +96,17 @@ describe('buildRrRows / buildBrRows — no kontonummer regression', () => {
|
||||
})
|
||||
|
||||
describe('buildRrRows', () => {
|
||||
it('renders account 7833 depreciation in the statutory expense post', () => {
|
||||
const rows = buildRrRows(mapTrialBalancesToK2(reportedAccountsPair(), null))
|
||||
|
||||
expect(
|
||||
rows.find(
|
||||
(row) =>
|
||||
row.label === 'Av- och nedskrivningar av materiella och immateriella anläggningstillgångar',
|
||||
)?.current,
|
||||
).toBe(-10)
|
||||
})
|
||||
|
||||
it('follows the ÅRL uppställningsform order with posts and subtotals', () => {
|
||||
const mapping = mapTrialBalancesToK2(currentPair(), null)
|
||||
const labels = buildRrRows(mapping).map((r) => r.label)
|
||||
@@ -130,6 +158,7 @@ describe('buildRrRows', () => {
|
||||
const rr = buildRrRows(mapping)
|
||||
const aretsResultat = rr[rr.length - 1]
|
||||
expect(aretsResultat.label).toBe('Årets resultat')
|
||||
expect(aretsResultat.semantic_key).toBe('income_statement_result')
|
||||
expect(aretsResultat.is_total).toBe(true)
|
||||
expect(aretsResultat.current).toBe(mapping.totals.aretsResultat.current)
|
||||
expect(aretsResultat.current).toBe(300_000)
|
||||
@@ -146,10 +175,25 @@ describe('buildRrRows', () => {
|
||||
})
|
||||
|
||||
describe('buildBrRows', () => {
|
||||
it('renders a debit on account 2650 under receivables instead of liabilities', () => {
|
||||
const { assets, equityLiabilities } = buildBrRows(
|
||||
mapTrialBalancesToK2(reportedAccountsPair(), null),
|
||||
)
|
||||
|
||||
expect(assets.find((row) => row.label === 'Övriga fordringar')?.current).toBe(25)
|
||||
expect(equityLiabilities.find((row) => row.label === 'Övriga skulder')?.current).toBe(0)
|
||||
expect(assets.at(-1)?.current).toBe(140)
|
||||
expect(equityLiabilities.at(-1)?.current).toBe(140)
|
||||
})
|
||||
|
||||
it('renders Kassa och bank as a post and ends both sides on tied totals', () => {
|
||||
const mapping = mapTrialBalancesToK2(currentPair(), null)
|
||||
const { assets, equityLiabilities } = buildBrRows(mapping)
|
||||
|
||||
expect(
|
||||
equityLiabilities.find((row) => row.semantic_key === 'balance_sheet_current_year_result'),
|
||||
).toMatchObject({ label: 'Årets resultat', current: 300_000 })
|
||||
|
||||
expect(assets.find((r) => r.label === 'Kassa och bank' && !r.is_heading)?.current).toBe(
|
||||
600_000,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CanonicalAnnualReport } from '../compliance-types'
|
||||
import { annualReportContentHash, createAnnualReportVersion } from '../version-service'
|
||||
import {
|
||||
annualReportContentHash,
|
||||
createAnnualReportVersion,
|
||||
} from '../version-service'
|
||||
import { mapTrialBalancesToK2 } from '../../ixbrl/k2-mapper'
|
||||
import { buildBrRows, buildRrRows } from '../statement-rows'
|
||||
|
||||
function model(signedAt: string | null): CanonicalAnnualReport {
|
||||
const full = [
|
||||
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
|
||||
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
|
||||
{ account_number: '2099', account_name: 'Current result', closing_debit: 0, closing_credit: 20 },
|
||||
{ account_number: '3010', account_name: 'Revenue', closing_debit: 20, closing_credit: 20 },
|
||||
]
|
||||
const preClosing = [
|
||||
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
|
||||
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
|
||||
{ account_number: '3010', account_name: 'Revenue', closing_debit: 0, closing_credit: 20 },
|
||||
]
|
||||
const mapping = mapTrialBalancesToK2({ full, preClosing }, null)
|
||||
const balanceRows = buildBrRows(mapping)
|
||||
return {
|
||||
schema_version: '1.0',
|
||||
generated_at: '2026-07-21T10:00:00Z',
|
||||
@@ -12,6 +30,16 @@ function model(signedAt: string | null): CanonicalAnnualReport {
|
||||
report: {
|
||||
accounting_framework: 'k2',
|
||||
signatures: [{ role: 'Styrelseledamot', name: 'Anna Andersson', signed_at: signedAt }],
|
||||
resultatrakning: buildRrRows(mapping),
|
||||
balansrakning: {
|
||||
assets: balanceRows.assets,
|
||||
equity_liabilities: balanceRows.equityLiabilities,
|
||||
total_assets: 100,
|
||||
total_equity_liabilities: 100,
|
||||
},
|
||||
forvaltningsberattelse: {
|
||||
resultatdisposition_amounts: { current_year_result: 20 },
|
||||
},
|
||||
},
|
||||
profile: { reporting_currency: 'SEK' },
|
||||
disclosures: {},
|
||||
@@ -22,6 +50,9 @@ function model(signedAt: string | null): CanonicalAnnualReport {
|
||||
validation: { ok: true },
|
||||
ixbrl: {
|
||||
entryPointId: 'k2-ab-risbs-2024-09-12',
|
||||
rr: mapping.rr,
|
||||
br: mapping.br,
|
||||
totals: mapping.totals,
|
||||
underskrifter: {
|
||||
dateringsdatum: signedAt,
|
||||
signers: [
|
||||
@@ -76,4 +107,46 @@ describe('annualReportContentHash', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to snapshot inconsistent financial statements', async () => {
|
||||
const rpc = vi.fn()
|
||||
const inconsistent = model(null)
|
||||
inconsistent.report.resultatrakning.find(
|
||||
(row) => row.label === 'Årets resultat',
|
||||
)!.current = 21
|
||||
inconsistent.validation = { ...inconsistent.validation, ok: true, issues: [] }
|
||||
|
||||
await expect(
|
||||
createAnnualReportVersion({ rpc } as never, 'user-1', inconsistent, false),
|
||||
).rejects.toThrow('inconsistent financial statements')
|
||||
expect(rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a PDF and iXBRL line reclassification with unchanged totals', async () => {
|
||||
const rpc = vi.fn()
|
||||
const inconsistent = model(null)
|
||||
inconsistent.ixbrl!.rr = {
|
||||
...inconsistent.ixbrl!.rr,
|
||||
Nettoomsattning: { current: 0, previous: null },
|
||||
OvrigaRorelseintakter: { current: 20, previous: null },
|
||||
}
|
||||
|
||||
await expect(
|
||||
createAnnualReportVersion({ rpc } as never, 'user-1', inconsistent, false),
|
||||
).rejects.toThrow('inconsistent financial statements')
|
||||
expect(rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a mutated visible balance-sheet result row', async () => {
|
||||
const rpc = vi.fn()
|
||||
const inconsistent = model(null)
|
||||
inconsistent.report.balansrakning.equity_liabilities.find(
|
||||
(row) => row.label === 'Årets resultat',
|
||||
)!.current = 19
|
||||
|
||||
await expect(
|
||||
createAnnualReportVersion({ rpc } as never, 'user-1', inconsistent, false),
|
||||
).rejects.toThrow('inconsistent financial statements')
|
||||
expect(rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
|
||||
import { listAssets } from '@/lib/bokslut/assets/asset-service'
|
||||
@@ -85,7 +84,7 @@ export async function buildArsredovisningData(
|
||||
.range(from, to),
|
||||
),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeYearEndClosing: true }),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeFinalClosingEntry: true }),
|
||||
// Load persisted narrative overrides: replaces the URL-query-param
|
||||
// carry from earlier phases. Caller-supplied overrides (passed in via
|
||||
// the second arg) still win, so the API can layer per-request edits on
|
||||
@@ -134,8 +133,11 @@ export async function buildArsredovisningData(
|
||||
try {
|
||||
const [prevFull, prevPreClosing] = await Promise.all([
|
||||
generateTrialBalance(supabase, companyId, prevPeriodRow.id),
|
||||
// Comparative RR figures need the same statutory view as the current
|
||||
// year: keep booked depreciation, appropriations, and tax, excluding
|
||||
// only the linked final result-closing entry.
|
||||
generateTrialBalance(supabase, companyId, prevPeriodRow.id, {
|
||||
excludeYearEndClosing: true,
|
||||
excludeFinalClosingEntry: true,
|
||||
}),
|
||||
])
|
||||
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
|
||||
@@ -169,7 +171,7 @@ export async function buildArsredovisningData(
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
(periodList ?? []) as Array<{ id: string; name: string; period_start: string; period_end: string }>,
|
||||
accountingFramework,
|
||||
mapping,
|
||||
)
|
||||
|
||||
const egen_kapital_changes = buildEquityChanges(mapping)
|
||||
@@ -402,12 +404,21 @@ interface PeriodRow {
|
||||
period_end: string
|
||||
}
|
||||
|
||||
export function calculateSoliditet(mapping: K2MappingResult): number | null {
|
||||
const totalAssets = mapping.totals.tillgangar.current
|
||||
if (totalAssets <= 0) return null
|
||||
const adjustedEquity =
|
||||
mapping.totals.egetKapital.current +
|
||||
mapping.totals.obeskattadeReserver.current * (1 - LATENT_TAX_DEFAULT_RATE)
|
||||
return Math.round((adjustedEquity / totalAssets) * 1000) / 10
|
||||
}
|
||||
|
||||
async function buildFlerarsoversikt(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
currentPeriodId: string,
|
||||
allPeriods: PeriodRow[],
|
||||
accountingFramework: AccountingFramework,
|
||||
currentMapping: K2MappingResult,
|
||||
): Promise<FlerarsoversiktRow[]> {
|
||||
// Take the current period + 3 prior (oldest first).
|
||||
const sorted = [...allPeriods].sort((a, b) => a.period_start.localeCompare(b.period_start))
|
||||
@@ -418,46 +429,23 @@ async function buildFlerarsoversikt(
|
||||
const rows: FlerarsoversiktRow[] = []
|
||||
for (const p of slice) {
|
||||
try {
|
||||
const [is, tb] = await Promise.all([
|
||||
generateIncomeStatement(supabase, companyId, p.id),
|
||||
generateTrialBalance(supabase, companyId, p.id),
|
||||
])
|
||||
// Nettoomsättning = sum of revenue sections (revenue is normally credit).
|
||||
const netRevenue = is.total_revenue
|
||||
const resultAfterFinancial = is.total_revenue - is.total_expenses + is.total_financial
|
||||
const totalAssets = tb.rows
|
||||
.filter((r) => r.account_class === 1)
|
||||
.reduce((s, r) => s + (r.closing_debit - r.closing_credit), 0)
|
||||
const eqLiab = tb.rows
|
||||
.filter((r) => r.account_class === 2)
|
||||
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
|
||||
// Soliditet differs by framework:
|
||||
// K2 (ÅRL / BFNAR 2016:10): 20xx only. 21xx (periodiseringsfonder,
|
||||
// överavskrivningar) are obeskattade reserver: partially deferred
|
||||
// tax, not equity. Including 21xx would inflate soliditet for any AB
|
||||
// that posts dispositions.
|
||||
//
|
||||
// K3 (BFNAR 2012:1) splits 21xx into 79,4 % equity + 20,6 % latent
|
||||
// skatteskuld. Account 2240 holds the latent tax liability and is
|
||||
// already classified as a liability via class 2 / account_group 22,
|
||||
// so the soliditet add-on is just the equity portion of 21xx. (We
|
||||
// do NOT double-count 2240 here: the trial balance row for 2240
|
||||
// already lives in eqLiab as a liability.)
|
||||
const baseEquity = tb.rows
|
||||
.filter((r) => r.account_number.startsWith('20'))
|
||||
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
|
||||
let equity = baseEquity
|
||||
if (accountingFramework === 'k3') {
|
||||
const obeskattadeReserver = tb.rows
|
||||
.filter((r) => r.account_number.startsWith('21'))
|
||||
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
|
||||
equity += obeskattadeReserver * (1 - LATENT_TAX_DEFAULT_RATE)
|
||||
let mapping = currentMapping
|
||||
if (p.id !== currentPeriodId) {
|
||||
const [tbFull, tbPreClosing] = await Promise.all([
|
||||
generateTrialBalance(supabase, companyId, p.id),
|
||||
generateTrialBalance(supabase, companyId, p.id, { excludeFinalClosingEntry: true }),
|
||||
])
|
||||
mapping = mapTrialBalancesToK2(
|
||||
{ full: tbFull.rows, preClosing: tbPreClosing.rows },
|
||||
null,
|
||||
)
|
||||
}
|
||||
const soliditet =
|
||||
totalAssets > 0 ? Math.round((equity / totalAssets) * 1000) / 10 : null
|
||||
// Avoid the unused-variable warning while leaving eqLiab computed for
|
||||
// future "Skulder" column expansion.
|
||||
void eqLiab
|
||||
const netRevenue = mapping.rr['Nettoomsattning']?.current ?? 0
|
||||
const resultAfterFinancial = mapping.totals.resultatEfterFinansiellaPoster.current
|
||||
// K2 flerårsöversikt defines soliditet as adjusted equity divided by
|
||||
// total assets. Adjusted equity includes the equity portion of untaxed
|
||||
// reserves even though those reserves remain a separate BR section.
|
||||
const soliditet = calculateSoliditet(mapping)
|
||||
rows.push({
|
||||
year: p.name,
|
||||
net_revenue: Math.round(netRevenue),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ArsredovisningData } from './types'
|
||||
import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
|
||||
import { buildBrRows, buildRrRows } from './statement-rows'
|
||||
import type {
|
||||
AnnualReportComplianceIssue,
|
||||
AnnualReportDisclosureState,
|
||||
@@ -39,6 +41,121 @@ function push(
|
||||
issues.push({ code, severity, section, message, remediation })
|
||||
}
|
||||
|
||||
function statementRowsEqual(
|
||||
left: ArsredovisningData['resultatrakning'],
|
||||
right: ArsredovisningData['resultatrakning'],
|
||||
): boolean {
|
||||
return left.length === right.length && left.every((row, index) => {
|
||||
const other = right[index]
|
||||
return (
|
||||
row.label === other.label &&
|
||||
row.semantic_key === other.semantic_key &&
|
||||
row.current === other.current &&
|
||||
row.previous === other.previous &&
|
||||
Boolean(row.is_total) === Boolean(other.is_total) &&
|
||||
Boolean(row.is_heading) === Boolean(other.is_heading) &&
|
||||
(row.indent ?? 0) === (other.indent ?? 0)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function validateStatementIntegrity(
|
||||
report: ArsredovisningData,
|
||||
ixbrl: IxbrlArsredovisningInput | null = null,
|
||||
): AnnualReportComplianceIssue[] {
|
||||
const issues: AnnualReportComplianceIssue[] = []
|
||||
if (
|
||||
Math.round(report.balansrakning.total_assets * 100) !==
|
||||
Math.round(report.balansrakning.total_equity_liabilities * 100)
|
||||
) {
|
||||
push(
|
||||
issues,
|
||||
'AR-BALANCE-MISMATCH',
|
||||
'error',
|
||||
'statements',
|
||||
'Balansräkningen balanserar inte i årsredovisningen.',
|
||||
)
|
||||
}
|
||||
|
||||
const incomeStatementResult = (
|
||||
report.resultatrakning.find(
|
||||
(row) => row.semantic_key === 'income_statement_result' && row.current !== null,
|
||||
)
|
||||
?? report.resultatrakning.find(
|
||||
(row) => row.label === 'Årets resultat' && row.current !== null,
|
||||
)
|
||||
)?.current ?? undefined
|
||||
const visibleBalanceSheetResult = (
|
||||
report.balansrakning.equity_liabilities.find(
|
||||
(row) => row.semantic_key === 'balance_sheet_current_year_result' && row.current !== null,
|
||||
)
|
||||
?? report.balansrakning.equity_liabilities.find(
|
||||
(row) => row.label === 'Årets resultat' && row.current !== null,
|
||||
)
|
||||
)?.current ?? undefined
|
||||
const dispositionResult =
|
||||
report.forvaltningsberattelse.resultatdisposition_amounts.current_year_result
|
||||
if (incomeStatementResult === undefined || visibleBalanceSheetResult === undefined) {
|
||||
push(
|
||||
issues,
|
||||
'AR-RESULT-MISSING',
|
||||
'error',
|
||||
'statements',
|
||||
'Resultat- eller balansräkningen saknar raden Årets resultat.',
|
||||
)
|
||||
} else if (
|
||||
Math.round(incomeStatementResult * 100) !== Math.round(visibleBalanceSheetResult * 100) ||
|
||||
Math.round(incomeStatementResult * 100) !== Math.round(dispositionResult * 100)
|
||||
) {
|
||||
push(
|
||||
issues,
|
||||
'AR-RESULT-MISMATCH',
|
||||
'error',
|
||||
'statements',
|
||||
'Årets resultat i resultaträkningen stämmer inte med årets resultat i balansräkningen.',
|
||||
'Kontrollera att årsredovisningen innehåller bokslutsdispositioner och skatt före resultatstängningen.',
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
report.resultatrakning.length === 0 ||
|
||||
report.balansrakning.assets.length === 0 ||
|
||||
report.balansrakning.equity_liabilities.length === 0
|
||||
) {
|
||||
push(
|
||||
issues,
|
||||
'AR-STATEMENTS-EMPTY',
|
||||
'error',
|
||||
'statements',
|
||||
'Resultat- eller balansräkningen saknar rader.',
|
||||
)
|
||||
}
|
||||
|
||||
if (ixbrl) {
|
||||
const ixbrlMapping = { rr: ixbrl.rr, br: ixbrl.br, totals: ixbrl.totals }
|
||||
const ixbrlIncomeRows = buildRrRows(ixbrlMapping)
|
||||
const ixbrlBalanceRows = buildBrRows(ixbrlMapping)
|
||||
if (
|
||||
!statementRowsEqual(report.resultatrakning, ixbrlIncomeRows) ||
|
||||
!statementRowsEqual(report.balansrakning.assets, ixbrlBalanceRows.assets) ||
|
||||
!statementRowsEqual(
|
||||
report.balansrakning.equity_liabilities,
|
||||
ixbrlBalanceRows.equityLiabilities,
|
||||
)
|
||||
) {
|
||||
push(
|
||||
issues,
|
||||
'AR-IXBRL-STATEMENT-MISMATCH',
|
||||
'error',
|
||||
'statements',
|
||||
'Beloppen i PDF-underlaget och iXBRL-underlaget stämmer inte överens.',
|
||||
'Skapa om årsredovisningen från ett oförändrat bokslut.',
|
||||
)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
export interface ValidateAnnualReportInput {
|
||||
report: ArsredovisningData
|
||||
profile: AnnualReportProfile
|
||||
@@ -154,18 +271,7 @@ export function validateAnnualReportCompleteness(
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
Math.round(report.balansrakning.total_assets * 100) !==
|
||||
Math.round(report.balansrakning.total_equity_liabilities * 100)
|
||||
) {
|
||||
push(
|
||||
issues,
|
||||
'AR-BALANCE-MISMATCH',
|
||||
'error',
|
||||
'statements',
|
||||
'Balansräkningen balanserar inte i årsredovisningen.',
|
||||
)
|
||||
}
|
||||
issues.push(...validateStatementIntegrity(report))
|
||||
if (
|
||||
report.previous_period &&
|
||||
(report.balansrakning.total_assets_previous === null ||
|
||||
@@ -179,9 +285,6 @@ export function validateAnnualReportCompleteness(
|
||||
'Jämförelsetal saknas trots att ett föregående räkenskapsår finns.',
|
||||
)
|
||||
}
|
||||
if (report.resultatrakning.length === 0 || report.balansrakning.assets.length === 0) {
|
||||
push(issues, 'AR-STATEMENTS-EMPTY', 'error', 'statements', 'Resultat- eller balansräkningen saknar rader.')
|
||||
}
|
||||
if (report.noter.length === 0) {
|
||||
push(issues, 'AR-NOTES-EMPTY', 'error', 'notes', 'Årsredovisningen saknar noter.')
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { buildArsredovisningData } from './build-data'
|
||||
import { listSignatureRequests } from './signature-service'
|
||||
import { getAnnualReportProfile } from './profile-service'
|
||||
import { evaluateAnnualReportEligibility } from './eligibility'
|
||||
import { validateAnnualReportCompleteness } from './completeness'
|
||||
import {
|
||||
validateAnnualReportCompleteness,
|
||||
validateStatementIntegrity,
|
||||
} from './completeness'
|
||||
import {
|
||||
ANNUAL_REPORT_SCHEMA_VERSION,
|
||||
type AnnualReportDisclosureState,
|
||||
@@ -113,7 +116,7 @@ export async function buildCanonicalAnnualReport(
|
||||
metrics,
|
||||
})
|
||||
const disclosures = disclosureState(report)
|
||||
const validation = validateAnnualReportCompleteness({
|
||||
let validation = validateAnnualReportCompleteness({
|
||||
report,
|
||||
profile,
|
||||
disclosures,
|
||||
@@ -133,6 +136,18 @@ export async function buildCanonicalAnnualReport(
|
||||
})
|
||||
}
|
||||
|
||||
const crossDocumentIssues = validateStatementIntegrity(report, ixbrl).filter(
|
||||
(issue) => issue.code === 'AR-IXBRL-STATEMENT-MISMATCH',
|
||||
)
|
||||
if (crossDocumentIssues.length > 0) {
|
||||
validation = {
|
||||
...validation,
|
||||
ok: false,
|
||||
error_count: validation.error_count + crossDocumentIssues.length,
|
||||
issues: [...validation.issues, ...crossDocumentIssues],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: ANNUAL_REPORT_SCHEMA_VERSION,
|
||||
generated_at: options.generatedAt ?? new Date().toISOString(),
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { K2MappingResult } from '@/lib/bokslut/ixbrl/k2-mapper'
|
||||
import type { StatementRow } from './types'
|
||||
|
||||
const ZERO: ConceptAmount = { current: 0, previous: null }
|
||||
type StatementMapping = Pick<K2MappingResult, 'rr' | 'br' | 'totals'>
|
||||
|
||||
function hasValue(amount: ConceptAmount): boolean {
|
||||
return amount.current !== 0 || (amount.previous ?? 0) !== 0
|
||||
@@ -28,6 +29,7 @@ function hasValue(amount: ConceptAmount): boolean {
|
||||
|
||||
interface RowOptions {
|
||||
indent?: number
|
||||
semantic_key?: StatementRow['semantic_key']
|
||||
/** Presentational minus — show cost posts as negative. */
|
||||
displayMinus?: boolean
|
||||
/** Emit the row even when zero in both years (statutory always-visible posts). */
|
||||
@@ -64,6 +66,7 @@ class RowBuilder {
|
||||
label,
|
||||
current: sign * amount.current,
|
||||
previous: this.hasPrevious ? sign * (amount.previous ?? 0) : null,
|
||||
...(opts.semantic_key ? { semantic_key: opts.semantic_key } : {}),
|
||||
...(isTotal ? { is_total: true } : {}),
|
||||
...(opts.indent ? { indent: opts.indent } : {}),
|
||||
}
|
||||
@@ -73,7 +76,7 @@ class RowBuilder {
|
||||
/** The mapper leaves `previous` null on every concept when the company has
|
||||
* no previous fiscal year; any concept with a number means a jämförelseår
|
||||
* exists. */
|
||||
function mappingHasPrevious(mapping: K2MappingResult): boolean {
|
||||
function mappingHasPrevious(mapping: StatementMapping): boolean {
|
||||
return mapping.totals.tillgangar.previous !== null
|
||||
}
|
||||
|
||||
@@ -81,7 +84,7 @@ function mappingHasPrevious(mapping: K2MappingResult): boolean {
|
||||
* Resultaträkning — kostnadsslagsindelad per ÅRL bilaga 2 / K2 risbs, in
|
||||
* uppställningsform order.
|
||||
*/
|
||||
export function buildRrRows(mapping: K2MappingResult): StatementRow[] {
|
||||
export function buildRrRows(mapping: StatementMapping): StatementRow[] {
|
||||
const { rr, totals } = mapping
|
||||
const b = new RowBuilder(mappingHasPrevious(mapping))
|
||||
|
||||
@@ -171,7 +174,9 @@ export function buildRrRows(mapping: K2MappingResult): StatementRow[] {
|
||||
b.heading('Skatter')
|
||||
b.post('Skatt på årets resultat', rr['SkattAretsResultat'], { indent: 1, displayMinus: true })
|
||||
b.post('Övriga skatter', rr['OvrigaSkatter'], { indent: 1, displayMinus: true })
|
||||
b.total('Årets resultat', totals.aretsResultat)
|
||||
b.total('Årets resultat', totals.aretsResultat, {
|
||||
semantic_key: 'income_statement_result',
|
||||
})
|
||||
|
||||
return b.rows
|
||||
}
|
||||
@@ -182,7 +187,7 @@ export function buildRrRows(mapping: K2MappingResult): StatementRow[] {
|
||||
* kortfristiga fordringar, kassa och bank, eget kapital and kortfristiga
|
||||
* skulder always render.
|
||||
*/
|
||||
export function buildBrRows(mapping: K2MappingResult): {
|
||||
export function buildBrRows(mapping: StatementMapping): {
|
||||
assets: StatementRow[]
|
||||
equityLiabilities: StatementRow[]
|
||||
} {
|
||||
@@ -356,7 +361,11 @@ export function buildBrRows(mapping: K2MappingResult): {
|
||||
e.heading('Fritt eget kapital', 1)
|
||||
e.post('Överkursfond', br['Overkursfond'], { indent: 2 })
|
||||
e.post('Balanserat resultat', br['BalanseratResultat'], { indent: 2, alwaysShow: true })
|
||||
e.post('Årets resultat', br['AretsResultatEgetKapital'], { indent: 2, alwaysShow: true })
|
||||
e.post('Årets resultat', br['AretsResultatEgetKapital'], {
|
||||
indent: 2,
|
||||
alwaysShow: true,
|
||||
semantic_key: 'balance_sheet_current_year_result',
|
||||
})
|
||||
e.total('Summa fritt eget kapital', totals.frittEgetKapital, { indent: 1 })
|
||||
e.total('Summa eget kapital', totals.egetKapital)
|
||||
if (hasValue(totals.obeskattadeReserver)) {
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface NoteEntry {
|
||||
*/
|
||||
export interface StatementRow {
|
||||
label: string
|
||||
/** Stable integrity key for rows whose legal meaning must not depend on the
|
||||
* localized presentation label. */
|
||||
semantic_key?: 'income_statement_result' | 'balance_sheet_current_year_result'
|
||||
/** Whole-SEK amount for the current year; null on heading rows. */
|
||||
current: number | null
|
||||
/** Previous-year amount (jämförelseår, ÅRL 3:5 §); null on heading rows
|
||||
@@ -95,6 +98,7 @@ export interface ArsredovisningData {
|
||||
resultatdisposition_amounts: {
|
||||
retained_earnings: number
|
||||
share_premium_reserve: number
|
||||
/** Server-derived from the statutory statement mapping, never narrative input. */
|
||||
current_year_result: number
|
||||
total: number
|
||||
proposed_dividend: number
|
||||
|
||||
@@ -6,6 +6,11 @@ import type {
|
||||
CanonicalAnnualReport,
|
||||
} from './compliance-types'
|
||||
import { getEntryPoint } from '@/lib/bokslut/ixbrl/taxonomy/entry-points'
|
||||
import { validateStatementIntegrity } from './completeness'
|
||||
|
||||
export function hasStatementIntegrityErrors(model: CanonicalAnnualReport): boolean {
|
||||
return validateStatementIntegrity(model.report, model.ixbrl).length > 0
|
||||
}
|
||||
|
||||
function stableValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stableValue)
|
||||
@@ -143,6 +148,9 @@ export async function createAnnualReportVersion(
|
||||
model: CanonicalAnnualReport,
|
||||
finalize: boolean,
|
||||
): Promise<AnnualReportVersionSummary> {
|
||||
if (hasStatementIntegrityErrors(model)) {
|
||||
throw new Error('Annual report has inconsistent financial statements and cannot be versioned')
|
||||
}
|
||||
if (finalize && !model.validation.ok) {
|
||||
throw new Error('Annual report has blocking validation errors and cannot be finalized')
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* class 3-8 account is zeroed (equal debit/credit churn) and 2099
|
||||
* carries the year's result.
|
||||
* - `preClosing`: the same year WITHOUT the closing entry
|
||||
* (excludeYearEndClosing): RR accounts still open, 2099 only carries
|
||||
* (excludeFinalClosingEntry): RR accounts still open, 2099 only carries
|
||||
* the prior-year churn from the resultatdisposition entry.
|
||||
*/
|
||||
|
||||
|
||||
@@ -82,6 +82,42 @@ describe('generateK2IxbrlDocument', () => {
|
||||
expect(xhtml).toMatch(/contextRef="balans1" name="se-gen-base:Tillgangar"[^>]*>253 000/)
|
||||
})
|
||||
|
||||
it('emits account 7833 depreciation and debit 2650 as the correct iXBRL facts', () => {
|
||||
const balance = (account: string, name: string, debit: number, credit: number) => ({
|
||||
account_number: account,
|
||||
account_name: name,
|
||||
closing_debit: debit,
|
||||
closing_credit: credit,
|
||||
})
|
||||
const full = [
|
||||
balance('1250', 'Computers', 50, 0),
|
||||
balance('1259', 'Accumulated depreciation', 0, 10),
|
||||
balance('1930', 'Bank', 75, 0),
|
||||
balance('2081', 'Share capital', 0, 50),
|
||||
balance('2099', 'Current-year result', 0, 90),
|
||||
balance('2650', 'VAT settlement account', 25, 0),
|
||||
]
|
||||
const preClosing = [
|
||||
...full.filter((row) => row.account_number !== '2099'),
|
||||
balance('3010', 'Revenue', 0, 100),
|
||||
balance('7833', 'Depreciation of computers', 10, 0),
|
||||
]
|
||||
const mapping = mapTrialBalancesToK2({ full, preClosing }, null)
|
||||
const input = makeInput()
|
||||
input.rr = mapping.rr
|
||||
input.br = mapping.br
|
||||
input.totals = mapping.totals
|
||||
|
||||
const { xhtml: reportedAccountsXhtml } = generateK2IxbrlDocument(input)
|
||||
|
||||
expect(reportedAccountsXhtml).toMatch(
|
||||
/name="se-gen-base:AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar"[^>]*>10<\/ix:nonFraction>/,
|
||||
)
|
||||
expect(reportedAccountsXhtml).toMatch(
|
||||
/name="se-gen-base:OvrigaFordringarKortfristiga"[^>]*>25<\/ix:nonFraction>/,
|
||||
)
|
||||
})
|
||||
|
||||
it('tags the underskrifter tuple with per-signer dates (TA §2.9.1)', () => {
|
||||
expect(xhtml).toContain('se-gaap-ext:UnderskriftArsredovisningForetradareTuple')
|
||||
const tilltalsnamn = xhtml.match(/name="se-gen-base:UnderskriftHandlingTilltalsnamn"/g) ?? []
|
||||
|
||||
@@ -92,6 +92,144 @@ describe('mapTrialBalancesToK2', () => {
|
||||
expect(result.totals.aretsResultat.current).toBe(result.br['AretsResultatEgetKapital'].current)
|
||||
})
|
||||
|
||||
it('reclassifies tax and VAT balances by economic sign', () => {
|
||||
const rows = [
|
||||
row('1930', 'Bank', 100, 0),
|
||||
row('1630', 'Tax account', 0, 20),
|
||||
row('2518', 'Paid preliminary tax', 30, 0),
|
||||
row('2641', 'Input VAT', 5, 0),
|
||||
row('2081', 'Share capital', 0, 115),
|
||||
]
|
||||
|
||||
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
|
||||
|
||||
expect(res.br['OvrigaFordringarKortfristiga'].current).toBe(35)
|
||||
expect(res.br['Skatteskulder'].current).toBe(20)
|
||||
expect(res.br['OvrigaKortfristigaSkulder'].current).toBe(0)
|
||||
expect(res.totals.tillgangar.current).toBe(135)
|
||||
expect(res.totals.egetKapitalSkulder.current).toBe(135)
|
||||
expect(res.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('1630-1659'),
|
||||
expect.stringContaining('2500-2599'),
|
||||
expect.stringContaining('2610-2659'),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('maps account 7833 depreciation from the statutory pre-closing balance', () => {
|
||||
const full = [
|
||||
row('1250', 'Computers', 50, 0),
|
||||
row('1259', 'Accumulated depreciation', 0, 10),
|
||||
row('1930', 'Bank', 100, 0),
|
||||
row('2081', 'Share capital', 0, 50),
|
||||
row('2099', 'Current-year result', 0, 90),
|
||||
]
|
||||
const preClosing = [
|
||||
...full.filter((balance) => balance.account_number !== '2099'),
|
||||
row('3010', 'Revenue', 0, 100),
|
||||
row('7833', 'Depreciation of computers', 10, 0),
|
||||
]
|
||||
|
||||
const res = mapTrialBalancesToK2({ full, preClosing }, null)
|
||||
|
||||
expect(
|
||||
res.rr['AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar']
|
||||
.current,
|
||||
).toBe(10)
|
||||
expect(res.totals.aretsResultat.current).toBe(90)
|
||||
expect(res.br['AretsResultatEgetKapital'].current).toBe(90)
|
||||
expect(res.totals.tillgangar.current).toBe(res.totals.egetKapitalSkulder.current)
|
||||
})
|
||||
|
||||
it('presents a debit on account 2650 as a receivable for each comparison year', () => {
|
||||
const current = [
|
||||
row('1930', 'Bank', 75, 0),
|
||||
row('2081', 'Share capital', 0, 100),
|
||||
row('2650', 'VAT settlement account', 25, 0),
|
||||
]
|
||||
const previous = [
|
||||
row('1930', 'Bank', 100, 0),
|
||||
row('2081', 'Share capital', 0, 75),
|
||||
row('2650', 'VAT settlement account', 0, 25),
|
||||
]
|
||||
|
||||
const res = mapTrialBalancesToK2(
|
||||
{ full: current, preClosing: current },
|
||||
{ full: previous, preClosing: previous },
|
||||
)
|
||||
|
||||
expect(res.br['OvrigaFordringarKortfristiga']).toEqual({ current: 25, previous: 0 })
|
||||
expect(res.br['OvrigaKortfristigaSkulder']).toEqual({ current: 0, previous: 25 })
|
||||
expect(res.totals.tillgangar).toEqual({ current: 100, previous: 100 })
|
||||
expect(res.totals.egetKapitalSkulder).toEqual({ current: 100, previous: 100 })
|
||||
})
|
||||
|
||||
it('nets paid preliminary tax against the current tax liability', () => {
|
||||
const rows = [
|
||||
row('1930', 'Bank', 100, 0),
|
||||
row('2512', 'Current tax', 0, 123.18),
|
||||
row('2518', 'Paid preliminary tax', 23.18, 0),
|
||||
]
|
||||
|
||||
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
|
||||
|
||||
expect(res.br['Skatteskulder'].current).toBe(100)
|
||||
expect(res.br['OvrigaFordringarKortfristiga'].current).toBe(0)
|
||||
})
|
||||
|
||||
it('nets domestic VAT without offsetting excise duty', () => {
|
||||
const rows = [
|
||||
row('1930', 'Bank', 15, 0),
|
||||
row('2611', 'Output VAT', 0, 50),
|
||||
row('2641', 'Input VAT', 75, 0),
|
||||
row('2660', 'Excise duty', 0, 40),
|
||||
]
|
||||
|
||||
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
|
||||
|
||||
expect(res.br['OvrigaFordringarKortfristiga'].current).toBe(25)
|
||||
expect(res.br['OvrigaKortfristigaSkulder'].current).toBe(40)
|
||||
expect(res.totals.tillgangar.current).toBe(40)
|
||||
expect(res.totals.egetKapitalSkulder.current).toBe(40)
|
||||
})
|
||||
|
||||
it('reclassifies a previous-year tax-account credit independently', () => {
|
||||
const current = [
|
||||
row('1930', 'Bank', 100, 0),
|
||||
row('2081', 'Share capital', 0, 100),
|
||||
]
|
||||
const previous = [
|
||||
row('1930', 'Bank', 20, 0),
|
||||
row('1630', 'Tax account', 0, 20),
|
||||
]
|
||||
|
||||
const res = mapTrialBalancesToK2(
|
||||
{ full: current, preClosing: current },
|
||||
{ full: previous, preClosing: previous },
|
||||
)
|
||||
|
||||
expect(res.br['OvrigaFordringarKortfristiga']).toEqual({ current: 0, previous: 0 })
|
||||
expect(res.br['Skatteskulder']).toEqual({ current: 0, previous: 20 })
|
||||
expect(res.warnings).toContainEqual(expect.stringContaining('1630-1659'))
|
||||
})
|
||||
|
||||
it('does not offset a tax-account liability against a separate tax receivable', () => {
|
||||
const rows = [
|
||||
row('1630', 'Tax account', 0, 100),
|
||||
row('1650', 'VAT receivable', 100, 0),
|
||||
row('2081', 'Share capital', 0, 100),
|
||||
row('1930', 'Bank', 100, 0),
|
||||
]
|
||||
|
||||
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
|
||||
|
||||
expect(res.br['OvrigaFordringarKortfristiga'].current).toBe(100)
|
||||
expect(res.br['Skatteskulder'].current).toBe(100)
|
||||
expect(res.totals.tillgangar.current).toBe(200)
|
||||
expect(res.totals.egetKapitalSkulder.current).toBe(200)
|
||||
})
|
||||
|
||||
// Regression for the year-end-closing split: a realistic post-bokslut TB
|
||||
// pair must yield NON-ZERO RR concepts (from the pre-closing TB) AND a BR
|
||||
// that ties (from the full TB). Mapping a single TB can never do both: the
|
||||
@@ -165,11 +303,11 @@ describe('mapTrialBalancesToK2', () => {
|
||||
})
|
||||
|
||||
describe('mapTrialBalancesToK2: öre-rounding residual smoothing', () => {
|
||||
it('absorbs a ±1 kr BR residual into the largest equity/liability post', () => {
|
||||
it('absorbs a ±1 kr BR residual into a post with an exact öre balance', () => {
|
||||
// Assets round UP twice (.50 each), liabilities round once up once down:
|
||||
// rounded Tillgångar 202 vs rounded EK+skulder 201 although the TB ties
|
||||
// exactly at 201,00. The +1 residual lands in the largest post on the
|
||||
// equity/liabilities side (Leverantörsskulder).
|
||||
// exactly at 201,00. The +1 residual lands on a fractional post, never
|
||||
// on an unrelated exact whole-krona balance.
|
||||
const rows = [
|
||||
row('1510', 'Kundfordringar', 100.5, 0),
|
||||
row('1930', 'Bank', 100.5, 0),
|
||||
@@ -177,9 +315,10 @@ describe('mapTrialBalancesToK2: öre-rounding residual smoothing', () => {
|
||||
row('2510', 'Skatteskulder', 0, 100.25),
|
||||
]
|
||||
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
|
||||
expect(res.totals.tillgangar.current).toBe(202)
|
||||
expect(res.totals.egetKapitalSkulder.current).toBe(202)
|
||||
expect(res.br['Leverantorsskulder'].current).toBe(102)
|
||||
expect(res.totals.tillgangar.current).toBe(201)
|
||||
expect(res.totals.egetKapitalSkulder.current).toBe(201)
|
||||
expect(res.br['Kundfordringar'].current).toBe(100)
|
||||
expect(res.br['Leverantorsskulder'].current).toBe(101)
|
||||
expect(res.br['Skatteskulder'].current).toBe(100)
|
||||
expect(res.warnings).toEqual([])
|
||||
})
|
||||
@@ -207,7 +346,64 @@ describe('mapTrialBalancesToK2: öre-rounding residual smoothing', () => {
|
||||
expect(res.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves residuals beyond ±1 kr alone and reports them', () => {
|
||||
it('distributes a multi-krona BR residual over fractional posts', () => {
|
||||
const rows = [
|
||||
row('1510', 'Trade receivable', 0.5, 0),
|
||||
row('1630', 'Tax account', 0.5, 0),
|
||||
row('1710', 'Prepaid expense', 0.5, 0),
|
||||
row('1810', 'Short-term investment', 0.5, 0),
|
||||
row('1930', 'Bank', 0.5, 0),
|
||||
row('2081', 'Share capital', 0, 2.5),
|
||||
]
|
||||
|
||||
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
|
||||
|
||||
expect(res.totals.tillgangar.current).toBe(3)
|
||||
expect(res.totals.egetKapitalSkulder.current).toBe(3)
|
||||
expect(res.warnings.some((warning) => warning.includes('3005'))).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes exact öre sums before whole-krona rounding', () => {
|
||||
const rows = [
|
||||
row('1510', 'Trade receivable', 0.03, 0),
|
||||
row('1710', 'Prepaid expense', 0.29, 0),
|
||||
row('1930', 'Bank', 0.18, 0),
|
||||
row('2081', 'Share capital', 0, 0.5),
|
||||
]
|
||||
|
||||
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
|
||||
|
||||
expect(res.totals.tillgangar.current).toBe(1)
|
||||
expect(res.totals.egetKapitalSkulder.current).toBe(1)
|
||||
expect(res.warnings.some((warning) => warning.includes('3005'))).toBe(false)
|
||||
})
|
||||
|
||||
it('distributes a multi-krona RR residual over fractional posts', () => {
|
||||
const incomeRows = [
|
||||
row('3010', 'Revenue', 0, 0.5),
|
||||
row('3810', 'Capitalized work', 0, 0.5),
|
||||
row('3910', 'Other income', 0, 0.5),
|
||||
row('8010', 'Group result', 0, 0.5),
|
||||
row('8310', 'Interest income', 0, 0.5),
|
||||
]
|
||||
const preClosing = [row('1930', 'Bank', 2.5, 0), ...incomeRows]
|
||||
const full = [
|
||||
row('1930', 'Bank', 2.5, 0),
|
||||
row('2099', 'Current result', 0, 2.5),
|
||||
...incomeRows.map((incomeRow) => ({
|
||||
...incomeRow,
|
||||
closing_debit: incomeRow.closing_credit,
|
||||
})),
|
||||
]
|
||||
|
||||
const res = mapTrialBalancesToK2({ full, preClosing }, null)
|
||||
|
||||
expect(res.totals.aretsResultat.current).toBe(3)
|
||||
expect(res.br['AretsResultatEgetKapital'].current).toBe(3)
|
||||
expect(res.warnings.some((warning) => warning.includes('2099'))).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves real bookkeeping differences alone and reports them', () => {
|
||||
const rows = [
|
||||
row('1930', 'Bank', 1_000, 0),
|
||||
row('2440', 'Leverantörsskulder', 0, 990),
|
||||
|
||||
@@ -58,8 +58,8 @@ export async function buildIxbrlInput(
|
||||
|
||||
// Two TB variants per year (see TrialBalancePair): the FULL trial balance
|
||||
// (year-end closing included → 2099 booked, class 3-8 zeroed) drives the
|
||||
// BR; the PRE-CLOSING trial balance (excludeYearEndClosing: the same split
|
||||
// lib/reports' generateIncomeStatement uses) drives the RR. A single TB can
|
||||
// BR; the PRE-CLOSING trial balance (excludeFinalClosingEntry) drives the
|
||||
// RR while retaining tax and appropriations. A single TB can
|
||||
// never serve both: with bokslut booked every RR concept would map to 0,
|
||||
// without it the BR would not tie.
|
||||
const [pdfData, periodRow, currentTbFull, currentTbPreClosing, signatureRequests] =
|
||||
@@ -72,7 +72,7 @@ export async function buildIxbrlInput(
|
||||
.eq('company_id', companyId)
|
||||
.single(),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeYearEndClosing: true }),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeFinalClosingEntry: true }),
|
||||
options.signatureRequests ?? listSignatureRequests(supabase, companyId, fiscalPeriodId),
|
||||
])
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function buildIxbrlInput(
|
||||
try {
|
||||
const [prevFull, prevPreClosing] = await Promise.all([
|
||||
generateTrialBalance(supabase, companyId, prev.id),
|
||||
generateTrialBalance(supabase, companyId, prev.id, { excludeYearEndClosing: true }),
|
||||
generateTrialBalance(supabase, companyId, prev.id, { excludeFinalClosingEntry: true }),
|
||||
])
|
||||
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
|
||||
} catch {
|
||||
|
||||
+212
-67
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import type { ConceptAmount, ConceptAmounts } from './types'
|
||||
import { equalOre, roundOre, sumOre } from '@/lib/money'
|
||||
|
||||
export interface TrialBalanceRowLike {
|
||||
account_number: string
|
||||
@@ -29,7 +30,7 @@ export interface TrialBalanceRowLike {
|
||||
* never serve both statements:
|
||||
* - `full` (including the closing entry) carries the booked 2099 and the
|
||||
* correct equity: it drives the BR concepts.
|
||||
* - `preClosing` (generateTrialBalance with excludeYearEndClosing: true)
|
||||
* - `preClosing` (generateTrialBalance with excludeFinalClosingEntry: true)
|
||||
* still has the RR accounts open: it drives the RR concepts.
|
||||
* Mirrors how lib/reports' generateIncomeStatement/generateBalanceSheet split
|
||||
* the same source.
|
||||
@@ -51,6 +52,15 @@ interface PostMapping {
|
||||
ranges: Range[]
|
||||
}
|
||||
|
||||
interface SignReclassification {
|
||||
sourceConcept: string
|
||||
targetConcept: string
|
||||
balance: 'debit' | 'credit'
|
||||
ranges: Range[]
|
||||
mode: 'net' | 'deviating_rows'
|
||||
warning: string
|
||||
}
|
||||
|
||||
const r = (start: string, end: string): Range => ({ start, end })
|
||||
|
||||
/** RR: kostnadsslagsindelad (risbs), in uppställningsform order. */
|
||||
@@ -383,6 +393,42 @@ const RECLASSIFIED_ACCOUNTS: Record<string, string> = {
|
||||
'2089': 'Fond för utvecklingsutgifter (2089) redovisas under Reservfond: granska klassificeringen (K2 tillåter inte aktivering av egenupparbetade utgifter).',
|
||||
}
|
||||
|
||||
/**
|
||||
* Tax settlement and VAT accounts can carry the opposite economic balance
|
||||
* from their BAS class. K2 presentation follows the balance's substance:
|
||||
* a tax-account credit is a liability, while a net debit on tax or VAT
|
||||
* liability accounts is a current receivable.
|
||||
*/
|
||||
const SIGN_RECLASSIFICATIONS: SignReclassification[] = [
|
||||
{
|
||||
sourceConcept: 'OvrigaFordringarKortfristiga',
|
||||
targetConcept: 'Skatteskulder',
|
||||
balance: 'debit',
|
||||
ranges: [r('1630', '1659')],
|
||||
mode: 'deviating_rows',
|
||||
warning:
|
||||
'Skatte- och momsfordringskonton 1630-1659 har ett nettokreditsaldo och har därför redovisats som skatteskuld.',
|
||||
},
|
||||
{
|
||||
sourceConcept: 'Skatteskulder',
|
||||
targetConcept: 'OvrigaFordringarKortfristiga',
|
||||
balance: 'credit',
|
||||
ranges: [r('2500', '2599')],
|
||||
mode: 'net',
|
||||
warning:
|
||||
'Skatteskuldkonton 2500-2599 har ett nettodebetsaldo och har därför redovisats som övrig fordran.',
|
||||
},
|
||||
{
|
||||
sourceConcept: 'OvrigaKortfristigaSkulder',
|
||||
targetConcept: 'OvrigaFordringarKortfristiga',
|
||||
balance: 'credit',
|
||||
ranges: [r('2610', '2659')],
|
||||
mode: 'net',
|
||||
warning:
|
||||
'Momsavräkningskonton 2610-2659 har ett nettodebetsaldo och har därför redovisats som övrig fordran.',
|
||||
},
|
||||
]
|
||||
|
||||
export interface K2MappingResult {
|
||||
rr: ConceptAmounts
|
||||
br: ConceptAmounts
|
||||
@@ -421,7 +467,7 @@ export interface K2MappingResult {
|
||||
}
|
||||
|
||||
function netBalance(row: TrialBalanceRowLike, orientation: 'debit' | 'credit'): number {
|
||||
const net = row.closing_debit - row.closing_credit
|
||||
const net = roundOre(row.closing_debit - row.closing_credit)
|
||||
return orientation === 'debit' ? net : -net
|
||||
}
|
||||
|
||||
@@ -429,34 +475,80 @@ function inRanges(account: string, ranges: Range[]): boolean {
|
||||
return ranges.some((range) => account >= range.start && account <= range.end)
|
||||
}
|
||||
|
||||
function sumForMapping(rows: TrialBalanceRowLike[], mapping: PostMapping): number {
|
||||
let total = 0
|
||||
for (const row of rows) {
|
||||
if (inRanges(row.account_number, mapping.ranges)) {
|
||||
total += netBalance(row, mapping.balance)
|
||||
}
|
||||
}
|
||||
return Math.round(total)
|
||||
function exactSumForMapping(rows: TrialBalanceRowLike[], mapping: PostMapping): number {
|
||||
return sumOre(
|
||||
rows
|
||||
.filter((row) => inRanges(row.account_number, mapping.ranges))
|
||||
.map((row) => netBalance(row, mapping.balance)),
|
||||
)
|
||||
}
|
||||
|
||||
function amount(
|
||||
function roundWhole(amount: number): number {
|
||||
const normalized = roundOre(amount)
|
||||
const rounded = Math.round(Math.abs(normalized))
|
||||
return rounded === 0 ? 0 : Math.sign(normalized) * rounded
|
||||
}
|
||||
|
||||
function exactAmount(
|
||||
mapping: PostMapping,
|
||||
current: TrialBalanceRowLike[],
|
||||
previous: TrialBalanceRowLike[] | null,
|
||||
): ConceptAmount {
|
||||
return {
|
||||
current: sumForMapping(current, mapping),
|
||||
previous: previous ? sumForMapping(previous, mapping) : null,
|
||||
current: exactSumForMapping(current, mapping),
|
||||
previous: previous ? exactSumForMapping(previous, mapping) : null,
|
||||
}
|
||||
}
|
||||
|
||||
function deviatingRowsTotal(
|
||||
rows: TrialBalanceRowLike[],
|
||||
rule: SignReclassification,
|
||||
): number {
|
||||
return sumOre(
|
||||
rows
|
||||
.filter((row) => inRanges(row.account_number, rule.ranges))
|
||||
.map((row) => netBalance(row, rule.balance))
|
||||
.filter((balance) => balance < 0),
|
||||
)
|
||||
}
|
||||
|
||||
function applySignReclassifications(
|
||||
br: ConceptAmounts,
|
||||
current: TrialBalanceRowLike[],
|
||||
previous: TrialBalanceRowLike[] | null,
|
||||
warnings: string[],
|
||||
): void {
|
||||
for (const rule of SIGN_RECLASSIFICATIONS) {
|
||||
let reclassified = false
|
||||
for (const field of ['current', 'previous'] as const) {
|
||||
const rows = field === 'current' ? current : previous
|
||||
if (!rows) continue
|
||||
const deviatingBalance =
|
||||
rule.mode === 'deviating_rows'
|
||||
? deviatingRowsTotal(rows, rule)
|
||||
: exactSumForMapping(rows, {
|
||||
concept: rule.sourceConcept,
|
||||
balance: rule.balance,
|
||||
ranges: rule.ranges,
|
||||
})
|
||||
if (deviatingBalance >= 0) continue
|
||||
|
||||
const amountToMove = -deviatingBalance
|
||||
adjustConcept(br, rule.sourceConcept, field, amountToMove)
|
||||
adjustConcept(br, rule.targetConcept, field, amountToMove)
|
||||
reclassified = true
|
||||
}
|
||||
if (reclassified) warnings.push(rule.warning)
|
||||
}
|
||||
}
|
||||
|
||||
function add(a: ConceptAmount, b: ConceptAmount, sign = 1): ConceptAmount {
|
||||
return {
|
||||
current: a.current + sign * b.current,
|
||||
current: roundOre(a.current + sign * b.current),
|
||||
previous:
|
||||
a.previous === null && b.previous === null
|
||||
? null
|
||||
: (a.previous ?? 0) + sign * (b.previous ?? 0),
|
||||
: roundOre((a.previous ?? 0) + sign * (b.previous ?? 0)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,13 +578,32 @@ export function mapTrialBalancesToK2(
|
||||
): K2MappingResult {
|
||||
const warnings: string[] = []
|
||||
const rr: ConceptAmounts = {}
|
||||
const rrExact: ConceptAmounts = {}
|
||||
const br: ConceptAmounts = {}
|
||||
const brExact: ConceptAmounts = {}
|
||||
|
||||
for (const mapping of K2_RR_MAPPINGS) {
|
||||
rr[mapping.concept] = amount(mapping, current.preClosing, previous?.preClosing ?? null)
|
||||
rrExact[mapping.concept] = exactAmount(
|
||||
mapping,
|
||||
current.preClosing,
|
||||
previous?.preClosing ?? null,
|
||||
)
|
||||
const exact = rrExact[mapping.concept]
|
||||
rr[mapping.concept] = {
|
||||
current: roundWhole(exact.current),
|
||||
previous: exact.previous === null ? null : roundWhole(exact.previous),
|
||||
}
|
||||
}
|
||||
for (const mapping of K2_BR_MAPPINGS) {
|
||||
br[mapping.concept] = amount(mapping, current.full, previous?.full ?? null)
|
||||
brExact[mapping.concept] = exactAmount(mapping, current.full, previous?.full ?? null)
|
||||
}
|
||||
applySignReclassifications(brExact, current.full, previous?.full ?? null, warnings)
|
||||
for (const mapping of K2_BR_MAPPINGS) {
|
||||
const exact = brExact[mapping.concept]
|
||||
br[mapping.concept] = {
|
||||
current: roundWhole(exact.current),
|
||||
previous: exact.previous === null ? null : roundWhole(exact.previous),
|
||||
}
|
||||
}
|
||||
|
||||
// Reclassification + unmapped sweep over balance-carrying accounts. Both TB
|
||||
@@ -536,21 +647,20 @@ export function mapTrialBalancesToK2(
|
||||
// tagged totals exactly (kontrollera 3005), so a ±1 kr residual is
|
||||
// distributed back into a line item instead of tolerated. Deterministic
|
||||
// rule, per year:
|
||||
// - BR: the residual (Tillgångar − Eget kapital och skulder) is added to
|
||||
// the largest post (by absolute value) on the equity/liabilities side,
|
||||
// excluding AretsResultatEgetKapital, whose value must stay equal to
|
||||
// the booked 2099 / RR result (ties broken toward the LATER post in
|
||||
// the uppställningsform, so liabilities win over aktiekapital).
|
||||
// - BR: each side is reconciled to its rounded exact total. The residual
|
||||
// is assigned only to a post with an exact öre amount on that side.
|
||||
// Exact whole-krona posts, such as a booked reserve, are never changed.
|
||||
// - RR: the residual (RR-resultat − konto 2099) is absorbed by the
|
||||
// largest RR post: cost posts are increased by the residual, income
|
||||
// posts decreased (ties broken toward the EARLIER post).
|
||||
// Residuals beyond ±1 kr are real bookkeeping errors and are left for the
|
||||
// exact balance checks below.
|
||||
// Multi-krona residuals are distributed over multiple fractional posts.
|
||||
// A residual without enough fractional posts is left for the exact balance
|
||||
// checks below instead of changing a booked whole-krona amount.
|
||||
let smoothedAny = false
|
||||
for (const field of ['current', 'previous'] as const) {
|
||||
if (field === 'previous' && previous === null) continue
|
||||
const rrSmoothed = smoothRrResidual(rr, br, totals, field)
|
||||
const brSmoothed = smoothBrResidual(br, totals, field)
|
||||
const rrSmoothed = smoothRrResidual(rr, rrExact, br, brExact, totals, field)
|
||||
const brSmoothed = smoothBrResidual(br, brExact, totals, field)
|
||||
smoothedAny = smoothedAny || rrSmoothed || brSmoothed
|
||||
}
|
||||
if (smoothedAny) totals = computeTotals(rr, br)
|
||||
@@ -574,28 +684,6 @@ export function mapTrialBalancesToK2(
|
||||
return { rr, br, totals, warnings, unmappedAccounts }
|
||||
}
|
||||
|
||||
function pickLargestConcept(
|
||||
amounts: ConceptAmounts,
|
||||
mappings: PostMapping[],
|
||||
field: 'current' | 'previous',
|
||||
exclude: ReadonlySet<string>,
|
||||
tieBreak: 'first' | 'last',
|
||||
): string | null {
|
||||
let best: string | null = null
|
||||
let bestAbs = -1
|
||||
for (const mapping of mappings) {
|
||||
if (exclude.has(mapping.concept)) continue
|
||||
const value = amounts[mapping.concept]?.[field]
|
||||
if (value === null || value === undefined || value === 0) continue
|
||||
const abs = Math.abs(value)
|
||||
if (abs > bestAbs || (abs === bestAbs && tieBreak === 'last')) {
|
||||
best = mapping.concept
|
||||
bestAbs = abs
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function adjustConcept(
|
||||
amounts: ConceptAmounts,
|
||||
concept: string,
|
||||
@@ -603,13 +691,15 @@ function adjustConcept(
|
||||
delta: number,
|
||||
): void {
|
||||
const existing = amounts[concept] ?? { current: 0, previous: null }
|
||||
amounts[concept] = { ...existing, [field]: (existing[field] ?? 0) + delta }
|
||||
amounts[concept] = { ...existing, [field]: roundOre((existing[field] ?? 0) + delta) }
|
||||
}
|
||||
|
||||
/** Absorb a ±1 kr rounding residual between the RR result and BR 2099. */
|
||||
function smoothRrResidual(
|
||||
rr: ConceptAmounts,
|
||||
rrExact: ConceptAmounts,
|
||||
br: ConceptAmounts,
|
||||
brExact: ConceptAmounts,
|
||||
totals: K2MappingResult['totals'],
|
||||
field: 'current' | 'previous',
|
||||
): boolean {
|
||||
@@ -617,39 +707,94 @@ function smoothRrResidual(
|
||||
const result = totals.aretsResultat[field]
|
||||
if (target === null || target === undefined || result === null) return false
|
||||
const diff = result - target
|
||||
if (diff === 0 || Math.abs(diff) > 1) return false
|
||||
const concept = pickLargestConcept(rr, K2_RR_MAPPINGS, field, new Set(), 'first')
|
||||
if (!concept) return false
|
||||
const balance = K2_RR_MAPPINGS.find((mapping) => mapping.concept === concept)?.balance
|
||||
// Debit (cost) posts enter the result with weight −1, credit (income)
|
||||
// posts with +1: adjust so the recomputed result lands on the 2099 value.
|
||||
adjustConcept(rr, concept, field, balance === 'debit' ? diff : -diff)
|
||||
if (diff === 0) return false
|
||||
|
||||
const exactResult = computeTotals(rrExact, brExact).aretsResultat[field]
|
||||
const exactTarget = brExact['AretsResultatEgetKapital']?.[field]
|
||||
if (exactResult === null || exactTarget === null || exactTarget === undefined) return false
|
||||
if (!equalOre(exactResult, exactTarget)) return false
|
||||
|
||||
const direction = Math.sign(diff)
|
||||
const candidates = K2_RR_MAPPINGS.flatMap((mapping, index) => {
|
||||
const rounded = rr[mapping.concept]?.[field]
|
||||
const exact = rrExact[mapping.concept]?.[field]
|
||||
if (rounded === null || rounded === undefined || exact === null || exact === undefined) return []
|
||||
if (Math.abs(exact - rounded) < 0.000001) return []
|
||||
const delta = mapping.balance === 'debit' ? direction : -direction
|
||||
return [{ concept: mapping.concept, delta, error: Math.abs(rounded + delta - exact), index }]
|
||||
}).sort((left, right) => left.error - right.error || left.index - right.index)
|
||||
if (candidates.length < Math.abs(diff)) return false
|
||||
for (const candidate of candidates.slice(0, Math.abs(diff))) {
|
||||
adjustConcept(rr, candidate.concept, field, candidate.delta)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Equity/liability-side posts (everything from Aktiekapital onwards). */
|
||||
const FIRST_EQ_LIAB_MAPPING_INDEX = K2_BR_MAPPINGS.findIndex(
|
||||
(mapping) => mapping.concept === 'Aktiekapital',
|
||||
)
|
||||
const ASSET_MAPPINGS = K2_BR_MAPPINGS.slice(0, FIRST_EQ_LIAB_MAPPING_INDEX)
|
||||
const EQ_LIAB_MAPPINGS = K2_BR_MAPPINGS.slice(
|
||||
K2_BR_MAPPINGS.findIndex((mapping) => mapping.concept === 'Aktiekapital'),
|
||||
FIRST_EQ_LIAB_MAPPING_INDEX,
|
||||
)
|
||||
|
||||
/** Absorb a ±1 kr rounding residual between the two BR sides. */
|
||||
/**
|
||||
* Reconcile each BR side to its own rounded exact total without changing exact
|
||||
* posts. Residuals must not be netted across sides: doing so could make the
|
||||
* balance check pass while leaving one reported side different from its exact
|
||||
* accounting total.
|
||||
*/
|
||||
function smoothBrResidual(
|
||||
br: ConceptAmounts,
|
||||
brExact: ConceptAmounts,
|
||||
totals: K2MappingResult['totals'],
|
||||
field: 'current' | 'previous',
|
||||
): boolean {
|
||||
const assets = totals.tillgangar[field]
|
||||
const eqLiab = totals.egetKapitalSkulder[field]
|
||||
if (assets === null || eqLiab === null) return false
|
||||
const diff = assets - eqLiab
|
||||
if (diff === 0 || Math.abs(diff) > 1) return false
|
||||
const concept =
|
||||
pickLargestConcept(br, EQ_LIAB_MAPPINGS, field, new Set(['AretsResultatEgetKapital']), 'last') ??
|
||||
'BalanseratResultat'
|
||||
// All equity/liability posts are credit-oriented: adding the residual
|
||||
// raises the eget kapital och skulder side to match Tillgångar.
|
||||
adjustConcept(br, concept, field, diff)
|
||||
return true
|
||||
const exactTotals = computeTotals({}, brExact)
|
||||
const exactAssets = exactTotals.tillgangar[field]
|
||||
const exactEqLiab = exactTotals.egetKapitalSkulder[field]
|
||||
if (exactAssets === null || exactEqLiab === null) return false
|
||||
if (!equalOre(exactAssets, exactEqLiab)) return false
|
||||
|
||||
const sides = [
|
||||
{ mappings: ASSET_MAPPINGS, rounded: assets, target: roundWhole(exactAssets) },
|
||||
{ mappings: EQ_LIAB_MAPPINGS, rounded: eqLiab, target: roundWhole(exactEqLiab) },
|
||||
]
|
||||
const residuals = sides.map((side) => side.target - side.rounded)
|
||||
if (residuals.every((residual) => residual === 0)) return false
|
||||
const plans = sides.map((side, index) => {
|
||||
const residual = residuals[index]
|
||||
if (residual === 0) return []
|
||||
const direction = Math.sign(residual)
|
||||
const candidates = side.mappings.flatMap((mapping, mappingIndex) => {
|
||||
if (mapping.concept === 'AretsResultatEgetKapital') return []
|
||||
const rounded = br[mapping.concept]?.[field]
|
||||
const exact = brExact[mapping.concept]?.[field]
|
||||
if (rounded === null || rounded === undefined || exact === null || exact === undefined) return []
|
||||
if (Math.abs(exact - rounded) < 0.000001) return []
|
||||
return [{
|
||||
concept: mapping.concept,
|
||||
error: Math.abs(rounded + direction - exact),
|
||||
index: mappingIndex,
|
||||
}]
|
||||
}).sort((left, right) => left.error - right.error || left.index - right.index)
|
||||
if (candidates.length < Math.abs(residual)) return null
|
||||
return candidates.slice(0, Math.abs(residual)).map((candidate) => ({
|
||||
concept: candidate.concept,
|
||||
delta: direction,
|
||||
}))
|
||||
})
|
||||
if (plans.some((plan) => plan === null)) return false
|
||||
for (const plan of plans) {
|
||||
for (const adjustment of plan ?? []) {
|
||||
adjustConcept(br, adjustment.concept, field, adjustment.delta)
|
||||
}
|
||||
}
|
||||
return plans.some((plan) => (plan?.length ?? 0) > 0)
|
||||
}
|
||||
|
||||
function computeTotals(rr: ConceptAmounts, br: ConceptAmounts): K2MappingResult['totals'] {
|
||||
|
||||
Reference in New Issue
Block a user