fix(reports): stop the resultatavslut zeroing declarations, and make the mistake uninventable (#1293)
* fix(settings): explain why account deletion is blocked The delete-account button was disabled while the user still owned companies, but the reason only lived behind the "?" on the blocker row, so the greyed-out button read as broken. Surface it as one visible attn sentence directly under the button, and point aria-describedby at it whenever the button is disabled, not only on a load error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(enable-banking): share one PSD2 consent across a user's companies Connecting the same bank for a second company required a second BankID, and at SEB that new authorization silently revoked the first one. A user with four companies at one bank therefore signed four times a quarter and ended up with three dead feeds, each still rendering as "Aktiv" with a stale last_synced_at until someone pressed Synka. Prod says this is not one customer: every SEB customer holding connections in more than one company has had an earlier company stop syncing at the moment the next was authorized, most of them while the consent was still formally valid for weeks. The same measurement over other banks is far quieter, so the one-active-session-per-PSU limit is real and ASPSP-side. Enable Banking already supports the shape we want. POST /auth carries no account restriction, so a session covers every account the user ticked at the bank, and GET /accounts/{uid}/transactions takes no session id, so a second company can sync its own accounts from an existing session. bank_connections has no unique constraint on session_id, so this needs no migration. Adds lib/session-sharing.ts plus GET /reusable-sessions and POST /attach. When a live session in another of the user's companies still exposes accounts no company syncs, the settings panel offers to reuse it: the new row shares session_id and consent_expires, carries only the unclaimed accounts, and lands in pending_selection so the existing IBAN-aware account picker does the ledger mapping. Only the consent is shared; accounts, cash_accounts and transactions stay strictly per-company. Sharing a session changes three lifecycle paths, all handled here: - Disconnect and reconnect now refcount before revoking. A blind revoke would take down a sibling company's feed, which is the exact failure this removes. The count runs on a service-role client because RLS hides a sibling in a company the user has since left, and it fails closed: an uncertain count is treated as shared, since a lingering consent lapses on its own in 90 days while a wrongly revoked one kills a working feed. - A renewed consent fans out to every company sharing the old session, and re-points their account uids by IBAN. Several ASPSPs reissue uids on re-authorization, so carrying the session id alone would have left siblings calling retired uids and re-broken them every quarter. This is also why the superseded session_id is no longer nulled at /connect: the callback needs it. - The nightly probe runs once per distinct session and applies the verdict to every row holding it, and expiry mails are keyed per (user, session), so one dead consent is one probe and one mail rather than four of each. Only enabled cash_accounts rows count as claiming an IBAN. The callback mirrors every account in a consent, deselected ones included, so counting any row as a claim would leave nothing offerable once the first company connects. An account handed to a company also stops being offered while that company's picker is still open, closing the window where two companies could book the same physical account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ink2): read the resultaträkning from the pre-closing books INK2R summed journal entries raw, so it included the resultatavslut that zeroes every P&L account into 2099 at year-end. Nettoomsättning, kostnader, periodiseringsfond and skatt all came out as 0, which cascaded into INK2S 7650/7651 and the taxable result. INK2 is always filed after bokslut, so this was every real declaration, and nothing warned: with the P&L at zero the balance sheet still tied out. INK2R now reads two views of the same period. The balance sheet comes from the closed books so 7302 keeps arets resultat via 2099; the income statement comes from the pre-closing books via excludeFinalClosingEntry, which drops only fiscal_periods.closing_entry_id so skatt and bokslutsdispositioner stay on the form (7525, 7528). The equity adjustment is now conditional on a posted closing entry having moved the result into 2099. Second, independent bug: accounts were mapped by BAS number with no regard for the sign of the balance, so konto 1630 with a credit was reported as a negative fordran instead of a skatteskuld and konto 2641 with a debit was netted off the liabilities. The three sign-reclassification rules the K2 iXBRL mapper already had are extracted to lib/reports/sign-reclassification .ts and applied to INK2R too, so both statutory reports present the same balance sheet. Only the rule table is shared: k2-mapper keeps its sumOre arithmetic because the iXBRL path is ore-exact while INK2R truncates per SFL 22:1. NE-bilaga had the same empty-resultatrakning bug and gets the same fix. Adds the closed-period coverage that was missing: the old tests only exercised the mapping table against an open period, the one state in which the engine happened to work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(reports): make the year-end closing decision explicit at every call site generateTrialBalance took two optional booleans, so a caller that never thought about the resultatavslut silently got 'include'. That is the wrong default for anything summing class 3-8: the closing verifikat posts the mirror image of every P&L account into 2099 inside the same period, so the report reads ZERO across the board while the balance sheet still ties out and nothing warns. The booleans are replaced by a required closingEntry: 'include' | 'exclude-final' | 'exclude-all-year-end' with no default, so the build fails until each call site decides. All 40 were audited individually; every one keeps its current behaviour except the two that were provably broken: - Resultatrapport read zero on every line for a closed year, in JSON, PDF and XLSX, and its prior-year comparison column read zero for anyone whose previous year was closed. - Resultat per projekt (dimension-pnl) had the same defect and must stay in lockstep with Resultatrapport to keep reconciling. Both now pass 'exclude-all-year-end', which keeps them agreeing with the formal Resultaträkning rather than pre-empting Stage 2 of #1051 (DECISIONS.md:632). Deliberately unchanged and recorded in DECISIONS.md: the KPI expense composition, which is blank for a closed year but cannot be fixed without a migration and a displayed-figure change, and getBookedBolagsskatt, whose contract is an open period and whose call chain already caused a too-high-tax customer bug once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(vat): keep the resultatavslut out of the momsdeklaration The closing verifikat posts the mirror image of every P&L account into 2099 inside the same fiscal period. Revenue accounts drive rutor 05, 39 and 40, so any VAT period containing the fiscal-year end reported NEGATED turnover once the year was closed. get_vat_declaration_totals already excluded vat_settlement and opening_balance entries, but not this one. Reproduced read-only against production: for December of a closed year the December declaration reported ruta 39 = -794 734 kr. After the fix that period reports 0 and the January period carrying the real sale is unchanged at 794 734 kr. Keyed on fiscal_periods.closing_entry_id, not source_type = 'year_end': avskrivningar, periodiseringsfond and skatt share that source_type and must keep whatever VAT effect they carry. A reversed closing entry is retained together with its storno so the pair still nets to zero, the same predicate trial-balance.ts uses for closingEntry: 'exclude-final'. Migration applied to the staging branch only; prod gets it via merge. The pg test is written but has NOT been executed locally (no DATABASE_URL configured and no local Postgres), so CI is its first real run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(kpi): keep the resultatavslut off the monthly chart The monthly income/expense chart summed every posted entry in the fiscal period. The closing verifikat posts the mirror image of every P&L account, so once a year was closed the fiscal-year-end month charted the whole year's revenue as negative income. Measured read-only on production: 28 companies across 34 month-rows. The worst case charted December income as -10 347 459,81 kr where the real figure is +12,88 kr. Other examples: -1 868 731 -> +128 730, -1 850 501 -> +431 709. Both paths are fixed together so they keep agreeing: the RPC's monthly section now joins the tb_ex_ye_entries CTE it already computes for tb_ex_year_end, and monthly-breakdown.ts (the dimension-filtered fallback and the MCP path) gains the matching source_type filter plus the storno/correction chain of REVERSED year-end entries, so an undone bokslut does not leave half a pair behind. Migration 20260723180000 had recorded the omission as deliberate, on the grounds that it mirrored the JS scan. It did, but the JS scan was wrong. Migration applied to the staging branch (function body identical; three comment lines differ from the committed file). Prod gets the file via merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(reports): pin every statement generator against a closed fiscal year The per-generator suites all exercised an OPEN fiscal period, which is the one state in which a generator that forgets the resultatavslut happens to work. Declarations are filed AFTER bokslut, so the untested state was the only state that occurs in production. That is why the same defect could ship three times. Two new suites over one shared fixture (closed-year-fixture.ts, a synthetic closed AB with a resultatavslut, a credit 1630 and a debit 2641): closed-year-statements.test.ts enumerates the generators and asserts each reports the year's revenue rather than zero, plus its own bottom line. The table IS the checklist: a new report either appears in it or nothing stops it shipping with this bug. Verified by regressing income-statement back to closingEntry 'include', which fails 2 of its assertions. cross-surface-agreement.test.ts asserts the surfaces agree with each other, which is what every customer complaint actually was. INK2R and the K2 årsredovisning must produce the same årets resultat, the same fritt eget kapital, the same sign reclassifications and the same balance total. The operational family (Resultaträkning, Resultatrapport) must agree internally, and the gap BETWEEN the families is asserted explicitly as bokslutsdispositioner + skatt, so when Stage 2 of #1051 lands the test names the expectation to change instead of failing vaguely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(guards): ratchet against new reports that scan the ledger directly A statement generator that aggregates journal_entry_lines itself has to remember, on its own, that the resultatavslut posts the mirror image of every P&L account into 2099 inside the same fiscal period. Three forgot, and each read ZERO revenue for a closed year while the balance sheet still tied out, so nothing warned. generateTrialBalance now requires an explicit closingEntry mode, which makes that decision a compile error. This guard is what keeps NEW reports on that path: any generator under lib/reports or lib/bokslut that reads journal_entry_lines and is not in the baseline set fails CI. Verified by adding a throwaway report, which the guard rejects by name. Voucher and line listings (general-ledger, journal-register, SIE export, reconciliation, diagnostics) are sanctioned: they show the ledger as posted and have no closingEntry decision to make. Four existing lib/bokslut files are grandfathered rather than migrated. One of them is a genuine open follow-up recorded in DECISIONS.md: sarskild-loneskatt-calculator sums 7410-7419 with no year-end exclusion, so its basis reads ~0 if it runs against an already-closed period. Left alone deliberately: it is a tax figure whose call chain has caused a customer bug before and deserves its own verified change. Also ratchets naive-ore-round down 646 -> 641. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(reports): pin where sign reclassification applies, in both directions No behaviour change. The sweep asked whether the 1630/2641 sign reclassification should be extended to the remaining balance-sheet surfaces; the answer is that there are none left. Both STATUTORY presentations already have it: the K2 iXBRL årsredovisning since 2026-07-23 and INK2R since 2026-07-29. The other two balance-sheet surfaces must NOT have it: /rapporter Balansräkning and Balansrapport are organised by account number under BAS-prefix headings, and balansrapport documents an invariant that depends on every row staying debit-positive where it was booked. Moving konto 1630 into a liability section would break the add-the-rows-to-verify-the-balance property and hide the account from anyone looking it up by number. Asserting both halves is the point. The first half stops the reclassification silently disappearing from one statutory surface again, which is how a customer ended up comparing two of our own reports against each other. The second half stops a future sweep "fixing" the operational reports into disagreeing with their own documented contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(reports): detect statement disagreement instead of waiting for a customer Every year-end problem reported so far was a DISAGREEMENT between two of our own screens, not a single wrong screen. The årsredovisning said one figure, INK2 said another, and the customer did the reconciliation for us. Nothing in the product noticed, because each screen tied out on its own. Two additions: INK2R self-checks. On a closed year it compares the årets resultat it is about to declare against the booked konto 2099, and warns in Swedish when they disagree. This is the alarm that was missing: when INK2R reported 0 kr against a booked 469 542 kr, the balance sheet still balanced, so no warning fired. Mirrors the equivalent check k2-mapper has had since 2026-07-23, so both statutory reports now catch the same fault. reconcileStatements + GET /api/reports/statement-reconciliation return årets resultat from every surface side by side, grouped into families. ledger + statutory must agree and a mismatch is named; operational legitimately differs by bokslutsdispositioner + skatt until Stage 2 of #1051 lands, so that gap is explained rather than flagged. The visual panel is deliberately not built here: it needs a /frontend-design pass against the locked concept conventions plus sv/en strings, and the warning above already puts the alarm where the user looks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(reports): address review findings from PR #1293 pg-real (7 failures, one signature): the new fixture called insertFiscalPeriod({ isClosed: true }) and then inserted journal entries into it, so enforce_period_lock (migration 017, legally required) refused the write. Not worked around: the RPC's predicate keys on fiscal_periods.closing_entry_id and never reads is_closed, so the fixture now links the closing entry and leaves the period open, which exercises the path that actually matters. CodeRabbit, closed-year-fixture: EX_YEAR_END_ROWS dropped only the P&L legs of the year_end entries (8811, 8910) and left their balance-sheet legs (2125, 2512) at pre-closing values, so the 'exclude-all-year-end' view sat 160 000 kr out of balance and misrepresented what generateTrialBalance returns. Latent, because today's consumers read class 3-8 only, but a shared fixture that does not balance is a trap for the next consumer. Both legs now go, and a new test asserts all three views sum to zero. CodeRabbit, INK2 totals: renamed totals.resultAfterFinancial to aretsResultat. It holds the result after bokslutsdispositioner AND skatt, which is årets resultat, not resultat efter finansiella poster, and build-data.ts uses the old name correctly for the different subtotal. The UI already labelled the value "Årets resultat", so the name was simply wrong. CodeRabbit, statement-reconciliation: the statutory branch called a generator and caught any throw as "wrong entity type", mapping genuine failures to a null figure that the comparison then skipped, so a real bug in a declaration generator made the function report isReconciled: true. That is the opposite of its purpose. It now dispatches on entity_type and surfaces a generation failure as a named disagreement. CodeRabbit, enable-banking (Emil's call to include): fetchClaimedIbans returned an empty Set on a cash_accounts read failure, which is indistinguishable from "nothing is claimed" and made every IBAN in the session offerable, including accounts another company already books to. Its own comment said it failed closed and its log said "offering nothing"; it failed open. Returns null now, and findReusableSessions offers nothing when the claimed set is unavailable. The test that pinned the fail-open asserted toHaveLength(1) under the name "offers nothing"; it now asserts []. Also removed an em dash per CLAUDE.md. The remaining enable-banking finding (consent-expiry cooldown stamped only on the selected connection, so it leaks one duplicate mail per sibling company) is deliberately left to Emil: it changes email-sending behaviour in his feature rather than fixing a stated contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(reports): resolve second-round review findings on PR #1293 pg-real, two NEW signatures (the closed-period one from cycle 1 is gone): kpi-report-aggregates-rpc.pg.test.ts asserted the exact contract migration 20260730090000 deliberately changes. Its comment read "year_end entries are NOT excluded from monthly" and expected December expenses 1250. That fixture's December holds only year-end-chain entries, so with the fix the month drops out of the chart entirely, which is the correct operational view: a month whose only activity is bokslut has no operating result. Assertion and file docstring updated to the new contract rather than the test being removed. vat-totals-closing-entry.pg.test.ts passed the wrong account arrays. p_net_ accounts is VAT_SETTLEMENT_NET_ACCOUNTS (2650/1650, the momsredovisning settlement pair), not the output-VAT accounts. Putting 2611 there made the extra year_end entry match the settlement-SHAPE detector, so an ordinary sale-with-VAT was classified a momsredovisning and dropped, and the test read 0 instead of 10 000. The RPC was right; the fixture was not. CodeRabbit, statement-reconciliation: resolveEntityType checked neither query's error, so a genuine DB failure (RLS, permissions, connectivity) returned null indistinguishably from "no entity type set", fell into the unsupported-form branch and reported isReconciled: true. That is the same silent-false-reconciled bug the cycle-1 refactor closed, one level down. The companies error now throws; a missing company_settings ROW stays tolerated, because .single() errors on zero rows and many companies have none. Mirrors the pattern the INK2 and NE engines already use. Still open by Emil's explicit choice: the consent-expiry cooldown is stamped only on the connection it was handed, so it leaks one duplicate mail per sibling company on the shared session. That changes email-sending behaviour in his feature rather than fixing a stated contract, so it stays his. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
954ce873a8
commit
17a7a62ceb
@@ -50,7 +50,8 @@ export async function proposeVacationLiabilityChange(
|
||||
|
||||
const [report, tb] = await Promise.all([
|
||||
generateVacationLiability(supabase, companyId, closingYear),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId),
|
||||
// Reads 2920 (class 2), which no resultatavslut touches.
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' }),
|
||||
])
|
||||
|
||||
// Current closing balance (what 2920 should be at year-end)
|
||||
|
||||
@@ -414,13 +414,13 @@ describe('buildArsredovisningData: K2 byte-equivalence', () => {
|
||||
expect.anything(),
|
||||
'co1',
|
||||
'fp1',
|
||||
{ excludeFinalClosingEntry: true },
|
||||
{ closingEntry: 'exclude-final' },
|
||||
)
|
||||
expect(mockedTrialBalance).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'co1',
|
||||
'fp1',
|
||||
{ excludeYearEndClosing: true },
|
||||
{ closingEntry: 'exclude-all-year-end' },
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -89,8 +89,8 @@ export async function buildArsredovisningData(
|
||||
.order('period_start', { ascending: false })
|
||||
.range(from, to),
|
||||
),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeFinalClosingEntry: true }),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' }),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'exclude-final' }),
|
||||
// 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
|
||||
@@ -162,8 +162,8 @@ export async function buildArsredovisningData(
|
||||
[...tbTargets.values()].map(async (p) => {
|
||||
try {
|
||||
const [full, preClosing] = await Promise.all([
|
||||
generateTrialBalance(supabase, companyId, p.id),
|
||||
generateTrialBalance(supabase, companyId, p.id, { excludeFinalClosingEntry: true }),
|
||||
generateTrialBalance(supabase, companyId, p.id, { closingEntry: 'include' }),
|
||||
generateTrialBalance(supabase, companyId, p.id, { closingEntry: 'exclude-final' }),
|
||||
])
|
||||
tbPairs.set(p.id, { full: full.rows, preClosing: preClosing.rows })
|
||||
} catch {
|
||||
|
||||
@@ -256,7 +256,10 @@ export async function buildLatentTaxProposal(params: {
|
||||
}): Promise<ProposedDisposition | null> {
|
||||
const { supabase, companyId, fiscalPeriodId, proposalsBeforeLatentTax = [] } = params
|
||||
|
||||
const tb = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
|
||||
// Reads 21xx and 2240 only (class 2), which no resultatavslut touches.
|
||||
const tb = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
closingEntry: 'include',
|
||||
})
|
||||
|
||||
// 21xx: obeskattade reserver (credit-normal, so we measure credit − debit).
|
||||
let untaxedReserves = tb.rows
|
||||
|
||||
@@ -71,8 +71,8 @@ export async function buildIxbrlInput(
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single(),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeFinalClosingEntry: true }),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' }),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'exclude-final' }),
|
||||
options.signatureRequests ?? listSignatureRequests(supabase, companyId, fiscalPeriodId),
|
||||
])
|
||||
|
||||
@@ -101,8 +101,8 @@ export async function buildIxbrlInput(
|
||||
previousPeriod = { start: prev.period_start, end: prev.period_end }
|
||||
try {
|
||||
const [prevFull, prevPreClosing] = await Promise.all([
|
||||
generateTrialBalance(supabase, companyId, prev.id),
|
||||
generateTrialBalance(supabase, companyId, prev.id, { excludeFinalClosingEntry: true }),
|
||||
generateTrialBalance(supabase, companyId, prev.id, { closingEntry: 'include' }),
|
||||
generateTrialBalance(supabase, companyId, prev.id, { closingEntry: 'exclude-final' }),
|
||||
])
|
||||
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
|
||||
} catch {
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
|
||||
import type { ConceptAmount, ConceptAmounts } from './types'
|
||||
import { equalOre, roundOre, sumOre } from '@/lib/money'
|
||||
import {
|
||||
SIGN_RECLASSIFICATION_RULES,
|
||||
type SignReclassificationId,
|
||||
type SignReclassificationRule,
|
||||
} from '@/lib/reports/sign-reclassification'
|
||||
|
||||
export interface TrialBalanceRowLike {
|
||||
account_number: string
|
||||
@@ -52,15 +57,6 @@ 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. */
|
||||
@@ -394,40 +390,28 @@ const RECLASSIFIED_ACCOUNTS: Record<string, string> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* K2 BR posts each shared sign-reclassification rule moves between. The rules
|
||||
* themselves (ranges, mode, warning) live in lib/reports/sign-reclassification
|
||||
* .ts so INK2R presents the same balance sheet as the årsredovisning. Only the
|
||||
* post names are K2-specific; the arithmetic below stays öre-exact here.
|
||||
*/
|
||||
const SIGN_RECLASSIFICATIONS: SignReclassification[] = [
|
||||
{
|
||||
const SIGN_RECLASSIFICATION_POSTS: Record<
|
||||
SignReclassificationId,
|
||||
{ sourceConcept: string; targetConcept: string }
|
||||
> = {
|
||||
tax_account_credit_to_liability: {
|
||||
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.',
|
||||
},
|
||||
{
|
||||
tax_liability_debit_to_receivable: {
|
||||
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.',
|
||||
},
|
||||
{
|
||||
vat_liability_debit_to_receivable: {
|
||||
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
|
||||
@@ -502,7 +486,7 @@ function exactAmount(
|
||||
|
||||
function deviatingRowsTotal(
|
||||
rows: TrialBalanceRowLike[],
|
||||
rule: SignReclassification,
|
||||
rule: SignReclassificationRule,
|
||||
): number {
|
||||
return sumOre(
|
||||
rows
|
||||
@@ -518,7 +502,8 @@ function applySignReclassifications(
|
||||
previous: TrialBalanceRowLike[] | null,
|
||||
warnings: string[],
|
||||
): void {
|
||||
for (const rule of SIGN_RECLASSIFICATIONS) {
|
||||
for (const rule of SIGN_RECLASSIFICATION_RULES) {
|
||||
const { sourceConcept, targetConcept } = SIGN_RECLASSIFICATION_POSTS[rule.id]
|
||||
let reclassified = false
|
||||
for (const field of ['current', 'previous'] as const) {
|
||||
const rows = field === 'current' ? current : previous
|
||||
@@ -527,15 +512,15 @@ function applySignReclassifications(
|
||||
rule.mode === 'deviating_rows'
|
||||
? deviatingRowsTotal(rows, rule)
|
||||
: exactSumForMapping(rows, {
|
||||
concept: rule.sourceConcept,
|
||||
concept: 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)
|
||||
adjustConcept(br, sourceConcept, field, amountToMove)
|
||||
adjustConcept(br, targetConcept, field, amountToMove)
|
||||
reclassified = true
|
||||
}
|
||||
if (reclassified) warnings.push(rule.warning)
|
||||
|
||||
@@ -220,7 +220,13 @@ export async function getBookedBolagsskatt(
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
): Promise<number> {
|
||||
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
|
||||
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
// The contract above is an OPEN period, where no closing entry exists, so
|
||||
// 'include' and 'exclude-final' agree. Left as 'include' to keep the tax
|
||||
// path byte-identical: DECISIONS.md:632 records that this call chain
|
||||
// already caused a too-high-tax customer bug once.
|
||||
closingEntry: 'include',
|
||||
})
|
||||
const amount = trialBalance.rows
|
||||
.filter((row) => row.account_number === '8910')
|
||||
.reduce((sum, row) => sum + row.closing_debit - row.closing_credit, 0)
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function loadTaxAdjustmentSnapshot(
|
||||
): Promise<TaxAdjustmentSnapshot> {
|
||||
const [trialBalance, persistedResult] = await Promise.all([
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
excludeYearEndClosing: true,
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
}),
|
||||
supabase
|
||||
.from('fiscal_period_tax_adjustments')
|
||||
|
||||
@@ -204,7 +204,7 @@ export async function validateYearEndReadiness(
|
||||
}
|
||||
|
||||
// Check: trial balance is balanced
|
||||
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
|
||||
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' })
|
||||
const trialBalanceBalanced = trialBalance.isBalanced
|
||||
|
||||
if (!trialBalanceBalanced) {
|
||||
@@ -345,7 +345,7 @@ export async function previewYearEndClosing(
|
||||
: 'Årets resultat'
|
||||
|
||||
// Get trial balance for individual account balances in class 3-8
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' })
|
||||
const resultAccounts = rows.filter(
|
||||
(r) => r.account_class >= 3 && r.account_class <= 8
|
||||
)
|
||||
@@ -560,7 +560,7 @@ export async function executeYearEndClosing(
|
||||
// the engine commits atomically per-entry via commit_journal_entry RPC,
|
||||
// so a failure here means we need to reverse the just-committed entry.
|
||||
try {
|
||||
const postCloseTB = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
|
||||
const postCloseTB = await generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' })
|
||||
let resultNet = 0
|
||||
for (const row of postCloseTB.rows) {
|
||||
if (row.account_class >= 3 && row.account_class <= 8) {
|
||||
@@ -756,7 +756,7 @@ export async function generateOpeningBalances(
|
||||
}
|
||||
|
||||
// Get trial balance of closed period (includes the closing entry)
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, closedPeriodId)
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, closedPeriodId, { closingEntry: 'include' })
|
||||
|
||||
// Filter to balance sheet accounts (class 1-2) with non-zero closing balance
|
||||
const balanceSheetAccounts = rows.filter(
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Shared closed-year fixture.
|
||||
*
|
||||
* One synthetic AB that has been through a bokslut, expressed as the three
|
||||
* trial-balance views generateTrialBalance can return. Every statement
|
||||
* generator is exercised against it by closed-year-statements.test.ts.
|
||||
*
|
||||
* Why this exists: the same defect shipped three times. A generator sums
|
||||
* classes 3-8 from the trial balance, forgets that the resultatavslut posts the
|
||||
* mirror image of every P&L account into 2099 inside the same period, and reads
|
||||
* ZERO across the board. The balance sheet still ties out, so nothing warns.
|
||||
* It hit the årsredovisning (2026-07-23), INK2R and NE-bilaga (2026-07-29), and
|
||||
* was found sitting unreported on Resultatrapport, the KPI monthly chart and
|
||||
* the momsdeklaration in the same sweep.
|
||||
*
|
||||
* A new statement generator must be added to the table in
|
||||
* closed-year-statements.test.ts. That is the point: the list is the checklist.
|
||||
*/
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { TrialBalanceRow } from '@/types'
|
||||
|
||||
/** Build a trial balance row from a debit-positive balance. */
|
||||
export function tbRow(
|
||||
accountNumber: string,
|
||||
accountName: string,
|
||||
balance: number,
|
||||
): TrialBalanceRow {
|
||||
const debit = balance > 0 ? balance : 0
|
||||
const credit = balance < 0 ? -balance : 0
|
||||
return {
|
||||
account_number: accountNumber,
|
||||
account_name: accountName,
|
||||
account_class: Number(accountNumber[0]),
|
||||
opening_debit: 0,
|
||||
opening_credit: 0,
|
||||
period_debit: debit,
|
||||
period_credit: credit,
|
||||
closing_debit: debit,
|
||||
closing_credit: credit,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The books before the resultatavslut, i.e. closingEntry: 'exclude-final'.
|
||||
* Bokslutsdispositioner (8811) and skatt (8910) ARE present: they carry
|
||||
* source_type 'year_end' but are not the closing verifikat and belong on a
|
||||
* statutory form.
|
||||
*
|
||||
* Rörelseresultat 700 000 − 100 000 = 600 000
|
||||
* Finansiella poster 5 000 − 3 000 = 2 000
|
||||
* Efter finansiella poster = 602 000
|
||||
* Periodiseringsfond −100 000 = 502 000
|
||||
* Skatt −60 000 = 442 000
|
||||
*
|
||||
* 1630 carries a CREDIT (a skatteskuld that a sign-blind mapping shows as a
|
||||
* negative fordran) and 2641 a DEBIT (a momsfordran shown as a negative skuld).
|
||||
*/
|
||||
export const PRE_CLOSING_ROWS: TrialBalanceRow[] = [
|
||||
tbRow('1630', 'Avräkning skatter och avgifter', -20_000),
|
||||
tbRow('1930', 'Företagskonto', 610_000),
|
||||
tbRow('2081', 'Aktiekapital', -25_000),
|
||||
tbRow('2099', 'Årets resultat', 0),
|
||||
tbRow('2125', 'Periodiseringsfond', -100_000),
|
||||
tbRow('2440', 'Leverantörsskulder', -15_000),
|
||||
tbRow('2512', 'Beräknad inkomstskatt', -60_000),
|
||||
tbRow('2518', 'Betald F-skatt', 50_000),
|
||||
tbRow('2641', 'Debiterad ingående moms', 2_000),
|
||||
tbRow('3001', 'Försäljning', -700_000),
|
||||
tbRow('5010', 'Lokalhyra', 100_000),
|
||||
tbRow('8311', 'Ränteintäkter', -5_000),
|
||||
tbRow('8410', 'Räntekostnader', 3_000),
|
||||
tbRow('8811', 'Avsättning till periodiseringsfond', 100_000),
|
||||
tbRow('8910', 'Skatt på årets resultat', 60_000),
|
||||
]
|
||||
|
||||
/**
|
||||
* The operational view, i.e. closingEntry: 'exclude-all-year-end'. Every
|
||||
* source_type 'year_end' entry is gone, so the dispositions and the tax go too.
|
||||
*
|
||||
* BOTH legs of each dropped entry go, not just the P&L one: 2125 is the
|
||||
* periodiseringsfond credit leg of 8811, and 2512 the skatteskuld credit leg of
|
||||
* 8910. Zeroing only 8811/8910 would leave this view 160 000 kr out of balance
|
||||
* and misrepresent what generateTrialBalance actually returns in this mode.
|
||||
* Today's consumers read only class 3-8, so that was latent, but a shared
|
||||
* fixture that does not balance is a trap for the next balance-sheet consumer.
|
||||
* balancesToZero() below is asserted in closed-year-statements.test.ts.
|
||||
*/
|
||||
export const EX_YEAR_END_ROWS: TrialBalanceRow[] = PRE_CLOSING_ROWS
|
||||
.filter((r) => r.account_number !== '8811' && r.account_number !== '8910')
|
||||
.map((r) =>
|
||||
r.account_number === '2125' || r.account_number === '2512'
|
||||
? tbRow(r.account_number, r.account_name, 0)
|
||||
: r,
|
||||
)
|
||||
|
||||
/** Debit-positive sum of a view. Every trial balance must come to zero. */
|
||||
export function balancesToZero(rows: TrialBalanceRow[]): number {
|
||||
const total = rows.reduce(
|
||||
(sum, r) => sum + (Number(r.closing_debit) || 0) - (Number(r.closing_credit) || 0),
|
||||
0,
|
||||
)
|
||||
return roundOre(total)
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed books, i.e. closingEntry: 'include'. Every P&L account is zero and
|
||||
* 2099 carries årets resultat. Any generator that reads THIS and then reports a
|
||||
* resultaträkning is the bug.
|
||||
*/
|
||||
export const CLOSED_ROWS: TrialBalanceRow[] = PRE_CLOSING_ROWS.map((r) => {
|
||||
if (r.account_number === '2099') return tbRow('2099', 'Årets resultat', -442_000)
|
||||
if (r.account_class >= 3) return tbRow(r.account_number, r.account_name, 0)
|
||||
return r
|
||||
})
|
||||
|
||||
/** What the fixture is worth, per view. */
|
||||
export const EXPECTED = {
|
||||
/** Nettoomsättning, identical in every non-closed view. */
|
||||
revenue: 700_000,
|
||||
/** Resultat efter finansiella poster (no dispositions, no tax). */
|
||||
resultAfterFinancial: 602_000,
|
||||
/** Årets resultat after dispositions and tax. */
|
||||
netResult: 442_000,
|
||||
/** 1630's credit balance, which belongs in skatteskulder. */
|
||||
taxAccountCredit: 20_000,
|
||||
/** 2641's debit balance, which belongs in övriga fordringar. */
|
||||
inputVatDebit: 2_000,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Pick the view a caller asked for. Mount this as the generateTrialBalance mock
|
||||
* implementation and every generator gets the rows its own mode implies, which
|
||||
* is what makes a wrong mode show up as a wrong number.
|
||||
*/
|
||||
export function rowsForMode(mode: string): TrialBalanceRow[] {
|
||||
if (mode === 'exclude-final') return PRE_CLOSING_ROWS
|
||||
if (mode === 'exclude-all-year-end') return EX_YEAR_END_ROWS
|
||||
return CLOSED_ROWS
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Every statement generator, run against one closed fiscal year.
|
||||
*
|
||||
* This is the test that would have caught the INK2R and NE-bilaga bugs on
|
||||
* 2026-07-23, when the same two defects were fixed in the årsredovisning and
|
||||
* nowhere else. The old per-generator suites all exercised an OPEN period, the
|
||||
* one state in which a generator that forgets the resultatavslut happens to
|
||||
* work. Declarations are filed AFTER bokslut, so the untested state was the
|
||||
* only state that occurs in production.
|
||||
*
|
||||
* ADDING A GENERATOR: add a row to GENERATORS. If a new report sums classes 3-8
|
||||
* and is not in this table, nothing stops it shipping with the same bug.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/reports/trial-balance', () => ({
|
||||
generateTrialBalance: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/bokslut/tax-provision/tax-adjustment-service', () => ({
|
||||
loadTaxAdjustmentSnapshot: vi.fn(),
|
||||
}))
|
||||
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { loadTaxAdjustmentSnapshot } from '@/lib/bokslut/tax-provision/tax-adjustment-service'
|
||||
import { generateIncomeStatement } from '../income-statement'
|
||||
import { generateResultatrapport } from '../resultatrapport'
|
||||
import { generateINK2Declaration } from '../ink2/ink2-engine'
|
||||
import { generateNEDeclaration } from '../ne-bilaga/ne-engine'
|
||||
import {
|
||||
CLOSED_ROWS,
|
||||
EXPECTED,
|
||||
EX_YEAR_END_ROWS,
|
||||
PRE_CLOSING_ROWS,
|
||||
balancesToZero,
|
||||
rowsForMode,
|
||||
} from './closed-year-fixture'
|
||||
|
||||
const COMPANY_ID = 'company-1'
|
||||
const PERIOD_ID = 'period-1'
|
||||
|
||||
/**
|
||||
* Minimal chainable stub. Every generator in the table needs the fiscal period
|
||||
* and most need company_settings; INK2 also probes the closing entry's status.
|
||||
*/
|
||||
function makeSupabase(entityType: 'aktiebolag' | 'enskild_firma') {
|
||||
const period = {
|
||||
id: PERIOD_ID,
|
||||
name: 'Räkenskapsår 2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
is_closed: true,
|
||||
closing_entry_id: 'closing-1',
|
||||
previous_period_id: null,
|
||||
}
|
||||
const settings = {
|
||||
company_name: 'Testbolaget',
|
||||
org_number: '5560000000',
|
||||
entity_type: entityType,
|
||||
address_line1: 'Testgatan 1',
|
||||
postal_code: '11122',
|
||||
city: 'Stockholm',
|
||||
email: 'test@example.com',
|
||||
}
|
||||
|
||||
function chain(result: unknown): Record<string, unknown> {
|
||||
const c: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'neq', 'or', 'order', 'limit', 'contains']) {
|
||||
c[m] = () => c
|
||||
}
|
||||
c.single = async () => result
|
||||
c.maybeSingle = async () => result
|
||||
c.range = async () => result
|
||||
c.then = undefined
|
||||
return c
|
||||
}
|
||||
|
||||
return {
|
||||
from: (table: string) => {
|
||||
if (table === 'fiscal_periods') return chain({ data: period, error: null })
|
||||
if (table === 'company_settings') return chain({ data: settings, error: null })
|
||||
if (table === 'companies') return chain({ data: { entity_type: entityType }, error: null })
|
||||
// The closing entry's status: posted, so årets resultat is already in 2099.
|
||||
if (table === 'journal_entries') return chain({ data: { status: 'posted' }, error: null })
|
||||
return chain({ data: [], error: null })
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
interface GeneratorCase {
|
||||
name: string
|
||||
entityType: 'aktiebolag' | 'enskild_firma'
|
||||
/** Revenue as the generator reports it, in kronor. */
|
||||
revenue: (result: never) => number
|
||||
/** The generator's own bottom line, for the subset that computes one. */
|
||||
netResult?: (result: never) => number
|
||||
expectedNetResult?: number
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run: (supabase: any) => Promise<any>
|
||||
}
|
||||
|
||||
const GENERATORS: GeneratorCase[] = [
|
||||
{
|
||||
name: 'Resultaträkning (income-statement)',
|
||||
entityType: 'aktiebolag',
|
||||
run: (s) => generateIncomeStatement(s, COMPANY_ID, PERIOD_ID),
|
||||
// Operational convention: no dispositions, no tax.
|
||||
revenue: (r) => revenueFromSections(r),
|
||||
netResult: (r) => (r as { net_result: number }).net_result,
|
||||
expectedNetResult: EXPECTED.resultAfterFinancial,
|
||||
},
|
||||
{
|
||||
name: 'Resultatrapport',
|
||||
entityType: 'aktiebolag',
|
||||
run: (s) => generateResultatrapport(s, COMPANY_ID, PERIOD_ID),
|
||||
revenue: (r) => {
|
||||
const groups = (r as { groups: Array<{ rows: Array<{ account_number: string; current_period: number }> }> }).groups
|
||||
for (const g of groups) {
|
||||
for (const row of g.rows) if (row.account_number === '3001') return row.current_period
|
||||
}
|
||||
return 0
|
||||
},
|
||||
netResult: (r) => (r as { net_result_current: number }).net_result_current,
|
||||
expectedNetResult: EXPECTED.resultAfterFinancial,
|
||||
},
|
||||
{
|
||||
name: 'INK2R (räkenskapsschema)',
|
||||
entityType: 'aktiebolag',
|
||||
run: (s) => generateINK2Declaration(s, COMPANY_ID, PERIOD_ID),
|
||||
revenue: (r) => (r as { ink2r: Record<string, number> }).ink2r['7410'],
|
||||
netResult: (r) => (r as { ink2r: Record<string, number> }).ink2r['7450'],
|
||||
expectedNetResult: EXPECTED.netResult,
|
||||
},
|
||||
{
|
||||
name: 'NE-bilaga',
|
||||
entityType: 'enskild_firma',
|
||||
run: (s) => generateNEDeclaration(s, COMPANY_ID, PERIOD_ID),
|
||||
revenue: (r) => (r as { rutor: Record<string, number> }).rutor.R1,
|
||||
},
|
||||
]
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function revenueFromSections(report: any): number {
|
||||
for (const section of report.revenue_sections ?? []) {
|
||||
for (const row of section.rows ?? []) {
|
||||
if (row.account_number === '3001') return row.amount
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(loadTaxAdjustmentSnapshot).mockResolvedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ nonDeductibleExpenses: 0, nonTaxableIncome: 0 } as any,
|
||||
)
|
||||
vi.mocked(generateTrialBalance).mockImplementation(async (_s, _c, _p, opts) => ({
|
||||
rows: rowsForMode(opts.closingEntry),
|
||||
totalDebit: 0,
|
||||
totalCredit: 0,
|
||||
isBalanced: true,
|
||||
}))
|
||||
})
|
||||
|
||||
describe('statement generators against a closed fiscal year', () => {
|
||||
for (const g of GENERATORS) {
|
||||
it(`${g.name} reports the year's revenue, not zero`, async () => {
|
||||
const result = await g.run(makeSupabase(g.entityType))
|
||||
|
||||
// The regression, in one assertion: a generator that asked for
|
||||
// closingEntry 'include' sees a zeroed P&L and reports 0 here.
|
||||
expect(g.revenue(result as never)).toBe(EXPECTED.revenue)
|
||||
})
|
||||
|
||||
const readNetResult = g.netResult
|
||||
const expectedNetResult = g.expectedNetResult
|
||||
if (readNetResult && expectedNetResult !== undefined) {
|
||||
it(`${g.name} reports its bottom line`, async () => {
|
||||
const result = await g.run(makeSupabase(g.entityType))
|
||||
expect(readNetResult(result as never)).toBe(expectedNetResult)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('has three self-consistent views: every one must balance', () => {
|
||||
// A trial balance that does not sum to zero is not a trial balance. The
|
||||
// 'exclude-all-year-end' view originally dropped only the P&L legs of the
|
||||
// year_end entries and sat 160 000 kr out of balance, which was latent
|
||||
// because today's consumers read class 3-8 only.
|
||||
expect(balancesToZero(PRE_CLOSING_ROWS)).toBe(0)
|
||||
expect(balancesToZero(EX_YEAR_END_ROWS)).toBe(0)
|
||||
expect(balancesToZero(CLOSED_ROWS)).toBe(0)
|
||||
})
|
||||
|
||||
it('covers every generator that reports a resultaträkning', () => {
|
||||
// A tripwire for the next person: this count is the checklist length.
|
||||
// Raising it without adding a row means a generator went untested.
|
||||
expect(GENERATORS).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('balance sheet presentation against a closed fiscal year', () => {
|
||||
it('INK2R keeps årets resultat in fritt eget kapital exactly once', async () => {
|
||||
const result = await generateINK2Declaration(makeSupabase('aktiebolag'), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
// 2099 carries it in the closed books, so the engine must NOT add the
|
||||
// computed result on top.
|
||||
expect(result.ink2r['7302']).toBe(EXPECTED.netResult)
|
||||
expect(result.totals.totalAssets).toBe(result.totals.totalEquityLiabilities)
|
||||
})
|
||||
|
||||
it('INK2R presents a credit skattekonto as a liability, not a negative asset', async () => {
|
||||
const result = await generateINK2Declaration(makeSupabase('aktiebolag'), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
// 2512 − 2518 = 10 000, plus the reclassified 1630 credit.
|
||||
expect(result.ink2r['7368']).toBe(10_000 + EXPECTED.taxAccountCredit)
|
||||
expect(result.ink2r['7261']).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('INK2R presents an input-VAT debit as a receivable, not a negative liability', async () => {
|
||||
const result = await generateINK2Declaration(makeSupabase('aktiebolag'), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.ink2r['7261']).toBe(EXPECTED.inputVatDebit)
|
||||
expect(result.ink2r['7369']).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Cross-surface agreement on one closed fiscal year.
|
||||
*
|
||||
* Every problem the year-end pipeline has produced for a real customer was a
|
||||
* disagreement between two screens, not a single wrong screen: the
|
||||
* årsredovisning said one thing and INK2 said another, so the customer became
|
||||
* the reconciliation engine. Per-surface tests cannot catch that; this file
|
||||
* tests the agreement itself.
|
||||
*
|
||||
* There are deliberately TWO families, and they are allowed to disagree with
|
||||
* each other while Stage 2 of #1051 is outstanding (DECISIONS.md:632):
|
||||
*
|
||||
* statutory (closingEntry 'exclude-final' + a post-closing balance sheet)
|
||||
* reports årets resultat AFTER bokslutsdispositioner and skatt.
|
||||
* operational (closingEntry 'exclude-all-year-end')
|
||||
* reports the result BEFORE them.
|
||||
*
|
||||
* Within a family the numbers must be identical. The gap BETWEEN the families
|
||||
* is asserted explicitly, so when Stage 2 moves generateIncomeStatement to
|
||||
* 'exclude-final' this test says exactly which expectations must change instead
|
||||
* of failing vaguely.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/reports/trial-balance', () => ({
|
||||
generateTrialBalance: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/bokslut/tax-provision/tax-adjustment-service', () => ({
|
||||
loadTaxAdjustmentSnapshot: vi.fn(),
|
||||
}))
|
||||
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { loadTaxAdjustmentSnapshot } from '@/lib/bokslut/tax-provision/tax-adjustment-service'
|
||||
import { generateIncomeStatement } from '../income-statement'
|
||||
import { generateResultatrapport } from '../resultatrapport'
|
||||
import { generateINK2Declaration } from '../ink2/ink2-engine'
|
||||
import { mapTrialBalancesToK2 } from '@/lib/bokslut/ixbrl/k2-mapper'
|
||||
import {
|
||||
CLOSED_ROWS,
|
||||
EXPECTED,
|
||||
PRE_CLOSING_ROWS,
|
||||
rowsForMode,
|
||||
} from './closed-year-fixture'
|
||||
|
||||
const COMPANY_ID = 'company-1'
|
||||
const PERIOD_ID = 'period-1'
|
||||
|
||||
function makeSupabase() {
|
||||
const period = {
|
||||
id: PERIOD_ID,
|
||||
name: 'Räkenskapsår 2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
is_closed: true,
|
||||
closing_entry_id: 'closing-1',
|
||||
previous_period_id: null,
|
||||
}
|
||||
const settings = {
|
||||
company_name: 'Testbolaget',
|
||||
org_number: '5560000000',
|
||||
entity_type: 'aktiebolag',
|
||||
address_line1: 'Testgatan 1',
|
||||
postal_code: '11122',
|
||||
city: 'Stockholm',
|
||||
email: 'test@example.com',
|
||||
}
|
||||
function chain(result: unknown): Record<string, unknown> {
|
||||
const c: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'neq', 'or', 'order', 'limit', 'contains']) {
|
||||
c[m] = () => c
|
||||
}
|
||||
c.single = async () => result
|
||||
c.maybeSingle = async () => result
|
||||
c.range = async () => result
|
||||
return c
|
||||
}
|
||||
return {
|
||||
from: (table: string) => {
|
||||
if (table === 'fiscal_periods') return chain({ data: period, error: null })
|
||||
if (table === 'company_settings') return chain({ data: settings, error: null })
|
||||
if (table === 'companies') return chain({ data: { entity_type: 'aktiebolag' }, error: null })
|
||||
if (table === 'journal_entries') return chain({ data: { status: 'posted' }, error: null })
|
||||
return chain({ data: [], error: null })
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(loadTaxAdjustmentSnapshot).mockResolvedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ nonDeductibleExpenses: 0, nonTaxableIncome: 0 } as any,
|
||||
)
|
||||
vi.mocked(generateTrialBalance).mockImplementation(async (_s, _c, _p, opts) => ({
|
||||
rows: rowsForMode(opts.closingEntry),
|
||||
totalDebit: 0,
|
||||
totalCredit: 0,
|
||||
isBalanced: true,
|
||||
}))
|
||||
})
|
||||
|
||||
describe('statutory surfaces agree on årets resultat', () => {
|
||||
it('INK2R 7450 and the K2 årsredovisning report the same figure', async () => {
|
||||
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
const k2 = mapTrialBalancesToK2({ full: CLOSED_ROWS, preClosing: PRE_CLOSING_ROWS }, null)
|
||||
|
||||
expect(ink2.ink2r['7450']).toBe(EXPECTED.netResult)
|
||||
expect(k2.totals.aretsResultat.current).toBe(EXPECTED.netResult)
|
||||
expect(ink2.ink2r['7450']).toBe(k2.totals.aretsResultat.current)
|
||||
})
|
||||
|
||||
it('both put the same figure in fritt eget kapital via 2099', async () => {
|
||||
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
const k2 = mapTrialBalancesToK2({ full: CLOSED_ROWS, preClosing: PRE_CLOSING_ROWS }, null)
|
||||
|
||||
expect(ink2.ink2r['7302']).toBe(EXPECTED.netResult)
|
||||
expect(k2.totals.frittEgetKapital.current).toBe(EXPECTED.netResult)
|
||||
})
|
||||
|
||||
it('both reclassify the credit skattekonto into skatteskulder', async () => {
|
||||
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
const k2 = mapTrialBalancesToK2({ full: CLOSED_ROWS, preClosing: PRE_CLOSING_ROWS }, null)
|
||||
|
||||
// 2512 − 2518 = 10 000, plus 1630's reclassified credit of 20 000.
|
||||
expect(ink2.ink2r['7368']).toBe(10_000 + EXPECTED.taxAccountCredit)
|
||||
expect(k2.br['Skatteskulder'].current).toBe(10_000 + EXPECTED.taxAccountCredit)
|
||||
})
|
||||
|
||||
it('both reclassify the input-VAT debit into övriga fordringar', async () => {
|
||||
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
const k2 = mapTrialBalancesToK2({ full: CLOSED_ROWS, preClosing: PRE_CLOSING_ROWS }, null)
|
||||
|
||||
expect(ink2.ink2r['7261']).toBe(EXPECTED.inputVatDebit)
|
||||
expect(k2.br['OvrigaFordringarKortfristiga'].current).toBe(EXPECTED.inputVatDebit)
|
||||
})
|
||||
|
||||
it('both balance', async () => {
|
||||
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
const k2 = mapTrialBalancesToK2({ full: CLOSED_ROWS, preClosing: PRE_CLOSING_ROWS }, null)
|
||||
|
||||
expect(ink2.totals.totalAssets).toBe(ink2.totals.totalEquityLiabilities)
|
||||
expect(k2.totals.tillgangar.current).toBe(k2.totals.egetKapitalSkulder.current)
|
||||
expect(ink2.totals.totalAssets).toBe(k2.totals.tillgangar.current)
|
||||
})
|
||||
})
|
||||
|
||||
describe('operational surfaces agree with each other', () => {
|
||||
it('Resultaträkning and Resultatrapport report the same result', async () => {
|
||||
const is = await generateIncomeStatement(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
const rr = await generateResultatrapport(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(is.net_result).toBe(EXPECTED.resultAfterFinancial)
|
||||
expect(rr.net_result_current).toBe(EXPECTED.resultAfterFinancial)
|
||||
expect(is.net_result).toBe(rr.net_result_current)
|
||||
})
|
||||
|
||||
it('and the same revenue as the statutory family', async () => {
|
||||
const is = await generateIncomeStatement(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
// Nettoomsättning is unaffected by the dispositions/tax split, so this one
|
||||
// figure must match across BOTH families.
|
||||
expect(is.total_revenue).toBe(ink2.ink2r['7410'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the known gap between the two families', () => {
|
||||
it('is exactly bokslutsdispositioner plus skatt', async () => {
|
||||
const is = await generateIncomeStatement(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
const gap = is.net_result - ink2.ink2r['7450']
|
||||
const dispositionsAndTax = ink2.ink2r['7525'] + ink2.ink2r['7528']
|
||||
|
||||
expect(gap).toBe(dispositionsAndTax)
|
||||
expect(gap).toBe(160_000) // 100 000 periodiseringsfond + 60 000 skatt
|
||||
|
||||
// This gap is Stage 2 of #1051, deliberately outstanding
|
||||
// (DECISIONS.md:632). When generateIncomeStatement moves to
|
||||
// 'exclude-final', the operational family joins the statutory one and this
|
||||
// expectation becomes gap === 0.
|
||||
})
|
||||
})
|
||||
@@ -215,6 +215,7 @@ describe('generateDimensionPnl', () => {
|
||||
})
|
||||
|
||||
expect(mockTrialBalance).toHaveBeenCalledWith(supabase, 'company-1', 'period-1', {
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
toDate: '2026-06-30',
|
||||
})
|
||||
// The label reflects actual coverage: cumulative from period_start.
|
||||
|
||||
@@ -61,9 +61,10 @@ describe('generateMonthlyBreakdown', () => {
|
||||
})
|
||||
|
||||
it('correctly classifies revenue (class 3) and expense (class 4-7) accounts', async () => {
|
||||
// Two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts):
|
||||
// call 1 = fiscal period, call 2 = journal_entries, call 3 = lines by
|
||||
// entry id (the parent entry is reattached under `journal_entry`).
|
||||
// call 1 = fiscal period, call 2 = reversed year_end ids (the year-end
|
||||
// exclusion chain), then the two-step entry-lines fetch
|
||||
// (lib/bookkeeping/entry-lines.ts): call 3 = journal_entries, call 4 =
|
||||
// lines by entry id (the parent entry is reattached under `journal_entry`).
|
||||
let callCount = 0
|
||||
supabase.from.mockImplementation(() => {
|
||||
callCount++
|
||||
@@ -71,6 +72,10 @@ describe('generateMonthlyBreakdown', () => {
|
||||
return chain({ data: { period_start: '2024-01-01', period_end: '2024-03-31' }, error: null })
|
||||
}
|
||||
if (callCount === 2) {
|
||||
// No undone bokslut in these fixtures.
|
||||
return chain({ data: [], error: null })
|
||||
}
|
||||
if (callCount === 3) {
|
||||
return chain({
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-01-15', status: 'posted', company_id: 'company-1', fiscal_period_id: 'period-1' },
|
||||
@@ -122,6 +127,10 @@ describe('generateMonthlyBreakdown', () => {
|
||||
return chain({ data: { period_start: '2024-01-01', period_end: '2024-01-31' }, error: null })
|
||||
}
|
||||
if (callCount === 2) {
|
||||
// No undone bokslut in these fixtures.
|
||||
return chain({ data: [], error: null })
|
||||
}
|
||||
if (callCount === 3) {
|
||||
return chain({
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-01-15', status: 'posted', company_id: 'company-1', fiscal_period_id: 'period-1' },
|
||||
@@ -151,3 +160,43 @@ describe('generateMonthlyBreakdown', () => {
|
||||
expect(jan.income).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateMonthlyBreakdown: year-end exclusion', () => {
|
||||
it('excludes year_end entries and the undone-bokslut chain', async () => {
|
||||
// Regression: the resultatavslut posts the mirror image of every P&L
|
||||
// account, so the fiscal-year-end month reported the whole year's revenue
|
||||
// as negative income. Measured on production as 28 companies affected,
|
||||
// worst case a month understated by 10 347 472 kr.
|
||||
const filters: Array<{ method: string; args: unknown[] }> = []
|
||||
let callCount = 0
|
||||
supabase.from.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return chain({ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null })
|
||||
}
|
||||
if (callCount === 2) {
|
||||
return chain({ data: [{ id: 'reversed-ye-1' }], error: null })
|
||||
}
|
||||
// Record the entry-side filters so the exclusion is asserted, not assumed.
|
||||
const c: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'neq', 'order', 'or']) {
|
||||
c[m] = (...args: unknown[]) => {
|
||||
filters.push({ method: m, args })
|
||||
return c
|
||||
}
|
||||
}
|
||||
c.single = () => Promise.resolve({ data: [], error: null })
|
||||
c.range = () => Promise.resolve({ data: [], error: null })
|
||||
return c
|
||||
})
|
||||
|
||||
await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1')
|
||||
|
||||
expect(filters).toContainEqual({ method: 'neq', args: ['source_type', 'year_end'] })
|
||||
// The storno/correction chain of a REVERSED year-end entry must go too, or
|
||||
// an undone bokslut leaves half the pair behind.
|
||||
const orFilters = filters.filter((f) => f.method === 'or').map((f) => String(f.args[0]))
|
||||
expect(orFilters.some((f) => f.includes('reverses_id') && f.includes('reversed-ye-1'))).toBe(true)
|
||||
expect(orFilters.some((f) => f.includes('correction_of_id') && f.includes('reversed-ye-1'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -265,7 +265,9 @@ describe('generateResultatrapport', () => {
|
||||
expect(report.groups[0].rows[0].prior_period).toBe(150000)
|
||||
expect(report.prior_period).toEqual({ start: '2025-01-01', end: '2025-12-31' })
|
||||
// The fallback resolved 'period-0' and the prior TB was fetched for it.
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-0')
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-0', {
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the prior column empty when there is no earlier period at all', async () => {
|
||||
@@ -328,6 +330,7 @@ describe('generateResultatrapport', () => {
|
||||
expect(report.groups[0].rows[0].prior_period).toBe(60000)
|
||||
expect(report.prior_period).toEqual({ start: '2025-01-01', end: '2025-03-31' })
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-0', {
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
fromDate: '2025-01-01',
|
||||
toDate: '2025-03-31',
|
||||
})
|
||||
@@ -375,10 +378,12 @@ describe('generateResultatrapport', () => {
|
||||
expect(report.groups[0].rows[0].prior_period).toBe(25000)
|
||||
expect(report.prior_period).toEqual({ start: '2025-06-01', end: '2025-07-31' })
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-old', {
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
fromDate: '2025-06-01',
|
||||
toDate: '2025-06-30',
|
||||
})
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(3, expect.anything(), 'company-1', 'period-1', {
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
fromDate: '2025-07-01',
|
||||
toDate: '2025-07-31',
|
||||
})
|
||||
@@ -566,3 +571,33 @@ describe('shiftDateOneYearBack', () => {
|
||||
expect(shiftDateOneYearBack('2001-02-28')).toBe('2000-02-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('closed fiscal year', () => {
|
||||
it('excludes year-end closing entries on every trial-balance pass', async () => {
|
||||
// Regression: the resultatavslut posts the mirror image of each P&L
|
||||
// account into 2099 inside the same period, so without this exclusion the
|
||||
// period movements this report sums cancel out and a closed year reads 0
|
||||
// on every line. Reported against INK2R on the same ledger 2026-07-29.
|
||||
const q = createQueuedMockSupabase()
|
||||
q.enqueue({
|
||||
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' },
|
||||
})
|
||||
q.enqueue({ data: { period_start: '2025-01-01', period_end: '2025-12-31' } })
|
||||
mockTrialBalance.mockResolvedValue({
|
||||
rows: [makeRow({ account_number: '3001', period_credit: 500000 })],
|
||||
totalDebit: 0,
|
||||
totalCredit: 0,
|
||||
isBalanced: true,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
|
||||
|
||||
// Both the current pass and the prior-year comparison pass must exclude:
|
||||
// a prior year is almost always closed.
|
||||
for (const call of mockTrialBalance.mock.calls) {
|
||||
expect(call[3]).toMatchObject({ closingEntry: 'exclude-all-year-end' })
|
||||
}
|
||||
expect(report.groups[0].rows[0].current_period).toBe(500000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* WHERE sign reclassification applies, pinned in both directions.
|
||||
*
|
||||
* A skattekonto (1630) with a credit balance is money owed to Skatteverket, and
|
||||
* a momsavräkningskonto (2641) with a debit balance is money owed back. ÅRL 3
|
||||
* kap. and K2 present a post by the substance of its balance, so the STATUTORY
|
||||
* surfaces move them. The ACCOUNT-ORIENTED surfaces deliberately do not.
|
||||
*
|
||||
* Both halves are asserted here on purpose:
|
||||
*
|
||||
* - The statutory half stops the reclassification silently disappearing from
|
||||
* one surface again. It shipped in the K2 mapper on 2026-07-23 and was
|
||||
* missing from INK2R until 2026-07-29, which is exactly how a customer came
|
||||
* to be comparing two of our own reports against each other.
|
||||
*
|
||||
* - The operational half stops a future sweep "fixing" Balansräkning and
|
||||
* Balansrapport into disagreeing with their own documented contract. Those
|
||||
* two are organised BY ACCOUNT NUMBER under BAS-prefix headings, and
|
||||
* balansrapport.ts states an invariant that depends on every row staying
|
||||
* debit-positive where it was booked: moving konto 1630 into a liability
|
||||
* section would break the add-the-rows-to-verify-the-balance property and
|
||||
* hide the account from anyone looking for it by number.
|
||||
*
|
||||
* See DECISIONS.md for the scope decision. If a new STATUTORY presentation is
|
||||
* added, it belongs in the first half of this file.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/reports/trial-balance', () => ({
|
||||
generateTrialBalance: vi.fn(),
|
||||
}))
|
||||
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateBalanceSheet } from '../balance-sheet'
|
||||
import { mapTrialBalancesToK2 } from '@/lib/bokslut/ixbrl/k2-mapper'
|
||||
import { CLOSED_ROWS, EXPECTED, PRE_CLOSING_ROWS, rowsForMode } from './closed-year-fixture'
|
||||
|
||||
const COMPANY_ID = 'company-1'
|
||||
const PERIOD_ID = 'period-1'
|
||||
|
||||
function findRow(
|
||||
sections: Array<{ title: string; rows: Array<{ account_number: string; amount: number }> }>,
|
||||
accountNumber: string,
|
||||
) {
|
||||
for (const section of sections) {
|
||||
for (const row of section.rows) {
|
||||
if (row.account_number === accountNumber) return { section: section.title, amount: row.amount }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(generateTrialBalance).mockImplementation(async (_s, _c, _p, opts) => ({
|
||||
rows: rowsForMode(opts.closingEntry),
|
||||
totalDebit: 0,
|
||||
totalCredit: 0,
|
||||
isBalanced: true,
|
||||
}))
|
||||
})
|
||||
|
||||
describe('statutory surfaces reclassify by sign', () => {
|
||||
it('the K2 årsredovisning moves a credit 1630 into Skatteskulder', () => {
|
||||
const k2 = mapTrialBalancesToK2({ full: CLOSED_ROWS, preClosing: PRE_CLOSING_ROWS }, null)
|
||||
|
||||
expect(k2.br['Skatteskulder'].current).toBe(10_000 + EXPECTED.taxAccountCredit)
|
||||
expect(k2.br['OvrigaFordringarKortfristiga'].current).toBe(EXPECTED.inputVatDebit)
|
||||
})
|
||||
|
||||
it('and says so in a warning rather than moving money silently', () => {
|
||||
const k2 = mapTrialBalancesToK2({ full: CLOSED_ROWS, preClosing: PRE_CLOSING_ROWS }, null)
|
||||
|
||||
expect(k2.warnings.some((w) => w.includes('1630-1659'))).toBe(true)
|
||||
expect(k2.warnings.some((w) => w.includes('2610-2659'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('account-oriented surfaces deliberately do NOT reclassify', () => {
|
||||
it('Balansräkning keeps konto 1630 under its own BAS heading', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateBalanceSheet({} as any, COMPANY_ID, PERIOD_ID)
|
||||
|
||||
const taxAccount = findRow(report.asset_sections, '1630')
|
||||
expect(taxAccount).not.toBeNull()
|
||||
expect(taxAccount!.section).toBe('Övriga kortfristiga fordringar')
|
||||
// Shown debit-positive, so a credit balance renders negative. That is the
|
||||
// documented convention for this report, not a bug to reclassify away.
|
||||
expect(taxAccount!.amount).toBe(-EXPECTED.taxAccountCredit)
|
||||
})
|
||||
|
||||
it('Balansräkning keeps konto 2641 under Moms och punktskatter', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateBalanceSheet({} as any, COMPANY_ID, PERIOD_ID)
|
||||
|
||||
const inputVat = findRow(report.equity_liability_sections, '2641')
|
||||
expect(inputVat).not.toBeNull()
|
||||
expect(inputVat!.section).toBe('Moms och punktskatter')
|
||||
// Credit-positive on the liability side, so a debit balance renders negative.
|
||||
expect(inputVat!.amount).toBe(-EXPECTED.inputVatDebit)
|
||||
})
|
||||
|
||||
it('and still ties out, because nothing moved across the split', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateBalanceSheet({} as any, COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(report.total_assets).toBe(report.total_equity_liabilities)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
SIGN_RECLASSIFICATION_RULES,
|
||||
isInRanges,
|
||||
selectReclassifiedAccounts,
|
||||
type SignReclassificationId,
|
||||
} from '../sign-reclassification'
|
||||
|
||||
function ruleFor(id: SignReclassificationId) {
|
||||
const rule = SIGN_RECLASSIFICATION_RULES.find((r) => r.id === id)
|
||||
if (!rule) throw new Error(`missing rule ${id}`)
|
||||
return rule
|
||||
}
|
||||
|
||||
const TAX_ACCOUNT = ruleFor('tax_account_credit_to_liability')
|
||||
const TAX_LIABILITY = ruleFor('tax_liability_debit_to_receivable')
|
||||
const VAT_LIABILITY = ruleFor('vat_liability_debit_to_receivable')
|
||||
|
||||
/** Balances are debit-positive, exactly as the ledger stores them. */
|
||||
function balances(entries: Record<string, number>): ReadonlyMap<string, number> {
|
||||
return new Map(Object.entries(entries))
|
||||
}
|
||||
|
||||
describe('SIGN_RECLASSIFICATION_RULES', () => {
|
||||
it('has a unique id per rule', () => {
|
||||
const ids = SIGN_RECLASSIFICATION_RULES.map((r) => r.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('carries a Swedish warning for every rule', () => {
|
||||
for (const rule of SIGN_RECLASSIFICATION_RULES) {
|
||||
expect(rule.warning.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInRanges', () => {
|
||||
it('compares account numbers as strings, not as quantities', () => {
|
||||
expect(isInRanges('1630', [{ start: '1630', end: '1659' }])).toBe(true)
|
||||
expect(isInRanges('1659', [{ start: '1630', end: '1659' }])).toBe(true)
|
||||
expect(isInRanges('1629', [{ start: '1630', end: '1659' }])).toBe(false)
|
||||
expect(isInRanges('1660', [{ start: '1630', end: '1659' }])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectReclassifiedAccounts: tax account (deviating_rows)', () => {
|
||||
it('reclassifies a skattekonto carrying a credit balance', () => {
|
||||
// 1630 with a credit balance is money owed to Skatteverket, not a fordran.
|
||||
expect(selectReclassifiedAccounts(TAX_ACCOUNT, balances({ '1630': -22_985 })))
|
||||
.toEqual(['1630'])
|
||||
})
|
||||
|
||||
it('leaves a skattekonto with a normal debit balance alone', () => {
|
||||
expect(selectReclassifiedAccounts(TAX_ACCOUNT, balances({ '1630': 5_000 })))
|
||||
.toEqual([])
|
||||
})
|
||||
|
||||
it('does not net a momsfordran against a skattekontoskuld', () => {
|
||||
// The two settle separately, so only the deviating row moves even though
|
||||
// the range nets to a debit.
|
||||
const result = selectReclassifiedAccounts(
|
||||
TAX_ACCOUNT,
|
||||
balances({ '1630': -20_000, '1650': 30_000 }),
|
||||
)
|
||||
expect(result).toEqual(['1630'])
|
||||
})
|
||||
|
||||
it('ignores accounts outside the range', () => {
|
||||
expect(selectReclassifiedAccounts(TAX_ACCOUNT, balances({ '1510': -50_000 })))
|
||||
.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectReclassifiedAccounts: tax liabilities (net)', () => {
|
||||
it('reclassifies when paid F-skatt exceeds the booked tax', () => {
|
||||
// 2518 debit 80 000 vs 2512 credit 60 000 nets to a receivable.
|
||||
const result = selectReclassifiedAccounts(
|
||||
TAX_LIABILITY,
|
||||
balances({ '2512': -60_000, '2518': 80_000 }),
|
||||
)
|
||||
expect(result.sort()).toEqual(['2512', '2518'])
|
||||
})
|
||||
|
||||
it('leaves the post alone when the range nets to a liability', () => {
|
||||
expect(
|
||||
selectReclassifiedAccounts(
|
||||
TAX_LIABILITY,
|
||||
balances({ '2512': -123_180, '2518': 101_970 }),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('moves every account in range so the moved rows equal the deviating net', () => {
|
||||
const rows = { '2512': -10_000, '2518': 25_000 }
|
||||
const moved = selectReclassifiedAccounts(TAX_LIABILITY, balances(rows))
|
||||
const movedNet = moved.reduce((sum, acc) => sum + rows[acc as keyof typeof rows], 0)
|
||||
// Debit-positive net of the moved rows is the receivable now presented.
|
||||
expect(movedNet).toBe(15_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectReclassifiedAccounts: VAT accounts (net)', () => {
|
||||
it('reclassifies a net input-VAT receivable', () => {
|
||||
expect(selectReclassifiedAccounts(VAT_LIABILITY, balances({ '2641': 1_387.5 })))
|
||||
.toEqual(['2641'])
|
||||
})
|
||||
|
||||
it('leaves a normal net VAT liability alone', () => {
|
||||
expect(
|
||||
selectReclassifiedAccounts(
|
||||
VAT_LIABILITY,
|
||||
balances({ '2611': -50_000, '2641': 12_000 }),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('nets output against input VAT before deciding', () => {
|
||||
const result = selectReclassifiedAccounts(
|
||||
VAT_LIABILITY,
|
||||
balances({ '2611': -10_000, '2641': 12_000 }),
|
||||
)
|
||||
expect(result.sort()).toEqual(['2611', '2641'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectReclassifiedAccounts: float noise', () => {
|
||||
it('does not reclassify on sub-öre drift', () => {
|
||||
expect(selectReclassifiedAccounts(TAX_ACCOUNT, balances({ '1630': -0.001 })))
|
||||
.toEqual([])
|
||||
expect(selectReclassifiedAccounts(VAT_LIABILITY, balances({ '2641': 0.001 })))
|
||||
.toEqual([])
|
||||
})
|
||||
|
||||
it('reclassifies a real one-öre deviation', () => {
|
||||
expect(selectReclassifiedAccounts(TAX_ACCOUNT, balances({ '1630': -0.01 })))
|
||||
.toEqual(['1630'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* reconcileStatements: the comparison a customer used to do for us.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/reports/trial-balance', () => ({
|
||||
generateTrialBalance: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/bokslut/tax-provision/tax-adjustment-service', () => ({
|
||||
loadTaxAdjustmentSnapshot: vi.fn(),
|
||||
}))
|
||||
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { loadTaxAdjustmentSnapshot } from '@/lib/bokslut/tax-provision/tax-adjustment-service'
|
||||
import { reconcileStatements } from '../statement-reconciliation'
|
||||
import { CLOSED_ROWS, EXPECTED, rowsForMode } from './closed-year-fixture'
|
||||
|
||||
const COMPANY_ID = 'company-1'
|
||||
const PERIOD_ID = 'period-1'
|
||||
|
||||
function makeSupabase(isClosed = true) {
|
||||
const period = {
|
||||
id: PERIOD_ID,
|
||||
name: 'Räkenskapsår 2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
is_closed: isClosed,
|
||||
closing_entry_id: isClosed ? 'closing-1' : null,
|
||||
previous_period_id: null,
|
||||
}
|
||||
function chain(result: unknown): Record<string, unknown> {
|
||||
const c: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'neq', 'or', 'order', 'limit', 'contains']) {
|
||||
c[m] = () => c
|
||||
}
|
||||
c.single = async () => result
|
||||
c.maybeSingle = async () => result
|
||||
c.range = async () => result
|
||||
return c
|
||||
}
|
||||
return {
|
||||
from: (table: string) => {
|
||||
if (table === 'fiscal_periods') return chain({ data: period, error: null })
|
||||
if (table === 'company_settings') {
|
||||
return chain({
|
||||
data: {
|
||||
company_name: 'Testbolaget',
|
||||
org_number: '5560000000',
|
||||
entity_type: 'aktiebolag',
|
||||
address_line1: 'Testgatan 1',
|
||||
postal_code: '11122',
|
||||
city: 'Stockholm',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
if (table === 'companies') return chain({ data: { entity_type: 'aktiebolag' }, error: null })
|
||||
if (table === 'journal_entries') {
|
||||
return chain({ data: isClosed ? { status: 'posted' } : null, error: null })
|
||||
}
|
||||
return chain({ data: [], error: null })
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(loadTaxAdjustmentSnapshot).mockResolvedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ nonDeductibleExpenses: 0, nonTaxableIncome: 0 } as any,
|
||||
)
|
||||
vi.mocked(generateTrialBalance).mockImplementation(async (_s, _c, _p, opts) => ({
|
||||
rows: rowsForMode(opts.closingEntry),
|
||||
totalDebit: 0,
|
||||
totalCredit: 0,
|
||||
isBalanced: true,
|
||||
}))
|
||||
})
|
||||
|
||||
describe('reconcileStatements', () => {
|
||||
it('reports the ledger, statutory and operational figures side by side', async () => {
|
||||
const result = await reconcileStatements(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
const byFamily = Object.fromEntries(result.figures.map((f) => [f.family, f]))
|
||||
expect(byFamily.ledger.aretsResultat).toBe(EXPECTED.netResult)
|
||||
expect(byFamily.statutory.aretsResultat).toBe(EXPECTED.netResult)
|
||||
expect(byFamily.statutory.surface).toBe('INK2R (3.26/3.27)')
|
||||
// Operational reports before dispositions and tax, and says so.
|
||||
expect(byFamily.operational.aretsResultat).toBe(EXPECTED.resultAfterFinancial)
|
||||
expect(byFamily.operational.note).toContain('före bokslutsdispositioner')
|
||||
})
|
||||
|
||||
it('reconciles when the declaration matches the books', async () => {
|
||||
const result = await reconcileStatements(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.disagreements).toEqual([])
|
||||
expect(result.isReconciled).toBe(true)
|
||||
})
|
||||
|
||||
it('flags the declaration disagreeing with the booked result', async () => {
|
||||
// The reported shape: the form reads 0 while 2099 carries the real result.
|
||||
vi.mocked(generateTrialBalance).mockImplementation(async (_s, _c, _p, opts) => ({
|
||||
rows: opts.closingEntry === 'include' ? CLOSED_ROWS : CLOSED_ROWS,
|
||||
totalDebit: 0,
|
||||
totalCredit: 0,
|
||||
isBalanced: true,
|
||||
}))
|
||||
|
||||
const result = await reconcileStatements(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.isReconciled).toBe(false)
|
||||
expect(result.disagreements[0]).toContain('stämmer inte med det fastställda bokslutet')
|
||||
expect(result.disagreements[0]).toContain('442000')
|
||||
})
|
||||
|
||||
it('does not flag an open year, where 2099 is legitimately empty', async () => {
|
||||
const result = await reconcileStatements(makeSupabase(false), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.isReconciled).toBe(true)
|
||||
const ledger = result.figures.find((f) => f.family === 'ledger')
|
||||
expect(ledger?.note).toContain('inte stängt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileStatements: a failing generator must not read as reconciled', () => {
|
||||
it('surfaces a declaration that could not be generated', async () => {
|
||||
// Regression: the old implementation called the generator and caught ANY
|
||||
// throw as "wrong entity type", mapping it to a null figure that the
|
||||
// comparison then skipped. A real generator bug therefore reported
|
||||
// isReconciled: true, the exact opposite of this function's purpose.
|
||||
vi.mocked(generateTrialBalance).mockImplementation(async (_s, _c, _p, opts) => {
|
||||
if (opts.closingEntry === 'exclude-final') {
|
||||
throw new Error(
|
||||
'Closed fiscal period is missing closing_entry_id; statutory pre-closing balances cannot be generated safely',
|
||||
)
|
||||
}
|
||||
return { rows: rowsForMode(opts.closingEntry), totalDebit: 0, totalCredit: 0, isBalanced: true }
|
||||
})
|
||||
|
||||
const result = await reconcileStatements(makeSupabase(), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.isReconciled).toBe(false)
|
||||
expect(result.disagreements.some((d) => d.includes('kunde inte genereras'))).toBe(true)
|
||||
const statutory = result.figures.find((f) => f.family === 'statutory')
|
||||
expect(statutory?.aretsResultat).toBeNull()
|
||||
expect(statutory?.note).toContain('closing_entry_id')
|
||||
})
|
||||
|
||||
it('reports no statutory figure for an unsupported entity form, without inventing a disagreement', async () => {
|
||||
const supabase = {
|
||||
from: (table: string) => {
|
||||
const chain = (result: unknown): Record<string, unknown> => {
|
||||
const c: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'neq', 'or', 'order', 'limit', 'contains']) {
|
||||
c[m] = () => c
|
||||
}
|
||||
c.single = async () => result
|
||||
c.maybeSingle = async () => result
|
||||
c.range = async () => result
|
||||
return c
|
||||
}
|
||||
if (table === 'company_settings') return chain({ data: { entity_type: 'handelsbolag' }, error: null })
|
||||
if (table === 'companies') return chain({ data: { entity_type: 'handelsbolag' }, error: null })
|
||||
return makeSupabase().from(table)
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
|
||||
const result = await reconcileStatements(supabase, COMPANY_ID, PERIOD_ID)
|
||||
|
||||
const statutory = result.figures.find((f) => f.family === 'statutory')
|
||||
expect(statutory?.aretsResultat).toBeNull()
|
||||
expect(statutory?.note).toContain('stöds')
|
||||
expect(result.isReconciled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileStatements: entity-type resolution must not fail silently', () => {
|
||||
it('throws when the companies lookup genuinely fails', async () => {
|
||||
// Regression: resolveEntityType ignored both queries' error, so a DB
|
||||
// failure returned null, landed in the unsupported-form branch and
|
||||
// reported isReconciled: true. That is the same silent-false-reconciled
|
||||
// bug the surrounding refactor exists to close, one level down.
|
||||
const chain = (result: unknown): Record<string, unknown> => {
|
||||
const c: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'neq', 'or', 'order', 'limit', 'contains']) {
|
||||
c[m] = () => c
|
||||
}
|
||||
c.single = async () => result
|
||||
c.maybeSingle = async () => result
|
||||
c.range = async () => result
|
||||
return c
|
||||
}
|
||||
const supabase = {
|
||||
from: (table: string) => {
|
||||
if (table === 'company_settings') return chain({ data: null, error: { message: 'no rows' } })
|
||||
if (table === 'companies') return chain({ data: null, error: { message: 'permission denied' } })
|
||||
return makeSupabase().from(table)
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
|
||||
await expect(reconcileStatements(supabase, COMPANY_ID, PERIOD_ID)).rejects.toThrow(
|
||||
/Failed to resolve entity type: permission denied/,
|
||||
)
|
||||
})
|
||||
|
||||
it('still tolerates a missing company_settings row', async () => {
|
||||
// .single() errors on zero rows and many companies have no settings row,
|
||||
// so that specific failure must fall through to companies, not throw.
|
||||
const chain = (result: unknown): Record<string, unknown> => {
|
||||
const c: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'neq', 'or', 'order', 'limit', 'contains']) {
|
||||
c[m] = () => c
|
||||
}
|
||||
c.single = async () => result
|
||||
c.maybeSingle = async () => result
|
||||
c.range = async () => result
|
||||
return c
|
||||
}
|
||||
const base = makeSupabase()
|
||||
const supabase = {
|
||||
from: (table: string) => {
|
||||
if (table === 'company_settings') return chain({ data: null, error: { message: 'no rows' } })
|
||||
return base.from(table)
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
|
||||
const result = await reconcileStatements(supabase, COMPANY_ID, PERIOD_ID)
|
||||
const statutory = result.figures.find((f) => f.family === 'statutory')
|
||||
expect(statutory?.surface).toBe('INK2R (3.26/3.27)')
|
||||
})
|
||||
})
|
||||
@@ -98,6 +98,7 @@ describe('generateTrialBalance: dimensions option', () => {
|
||||
mockOpeningBalances.mockResolvedValue({ balances: new Map(), obEntryId: null })
|
||||
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
closingEntry: 'include',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
|
||||
@@ -110,7 +111,7 @@ describe('generateTrialBalance: dimensions option', () => {
|
||||
seedCommon()
|
||||
mockOpeningBalances.mockResolvedValue({ balances: new Map(), obEntryId: null })
|
||||
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
||||
|
||||
expect(containsCalls).toEqual([])
|
||||
})
|
||||
@@ -119,7 +120,7 @@ describe('generateTrialBalance: dimensions option', () => {
|
||||
seedCommon()
|
||||
mockOpeningBalances.mockResolvedValue({ balances: new Map(), obEntryId: null })
|
||||
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1', { dimensions: {} })
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include', dimensions: {} })
|
||||
|
||||
expect(containsCalls).toEqual([])
|
||||
})
|
||||
@@ -133,6 +134,7 @@ describe('generateTrialBalance: dimensions option', () => {
|
||||
})
|
||||
|
||||
const filtered = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
closingEntry: 'include',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
|
||||
@@ -142,7 +144,7 @@ describe('generateTrialBalance: dimensions option', () => {
|
||||
|
||||
// Unfiltered keeps the IB (control).
|
||||
seedCommon()
|
||||
const unfiltered = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
const unfiltered = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
||||
const bank2 = unfiltered.rows.find((r) => r.account_number === '1930')
|
||||
expect(bank2?.opening_debit).toBe(9000)
|
||||
expect(bank2?.closing_debit).toBe(9500)
|
||||
@@ -156,6 +158,7 @@ describe('generateTrialBalance: dimensions option', () => {
|
||||
mockOpeningBalances.mockResolvedValue({ balances: new Map(), obEntryId: null })
|
||||
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
closingEntry: 'include',
|
||||
fromDate: '2026-06-01',
|
||||
dimensions: { '1': 'KS01' },
|
||||
})
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('generateTrialBalance', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
||||
|
||||
expect(result.rows).toEqual([])
|
||||
expect(result.totalDebit).toBe(0)
|
||||
@@ -87,7 +87,7 @@ describe('generateTrialBalance', () => {
|
||||
{
|
||||
data: [
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
||||
{ account_number: '1930', debit_amount: 300, credit_amount: 0 },
|
||||
{ closingEntry: 'include', account_number: '1930', debit_amount: 300, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 200 },
|
||||
{ account_number: '1930', debit_amount: 450, credit_amount: 0 },
|
||||
],
|
||||
@@ -105,7 +105,7 @@ describe('generateTrialBalance', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
||||
|
||||
expect(result.rows).toHaveLength(2)
|
||||
// Sorted by account number
|
||||
@@ -131,7 +131,7 @@ describe('generateTrialBalance', () => {
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit: 10000, credit: 0 },
|
||||
{ account_number: '2099', debit: 0, credit: 10000 },
|
||||
{ closingEntry: 'include', account_number: '2099', debit: 0, credit: 10000 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -158,7 +158,7 @@ describe('generateTrialBalance', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-2')
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-2', { closingEntry: 'include' })
|
||||
|
||||
// 1930: opening debit 10000, period credit 500 → closing debit 10000, credit 500
|
||||
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
|
||||
@@ -196,7 +196,7 @@ describe('generateTrialBalance', () => {
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 8000, credit_amount: 0 },
|
||||
{ account_number: '2099', debit_amount: 0, credit_amount: 8000 },
|
||||
{ closingEntry: 'include', account_number: '2099', debit_amount: 0, credit_amount: 8000 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -221,7 +221,7 @@ describe('generateTrialBalance', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-2')
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-2', { closingEntry: 'include' })
|
||||
|
||||
// 1930: opening 8000 debit + period 1000 debit = closing 9000 debit
|
||||
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
|
||||
@@ -258,7 +258,7 @@ describe('generateTrialBalance', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
||||
|
||||
expect(result.rows[0].account_name).toBe('Konto 9999')
|
||||
})
|
||||
@@ -281,7 +281,7 @@ describe('generateTrialBalance', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
||||
|
||||
expect(result.rows[0].account_class).toBe(5)
|
||||
})
|
||||
@@ -295,7 +295,7 @@ describe('generateTrialBalance', () => {
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
|
||||
{ closingEntry: 'include', account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 33.34, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 100 },
|
||||
],
|
||||
@@ -313,7 +313,7 @@ describe('generateTrialBalance', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
||||
|
||||
expect(result.rows[0].closing_debit).toBe(100)
|
||||
expect(result.totalDebit).toBe(100)
|
||||
@@ -330,7 +330,7 @@ describe('generateTrialBalance', () => {
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 999 },
|
||||
{ closingEntry: 'include', account_number: '3001', debit_amount: 0, credit_amount: 999 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -346,7 +346,7 @@ describe('generateTrialBalance', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
||||
|
||||
expect(result.totalDebit).toBe(1000)
|
||||
expect(result.totalCredit).toBe(999)
|
||||
@@ -363,7 +363,7 @@ describe('generateTrialBalance', () => {
|
||||
],
|
||||
}
|
||||
|
||||
await expect(generateTrialBalance(supabase, 'company-1', 'period-1')).rejects.toThrow('DB error')
|
||||
await expect(generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })).rejects.toThrow('DB error')
|
||||
})
|
||||
|
||||
it('handles balanced two-account entry', async () => {
|
||||
@@ -375,7 +375,7 @@ describe('generateTrialBalance', () => {
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 5000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 5000 },
|
||||
{ closingEntry: 'include', account_number: '3001', debit_amount: 0, credit_amount: 5000 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -391,7 +391,7 @@ describe('generateTrialBalance', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
||||
|
||||
expect(result.rows).toHaveLength(2)
|
||||
expect(result.totalDebit).toBe(5000)
|
||||
@@ -418,7 +418,7 @@ describe('generateTrialBalance', () => {
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
||||
{ closingEntry: 'include', account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -434,7 +434,7 @@ describe('generateTrialBalance', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
||||
|
||||
// Same as the existing "balanced two-account" case: no roll-forward query
|
||||
// is consumed because no range is requested.
|
||||
@@ -457,7 +457,7 @@ describe('generateTrialBalance', () => {
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
||||
{ closingEntry: 'include', account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -474,6 +474,7 @@ describe('generateTrialBalance', () => {
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
closingEntry: 'include',
|
||||
fromDate: '2024-01-01',
|
||||
toDate: '2024-06-30',
|
||||
})
|
||||
@@ -520,6 +521,7 @@ describe('generateTrialBalance', () => {
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
closingEntry: 'include',
|
||||
fromDate: '2024-04-01',
|
||||
toDate: '2024-06-30',
|
||||
})
|
||||
@@ -570,7 +572,7 @@ describe('generateTrialBalance', () => {
|
||||
}
|
||||
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
excludeFinalClosingEntry: true,
|
||||
closingEntry: 'exclude-final',
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -597,14 +599,14 @@ describe('generateTrialBalance', () => {
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
journal_entries: [{ data: [{ id: 'tax-1' }, { id: 'appropriation-1' }], error: null }],
|
||||
journal_entries: [{ data: [{ id: 'tax-1' }, { closingEntry: 'include', id: 'appropriation-1' }], error: null }],
|
||||
journal_entry_lines: [{ data: [], error: null }],
|
||||
chart_of_accounts: [{ data: [], error: null }],
|
||||
}
|
||||
|
||||
await expect(
|
||||
generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
excludeFinalClosingEntry: true,
|
||||
closingEntry: 'exclude-final',
|
||||
}),
|
||||
).rejects.toThrow(/missing closing_entry_id/i)
|
||||
|
||||
@@ -636,7 +638,7 @@ describe('generateTrialBalance', () => {
|
||||
}
|
||||
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
excludeFinalClosingEntry: true,
|
||||
closingEntry: 'exclude-final',
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -659,13 +661,13 @@ describe('generateTrialBalance', () => {
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
journal_entries: [{ data: [{ id: 'closing-1' }, { id: 'storno-1' }], error: null }],
|
||||
journal_entries: [{ data: [{ id: 'closing-1' }, { closingEntry: 'include', id: 'storno-1' }], error: null }],
|
||||
journal_entry_lines: [{ data: [], error: null }],
|
||||
chart_of_accounts: [{ data: [], error: null }],
|
||||
}
|
||||
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
excludeFinalClosingEntry: true,
|
||||
closingEntry: 'exclude-final',
|
||||
})
|
||||
|
||||
// The OR excludes closing-1 only while status is posted. If it is
|
||||
@@ -699,7 +701,7 @@ describe('generateTrialBalance', () => {
|
||||
}
|
||||
|
||||
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
excludeYearEndClosing: true,
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -730,7 +732,7 @@ describe('generateTrialBalance', () => {
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 750, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 750 },
|
||||
{ closingEntry: 'include', account_number: '3001', debit_amount: 0, credit_amount: 750 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -749,6 +751,7 @@ describe('generateTrialBalance', () => {
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
closingEntry: 'include',
|
||||
fromDate: '2024-11-01',
|
||||
toDate: '2024-11-30',
|
||||
})
|
||||
|
||||
@@ -22,6 +22,8 @@ export async function generateBalanceSheet(
|
||||
options?: { fromDate?: string; toDate?: string }
|
||||
): Promise<BalanceSheetReport> {
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
// Balance sheet: 2099 must carry årets resultat, so the resultatavslut stays in.
|
||||
closingEntry: 'include',
|
||||
fromDate: options?.fromDate,
|
||||
toDate: options?.toDate,
|
||||
})
|
||||
|
||||
@@ -49,6 +49,8 @@ export async function generateBalansrapport(
|
||||
const effectiveToDate = options?.toDate ?? period.period_end
|
||||
|
||||
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
// Class 1-2 only, and 2099 must carry årets resultat.
|
||||
closingEntry: 'include',
|
||||
fromDate: options?.fromDate,
|
||||
toDate: options?.toDate,
|
||||
})
|
||||
|
||||
@@ -62,7 +62,9 @@ export async function validateBalanceContinuity(
|
||||
const { rows: trialRows } = await generateTrialBalance(
|
||||
supabase,
|
||||
companyId,
|
||||
prevPeriod.id
|
||||
prevPeriod.id,
|
||||
// IB/UB continuity is checked against the ledger as posted.
|
||||
{ closingEntry: 'include' }
|
||||
)
|
||||
|
||||
const previousUB = new Map<string, { net: number; name: string }>()
|
||||
|
||||
@@ -29,10 +29,10 @@ const CLASS_LABELS: Record<number, string> = {
|
||||
* with activity becomes a column, plus an explicit "(Utan dimension)" bucket.
|
||||
*
|
||||
* Reconciliation is by construction, not by convention: the Totalt column
|
||||
* comes from the SAME unfiltered generateTrialBalance pass resultatrapport
|
||||
* uses (same options, same filterPnl scope, same sign convention), and the
|
||||
* untagged bucket is the residual Totalt − tagged columns. Columns therefore
|
||||
* always sum exactly to the unfiltered resultatrapport: including edge cases
|
||||
* comes from the SAME generateTrialBalance pass resultatrapport uses (same
|
||||
* options including closingEntry, same filterPnl scope, same sign convention),
|
||||
* and the untagged bucket is the residual Totalt − tagged columns. Columns
|
||||
* therefore always sum exactly to resultatrapport: including edge cases
|
||||
* the line pass cannot see (e.g. P&L opening remnants when a prior year was
|
||||
* never closed), which land in "(Utan dimension)" where they belong.
|
||||
*/
|
||||
@@ -67,7 +67,11 @@ export async function generateDimensionPnl(
|
||||
}
|
||||
|
||||
// ── Totalt column: identical inputs to resultatrapport ─────────
|
||||
// closingEntry must match resultatrapport exactly or the two stop
|
||||
// reconciling, and a closed year reads zero without it (see resultatrapport
|
||||
// and DECISIONS.md:632).
|
||||
const tb = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
toDate: options?.toDate,
|
||||
})
|
||||
const pnlRows = filterPnl(tb.rows)
|
||||
|
||||
@@ -357,7 +357,7 @@ async function generatePeriodReports(
|
||||
): Promise<PeriodReports> {
|
||||
const [trialBalance, incomeStatement, balanceSheet, generalLedger, journalRegister] =
|
||||
await Promise.all([
|
||||
generateTrialBalance(supabase, companyId, period.id),
|
||||
generateTrialBalance(supabase, companyId, period.id, { closingEntry: 'include' }),
|
||||
generateIncomeStatement(supabase, companyId, period.id),
|
||||
generateBalanceSheet(supabase, companyId, period.id),
|
||||
generateGeneralLedger(supabase, companyId, period.id),
|
||||
|
||||
@@ -53,7 +53,10 @@ export async function findUntransferredResults(
|
||||
|
||||
const culprits: UntransferredResult[] = []
|
||||
for (const period of candidates) {
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, period.id)
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, period.id, {
|
||||
// Diagnostics must see the ledger exactly as posted.
|
||||
closingEntry: 'include',
|
||||
})
|
||||
const plNet = roundOre(
|
||||
rows
|
||||
.filter((r) => r.account_class >= 3 && r.account_class <= 8)
|
||||
|
||||
@@ -27,7 +27,9 @@ export async function generateIncomeStatement(
|
||||
// the resultaträkning to zero. The income statement must reflect the
|
||||
// pre-closing activity for the year.
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
excludeYearEndClosing: true,
|
||||
// Operational convention, unchanged. Moving this to 'exclude-final' is
|
||||
// Stage 2 of #1051 and deliberately deferred: see DECISIONS.md:632.
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
fromDate: options?.fromDate,
|
||||
toDate: options?.toDate,
|
||||
dimensions: options?.dimensions,
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Integration tests for generateINK2Declaration.
|
||||
*
|
||||
* These cover the state the engine is actually used in: a CLOSED fiscal year.
|
||||
* INK2 is filed after bokslut, so the resultatavslut has already zeroed every
|
||||
* P&L account against 2099. The engine previously summed journal entries raw,
|
||||
* which made the whole resultaträkning collapse to zero (and INK2S with it)
|
||||
* while the balance sheet still tied out, so nothing warned. The old test file
|
||||
* only exercised the mapping table, never a closed period.
|
||||
*
|
||||
* The trial balance is mocked so the fixture can plant deterministic balances:
|
||||
* the pre-closing view feeds the income statement, the closed view the balance
|
||||
* sheet.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/reports/trial-balance', () => ({
|
||||
generateTrialBalance: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/bokslut/tax-provision/tax-adjustment-service', () => ({
|
||||
loadTaxAdjustmentSnapshot: vi.fn(),
|
||||
}))
|
||||
|
||||
import { generateINK2Declaration } from '../ink2-engine'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { loadTaxAdjustmentSnapshot } from '@/lib/bokslut/tax-provision/tax-adjustment-service'
|
||||
import type { TrialBalanceRow } from '@/types'
|
||||
|
||||
const COMPANY_ID = 'company-1'
|
||||
const PERIOD_ID = 'period-1'
|
||||
const CLOSING_ENTRY_ID = 'closing-entry-1'
|
||||
|
||||
/** Build a trial balance row from a debit-positive balance. */
|
||||
function row(accountNumber: string, accountName: string, balance: number): TrialBalanceRow {
|
||||
const debit = balance > 0 ? balance : 0
|
||||
const credit = balance < 0 ? -balance : 0
|
||||
return {
|
||||
account_number: accountNumber,
|
||||
account_name: accountName,
|
||||
account_class: Number(accountNumber[0]),
|
||||
opening_debit: 0,
|
||||
opening_credit: 0,
|
||||
period_debit: debit,
|
||||
period_credit: credit,
|
||||
closing_debit: debit,
|
||||
closing_credit: credit,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthetic AB, first fiscal year, closed.
|
||||
*
|
||||
* Rörelseresultat 700 000 − 100 000 = 600 000
|
||||
* Finansiella poster 5 000 − 3 000 = 2 000
|
||||
* Efter finansiella = 602 000
|
||||
* Periodiseringsfond −100 000 = 502 000
|
||||
* Skatt −60 000 = 442 000
|
||||
*
|
||||
* 1630 carries a credit (skatteskuld presented as a negative fordran) and
|
||||
* 2641 a debit (momsfordran presented as a negative skuld): both must be
|
||||
* reclassified by sign.
|
||||
*/
|
||||
const PRE_CLOSING_ROWS: TrialBalanceRow[] = [
|
||||
row('1630', 'Avräkning skatter och avgifter', -20_000),
|
||||
row('1930', 'Företagskonto', 610_000),
|
||||
row('2081', 'Aktiekapital', -25_000),
|
||||
row('2099', 'Årets resultat', 0),
|
||||
row('2125', 'Periodiseringsfond', -100_000),
|
||||
row('2440', 'Leverantörsskulder', -15_000),
|
||||
row('2512', 'Beräknad inkomstskatt', -60_000),
|
||||
row('2518', 'Betald F-skatt', 50_000),
|
||||
row('2641', 'Debiterad ingående moms', 2_000),
|
||||
row('3001', 'Försäljning', -700_000),
|
||||
row('5010', 'Lokalhyra', 100_000),
|
||||
row('8311', 'Ränteintäkter', -5_000),
|
||||
row('8410', 'Räntekostnader', 3_000),
|
||||
row('8811', 'Avsättning till periodiseringsfond', 100_000),
|
||||
row('8910', 'Skatt på årets resultat', 60_000),
|
||||
]
|
||||
|
||||
/** Same year after the resultatavslut: P&L zeroed, 2099 carries the result. */
|
||||
const CLOSED_ROWS: TrialBalanceRow[] = PRE_CLOSING_ROWS.map((r) => {
|
||||
if (r.account_number === '2099') return row('2099', 'Årets resultat', -442_000)
|
||||
if (Number(r.account_number[0]) >= 3) return row(r.account_number, r.account_name, 0)
|
||||
return r
|
||||
})
|
||||
|
||||
interface SupabaseStub {
|
||||
from: (table: string) => unknown
|
||||
}
|
||||
|
||||
function makeSupabase(options?: {
|
||||
closingEntryId?: string | null
|
||||
closingEntryStatus?: string
|
||||
isClosed?: boolean
|
||||
}): SupabaseStub {
|
||||
const closingEntryId =
|
||||
options?.closingEntryId === undefined ? CLOSING_ENTRY_ID : options.closingEntryId
|
||||
|
||||
return {
|
||||
from: (table: string) => {
|
||||
if (table === 'fiscal_periods') {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
single: async () => ({
|
||||
data: {
|
||||
id: PERIOD_ID,
|
||||
name: 'Räkenskapsår 1',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
is_closed: options?.isClosed ?? true,
|
||||
closing_entry_id: closingEntryId,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === 'company_settings') {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
single: async () => ({
|
||||
data: {
|
||||
company_name: 'Testbolaget AB',
|
||||
org_number: '5560000000',
|
||||
entity_type: 'aktiebolag',
|
||||
address_line1: 'Testgatan 1',
|
||||
postal_code: '11122',
|
||||
city: 'Stockholm',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === 'journal_entries') {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
maybeSingle: async () => ({
|
||||
data: closingEntryId
|
||||
? { status: options?.closingEntryStatus ?? 'posted' }
|
||||
: null,
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const anySupabase = (stub: SupabaseStub) => stub as any
|
||||
|
||||
function stubTrialBalances(closed: TrialBalanceRow[], preClosing: TrialBalanceRow[]) {
|
||||
vi.mocked(generateTrialBalance).mockImplementation(
|
||||
async (_supabase, _companyId, _periodId, opts) => ({
|
||||
rows: opts.closingEntry === 'exclude-final' ? preClosing : closed,
|
||||
totalDebit: 0,
|
||||
totalCredit: 0,
|
||||
isBalanced: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(loadTaxAdjustmentSnapshot).mockResolvedValue({
|
||||
nonDeductibleExpenses: 4_000,
|
||||
nonTaxableIncome: 0,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
stubTrialBalances(CLOSED_ROWS, PRE_CLOSING_ROWS)
|
||||
})
|
||||
|
||||
describe('generateINK2Declaration: closed fiscal year', () => {
|
||||
it('reads the income statement from the pre-closing books', async () => {
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
// The regression: every one of these was 0 when the resultatavslut was
|
||||
// summed into the P&L accounts.
|
||||
expect(result.ink2r['7410']).toBe(700_000)
|
||||
expect(result.ink2r['7513']).toBe(100_000)
|
||||
expect(result.ink2r['7417']).toBe(5_000)
|
||||
expect(result.ink2r['7522']).toBe(3_000)
|
||||
expect(result.ink2r['7525']).toBe(100_000)
|
||||
expect(result.ink2r['7528']).toBe(60_000)
|
||||
expect(result.ink2r['7450']).toBe(442_000)
|
||||
expect(result.ink2r['7550']).toBe(0)
|
||||
})
|
||||
|
||||
it('computes the result subtotals', async () => {
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.totals.operatingResult).toBe(600_000)
|
||||
expect(result.totals.aretsResultat).toBe(442_000)
|
||||
})
|
||||
|
||||
it('reads the balance sheet from the closed books so 7302 carries the result', async () => {
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.ink2r['7302']).toBe(442_000)
|
||||
expect(result.ink2r['7301']).toBe(25_000)
|
||||
expect(result.ink2r['7321']).toBe(100_000)
|
||||
})
|
||||
|
||||
it('does not double-count årets resultat in equity', async () => {
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
// 2099 already holds 442 000. Adding resultAfterFinancial on top would
|
||||
// report 1 054 000 and raise a bogus imbalance warning.
|
||||
expect(result.totals.totalEquityLiabilities).toBe(612_000)
|
||||
expect(result.totals.totalAssets).toBe(612_000)
|
||||
expect(result.warnings.some((w) => w.includes('inte i balans'))).toBe(false)
|
||||
})
|
||||
|
||||
it('derives INK2S from the restored result', async () => {
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.ink2s['7650']).toBe(442_000)
|
||||
expect(result.ink2s['7750']).toBe(0)
|
||||
expect(result.ink2s['7651']).toBe(60_000)
|
||||
expect(result.ink2s['7653']).toBe(4_000)
|
||||
// 442 000 + 60 000 + 4 000
|
||||
expect(result.ink2s['8020']).toBe(506_000)
|
||||
expect(result.ink2s['8021']).toBe(0)
|
||||
expect(result.ink2['7113']).toBe(506_000)
|
||||
})
|
||||
|
||||
it('does not re-add the periodiseringsfond, which is already in the result', async () => {
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
// 7525 appears on INK2R as a bokslutsdisposition but must not inflate the
|
||||
// taxable result: it already reduced årets resultat.
|
||||
expect(result.ink2r['7525']).toBe(100_000)
|
||||
expect(result.ink2s['8020']).toBe(506_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateINK2Declaration: sign-based reclassification', () => {
|
||||
it('presents a skattekonto credit as a skatteskuld, not a negative fordran', async () => {
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
// 2512 − 2518 = 10 000, plus the reclassified 1630 credit of 20 000.
|
||||
expect(result.ink2r['7368']).toBe(30_000)
|
||||
expect(result.ink2r['7261']).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('presents an input-VAT debit as a fordran, not a negative skuld', async () => {
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.ink2r['7261']).toBe(2_000)
|
||||
expect(result.ink2r['7369']).toBe(0)
|
||||
})
|
||||
|
||||
it('moves the account rows so the breakdown matches the post totals', async () => {
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
const codes = ['7261', '7368', '7369'] as const
|
||||
for (const code of codes) {
|
||||
const sum = result.breakdown[code].accounts.reduce((s, a) => s + a.amount, 0)
|
||||
expect(sum).toBe(result.ink2r[code])
|
||||
}
|
||||
expect(result.breakdown['7368'].accounts.map((a) => a.accountNumber)).toContain('1630')
|
||||
expect(result.breakdown['7261'].accounts.map((a) => a.accountNumber)).toContain('2641')
|
||||
expect(result.breakdown['7261'].accounts.map((a) => a.accountNumber)).not.toContain('1630')
|
||||
})
|
||||
|
||||
it('warns about each reclassification it performed', async () => {
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.warnings.some((w) => w.includes('1630-1659'))).toBe(true)
|
||||
expect(result.warnings.some((w) => w.includes('2610-2659'))).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves normally-signed accounts alone and stays silent', async () => {
|
||||
const normal = PRE_CLOSING_ROWS.map((r) => {
|
||||
if (r.account_number === '1630') return row('1630', 'Skattekonto', 20_000)
|
||||
if (r.account_number === '2641') return row('2641', 'Ingående moms', -2_000)
|
||||
return r
|
||||
})
|
||||
stubTrialBalances(normal, normal)
|
||||
|
||||
const result = await generateINK2Declaration(
|
||||
anySupabase(makeSupabase({ closingEntryId: null, isClosed: false })),
|
||||
COMPANY_ID,
|
||||
PERIOD_ID,
|
||||
)
|
||||
|
||||
expect(result.ink2r['7261']).toBe(20_000)
|
||||
expect(result.ink2r['7369']).toBe(2_000)
|
||||
expect(result.warnings.some((w) => w.includes('1630-1659'))).toBe(false)
|
||||
expect(result.warnings.some((w) => w.includes('2610-2659'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateINK2Declaration: open fiscal year', () => {
|
||||
beforeEach(() => {
|
||||
// No resultatavslut yet: both views are identical and 2099 is empty.
|
||||
stubTrialBalances(PRE_CLOSING_ROWS, PRE_CLOSING_ROWS)
|
||||
})
|
||||
|
||||
it('still adds the computed result to equity so the balance sheet ties out', async () => {
|
||||
const result = await generateINK2Declaration(
|
||||
anySupabase(makeSupabase({ closingEntryId: null, isClosed: false })),
|
||||
COMPANY_ID,
|
||||
PERIOD_ID,
|
||||
)
|
||||
|
||||
expect(result.ink2r['7302']).toBe(0)
|
||||
expect(result.totals.totalAssets).toBe(612_000)
|
||||
expect(result.totals.totalEquityLiabilities).toBe(612_000)
|
||||
expect(result.warnings.some((w) => w.includes('inte i balans'))).toBe(false)
|
||||
})
|
||||
|
||||
it('warns that the year is not closed', async () => {
|
||||
const result = await generateINK2Declaration(
|
||||
anySupabase(makeSupabase({ closingEntryId: null, isClosed: false })),
|
||||
COMPANY_ID,
|
||||
PERIOD_ID,
|
||||
)
|
||||
|
||||
expect(result.warnings.some((w) => w.includes('inte stängt'))).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a reversed closing entry as not closed into equity', async () => {
|
||||
// Undo year-end stornoes the closing entry: it nets to zero against its
|
||||
// storno, so the result is back in the P&L accounts.
|
||||
const result = await generateINK2Declaration(
|
||||
anySupabase(makeSupabase({ closingEntryStatus: 'reversed', isClosed: false })),
|
||||
COMPANY_ID,
|
||||
PERIOD_ID,
|
||||
)
|
||||
|
||||
expect(result.totals.totalEquityLiabilities).toBe(612_000)
|
||||
expect(result.warnings.some((w) => w.includes('inte i balans'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateINK2Declaration: guards', () => {
|
||||
it('rejects a non-aktiebolag', async () => {
|
||||
const supabase = {
|
||||
from: (table: string) => {
|
||||
if (table === 'company_settings') {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
single: async () => ({
|
||||
data: { entity_type: 'enskild_firma' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return makeSupabase().from(table)
|
||||
},
|
||||
}
|
||||
|
||||
await expect(
|
||||
generateINK2Declaration(anySupabase(supabase), COMPANY_ID, PERIOD_ID),
|
||||
).rejects.toThrow(/aktiebolag/i)
|
||||
})
|
||||
|
||||
it('warns about a BAS account with no SRU mapping', async () => {
|
||||
const withUnmapped = [
|
||||
...PRE_CLOSING_ROWS,
|
||||
row('1305', 'Okänt konto', 1_000),
|
||||
]
|
||||
stubTrialBalances(withUnmapped, withUnmapped)
|
||||
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.warnings.some((w) => w.includes('1305'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not warn about an unmapped account with no balance', async () => {
|
||||
const withUnmapped = [...PRE_CLOSING_ROWS, row('1305', 'Okänt konto', 0)]
|
||||
stubTrialBalances(withUnmapped, withUnmapped)
|
||||
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.warnings.some((w) => w.includes('1305'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateINK2Declaration: cross-surface self-check', () => {
|
||||
it('warns when the declared result disagrees with the booked 2099', async () => {
|
||||
// Exactly the shape a customer reported on 2026-07-29: the form said
|
||||
// 0 kr while the books carried 442 000 kr on 2099. Nothing warned then,
|
||||
// because the balance sheet still tied out on its own.
|
||||
const zeroedIncome = CLOSED_ROWS
|
||||
stubTrialBalances(CLOSED_ROWS, zeroedIncome)
|
||||
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.ink2r['7450']).toBe(0)
|
||||
expect(result.warnings.some((w) => w.includes('stämmer inte med det bokförda resultatet'))).toBe(true)
|
||||
})
|
||||
|
||||
it('stays silent when the declaration agrees with the books', async () => {
|
||||
const result = await generateINK2Declaration(anySupabase(makeSupabase()), COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.ink2r['7450']).toBe(442_000)
|
||||
expect(result.warnings.some((w) => w.includes('stämmer inte med det bokförda resultatet'))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not fire on an open year, where 2099 is legitimately empty', async () => {
|
||||
stubTrialBalances(PRE_CLOSING_ROWS, PRE_CLOSING_ROWS)
|
||||
|
||||
const result = await generateINK2Declaration(
|
||||
anySupabase(makeSupabase({ closingEntryId: null, isClosed: false })),
|
||||
COMPANY_ID,
|
||||
PERIOD_ID,
|
||||
)
|
||||
|
||||
expect(result.warnings.some((w) => w.includes('stämmer inte med det bokförda resultatet'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { INK2R_ACCOUNT_MAPPINGS, isAccountInMapping, checkBalanceWarning } from '../ink2-engine'
|
||||
import type { INK2RSRUCode } from '../types'
|
||||
import {
|
||||
SIGN_RECLASSIFICATION_RULES,
|
||||
type SignReclassificationId,
|
||||
} from '@/lib/reports/sign-reclassification'
|
||||
|
||||
/**
|
||||
* Helper to find which SRU code an account maps to
|
||||
@@ -356,3 +360,35 @@ describe('checkBalanceWarning', () => {
|
||||
expect(warning).toContain('5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('INK2R mapping table invariants', () => {
|
||||
it('declares exactly one mapping per SRU code', () => {
|
||||
// The engine indexes mappings by sruCode to re-orient reclassified
|
||||
// accounts under their new post; a duplicate would silently drop one.
|
||||
const seen = new Map<string, number>()
|
||||
for (const mapping of INK2R_ACCOUNT_MAPPINGS) {
|
||||
seen.set(mapping.sruCode, (seen.get(mapping.sruCode) ?? 0) + 1)
|
||||
}
|
||||
const duplicates = [...seen.entries()].filter(([, count]) => count > 1)
|
||||
expect(duplicates).toEqual([])
|
||||
})
|
||||
|
||||
it('routes every sign-reclassification rule out of the post its range maps to', () => {
|
||||
// Pins lib/reports/sign-reclassification.ts against the mapping table: if
|
||||
// a range moves to another SRU code, the reclassification would try to
|
||||
// relocate accounts that are not in the source post.
|
||||
const expectedSource: Record<SignReclassificationId, string> = {
|
||||
tax_account_credit_to_liability: '7261',
|
||||
tax_liability_debit_to_receivable: '7368',
|
||||
vat_liability_debit_to_receivable: '7369',
|
||||
}
|
||||
|
||||
for (const rule of SIGN_RECLASSIFICATION_RULES) {
|
||||
for (const range of rule.ranges) {
|
||||
for (const account of [range.start, range.end]) {
|
||||
expect(findSRUCodeForAccount(account)).toBe(expectedSource[rule.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ function makeDeclaration(overrides?: Partial<INK2Declaration>): INK2Declaration
|
||||
totalAssets: 175000,
|
||||
totalEquityLiabilities: 175000,
|
||||
operatingResult: 305000,
|
||||
resultAfterFinancial: 302000,
|
||||
aretsResultat: 302000,
|
||||
},
|
||||
companyInfo: {
|
||||
companyName: 'Test AB',
|
||||
|
||||
+248
-117
@@ -1,11 +1,12 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { loadTaxAdjustmentSnapshot } from '@/lib/bokslut/tax-provision/tax-adjustment-service'
|
||||
import type {
|
||||
FiscalPeriod,
|
||||
JournalEntry,
|
||||
JournalEntryLine,
|
||||
} from '@/types'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import {
|
||||
SIGN_RECLASSIFICATION_RULES,
|
||||
selectReclassifiedAccounts,
|
||||
type SignReclassificationId,
|
||||
} from '@/lib/reports/sign-reclassification'
|
||||
import type { FiscalPeriod, TrialBalanceRow } from '@/types'
|
||||
import type {
|
||||
INK2Declaration,
|
||||
INK2RRutor,
|
||||
@@ -32,6 +33,22 @@ import {
|
||||
* INK2S auto-derives basic fields (result + tax → taxable result), as well as
|
||||
* periodiseringsfond and överavskrivningar when those have been posted via the
|
||||
* bokslut-dispositions calculators in lib/bokslut/.
|
||||
*
|
||||
* Balances come from generateTrialBalance, never from a raw journal scan, and
|
||||
* the two sides of INK2R read DIFFERENT views of the same period:
|
||||
*
|
||||
* - Balance sheet: the closed books. After year-end the resultatavslut has
|
||||
* moved årets resultat into 2099, so fritt eget kapital (7302) is only
|
||||
* right when the closing verifikat is included.
|
||||
* - Income statement: the pre-closing books (excludeFinalClosingEntry). The
|
||||
* resultatavslut zeroes every P&L account against 2099, so including it
|
||||
* collapses the whole resultaträkning to zero, which then cascades into
|
||||
* INK2S 7650/7651 and the taxable result. INK2 is always filed after
|
||||
* bokslut, so that is the normal state, not an edge case.
|
||||
*
|
||||
* excludeFinalClosingEntry drops only fiscal_periods.closing_entry_id: tax,
|
||||
* depreciation and bokslutsdispositioner also carry source_type 'year_end' and
|
||||
* must stay on the form (7525, 7528).
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -622,6 +639,27 @@ export const INK2R_ACCOUNT_MAPPINGS: INK2AccountMapping[] = [
|
||||
// 7450/7550 (årets resultat vinst/förlust) are calculated, not mapped from accounts
|
||||
]
|
||||
|
||||
/** One mapping per SRU code: pinned by a test in __tests__/ink2-engine.test.ts. */
|
||||
const MAPPING_BY_CODE = new Map<INK2RSRUCode, INK2AccountMapping>(
|
||||
INK2R_ACCOUNT_MAPPINGS.map((mapping) => [mapping.sruCode, mapping]),
|
||||
)
|
||||
|
||||
/**
|
||||
* INK2R posts each shared sign-reclassification rule moves between. The rules
|
||||
* live in lib/reports/sign-reclassification.ts and are shared with the K2
|
||||
* iXBRL årsredovisning so both statutory reports present the same balance
|
||||
* sheet. A test pins that every rule's account range really does map to the
|
||||
* `from` code below.
|
||||
*/
|
||||
const SIGN_RECLASSIFICATION_ROUTES: Record<
|
||||
SignReclassificationId,
|
||||
{ from: INK2RSRUCode; to: INK2RSRUCode }
|
||||
> = {
|
||||
tax_account_credit_to_liability: { from: '7261', to: '7368' },
|
||||
tax_liability_debit_to_receivable: { from: '7368', to: '7261' },
|
||||
vat_liability_debit_to_receivable: { from: '7369', to: '7261' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an account number falls within a mapping's ranges
|
||||
*/
|
||||
@@ -644,12 +682,18 @@ function truncateToKrona(value: number): number {
|
||||
return value >= 0 ? Math.floor(value) : Math.ceil(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack allowed before a difference counts as a real disagreement. Every INK2
|
||||
* field is truncated to whole kronor per SFL 22 kap. 1 §, so a few öre of
|
||||
* truncation residual can accumulate across the form legitimately.
|
||||
*/
|
||||
const ROUNDING_TOLERANCE_KR = 2
|
||||
|
||||
/**
|
||||
* Check if the balance sheet totals differ beyond the expected rounding tolerance.
|
||||
*/
|
||||
export function checkBalanceWarning(totalAssets: number, totalEquityLiabilities: number): string | null {
|
||||
const balanceDiff = Math.abs(totalAssets - totalEquityLiabilities)
|
||||
const ROUNDING_TOLERANCE_KR = 2
|
||||
if (balanceDiff > ROUNDING_TOLERANCE_KR && (totalAssets > 0 || totalEquityLiabilities > 0)) {
|
||||
return `Balansräkningen är inte i balans. Tillgångar: ${totalAssets} kr, Eget kapital och skulder: ${totalEquityLiabilities} kr (differens: ${balanceDiff} kr).`
|
||||
}
|
||||
@@ -684,6 +728,96 @@ function createEmptyINK2RRutor(): INK2RRutor {
|
||||
const ASSET_CODES = INK2R_ASSET_CODES
|
||||
const EQUITY_LIABILITY_CODES = INK2R_EQUITY_LIABILITY_CODES
|
||||
|
||||
/** One account's contribution to an SRU code, before orientation and truncation. */
|
||||
interface AccountContribution {
|
||||
accountNumber: string
|
||||
accountName: string
|
||||
/** Raw ledger balance, debit-positive. */
|
||||
balance: number
|
||||
}
|
||||
|
||||
/** UB per account from a trial balance, debit-positive. */
|
||||
function toSignedBalances(rows: TrialBalanceRow[]): Map<string, number> {
|
||||
const balances = new Map<string, number>()
|
||||
for (const row of rows) {
|
||||
balances.set(
|
||||
row.account_number,
|
||||
(Number(row.closing_debit) || 0) - (Number(row.closing_credit) || 0),
|
||||
)
|
||||
}
|
||||
return balances
|
||||
}
|
||||
|
||||
function findMappingForAccount(accountNumber: string): INK2AccountMapping | null {
|
||||
for (const mapping of INK2R_ACCOUNT_MAPPINGS) {
|
||||
if (isAccountInMapping(accountNumber, mapping)) return mapping
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Orient a raw ledger balance to the amount Skatteverket expects in the field.
|
||||
* Every INK2R amount is reported positive when the post carries its normal
|
||||
* balance; costs are positive on the income statement side.
|
||||
*/
|
||||
function orientedAmount(balance: number, mapping: INK2AccountMapping): number {
|
||||
if (mapping.normalBalance === 'debit') return balance
|
||||
// Credit-normal posts, and 'net' posts where positive means income.
|
||||
return -balance
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocate balance sheet accounts whose balance deviates from their post's
|
||||
* normal side (1630 with a credit is a skatteskuld, 2641 with a debit is a
|
||||
* fordran). Whole account rows move, so the per-account breakdown stays
|
||||
* consistent with the post totals; for `net` rules the moved rows sum to the
|
||||
* deviating net by construction because every account in range moves together.
|
||||
*/
|
||||
function applySignReclassifications(
|
||||
contributions: Map<INK2RSRUCode, AccountContribution[]>,
|
||||
balanceSheetBalances: ReadonlyMap<string, number>,
|
||||
warnings: string[],
|
||||
): void {
|
||||
for (const rule of SIGN_RECLASSIFICATION_RULES) {
|
||||
const route = SIGN_RECLASSIFICATION_ROUTES[rule.id]
|
||||
const moving = new Set(selectReclassifiedAccounts(rule, balanceSheetBalances))
|
||||
if (moving.size === 0) continue
|
||||
|
||||
const source = contributions.get(route.from) ?? []
|
||||
const moved = source.filter((c) => moving.has(c.accountNumber))
|
||||
if (moved.length === 0) continue
|
||||
|
||||
contributions.set(
|
||||
route.from,
|
||||
source.filter((c) => !moving.has(c.accountNumber)),
|
||||
)
|
||||
contributions.set(route.to, [...(contributions.get(route.to) ?? []), ...moved])
|
||||
warnings.push(rule.warning)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the resultatavslut has already moved årets resultat into 2099.
|
||||
*
|
||||
* Mirrors the predicate generateTrialBalance uses to drop the closing entry: a
|
||||
* reversed closing entry nets to zero against its storno and has therefore not
|
||||
* moved anything.
|
||||
*/
|
||||
async function isResultClosedIntoEquity(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
closingEntryId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!closingEntryId) return false
|
||||
const { data } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('status')
|
||||
.eq('id', closingEntryId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
return (data as { status?: string } | null)?.status === 'posted'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate INK2 declaration for a fiscal period
|
||||
*/
|
||||
@@ -728,54 +862,77 @@ export async function generateINK2Declaration(
|
||||
throw new Error('INK2 declaration is only for aktiebolag (limited company)')
|
||||
}
|
||||
|
||||
const taxAdjustments = await loadTaxAdjustmentSnapshot(
|
||||
supabase,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
)
|
||||
// The balance sheet reads the closed books, the income statement the
|
||||
// pre-closing books. See the module docblock for why the two differ.
|
||||
const [taxAdjustments, closedTrialBalance, preClosingTrialBalance, resultClosedIntoEquity] =
|
||||
await Promise.all([
|
||||
loadTaxAdjustmentSnapshot(supabase, companyId, fiscalPeriodId),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' }),
|
||||
generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
closingEntry: 'exclude-final',
|
||||
}),
|
||||
isResultClosedIntoEquity(supabase, companyId, period.closing_entry_id as string | null),
|
||||
])
|
||||
|
||||
// Fetch all posted journal entries with lines for this period.
|
||||
// Paginated: a period can exceed PostgREST's 1000-row cap, and a silent
|
||||
// truncation here would under-report the INK2 tax declaration. PostgREST
|
||||
// ranges count parent rows, so the embedded lines come with each entry.
|
||||
const entries = await fetchAllRows<JournalEntry>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
, { dedupeBy: (e) => e.id })
|
||||
|
||||
// Fetch chart of accounts for account names
|
||||
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('company_id', companyId)
|
||||
.order('account_number', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
const balanceSheetBalances = toSignedBalances(closedTrialBalance.rows)
|
||||
const incomeBalances = toSignedBalances(preClosingTrialBalance.rows)
|
||||
|
||||
const accountNameMap = new Map<string, string>()
|
||||
for (const acc of accounts) {
|
||||
accountNameMap.set(acc.account_number, acc.account_name)
|
||||
for (const row of [...closedTrialBalance.rows, ...preClosingTrialBalance.rows]) {
|
||||
accountNameMap.set(row.account_number, row.account_name)
|
||||
}
|
||||
|
||||
// Calculate balances per account (debit - credit)
|
||||
const accountBalances = new Map<string, number>()
|
||||
const warnings: string[] = []
|
||||
|
||||
for (const entry of (entries as JournalEntry[]) || []) {
|
||||
const lines = (entry.lines as JournalEntryLine[]) || []
|
||||
for (const line of lines) {
|
||||
const current = accountBalances.get(line.account_number) || 0
|
||||
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
|
||||
accountBalances.set(line.account_number, current + netAmount)
|
||||
// Collect each account's contribution to its SRU code, keeping the raw
|
||||
// balance so a reclassified account can be re-oriented under its new code.
|
||||
const contributions = new Map<INK2RSRUCode, AccountContribution[]>()
|
||||
const allAccountNumbers = new Set([
|
||||
...balanceSheetBalances.keys(),
|
||||
...incomeBalances.keys(),
|
||||
])
|
||||
|
||||
for (const accountNumber of allAccountNumbers) {
|
||||
// Skip account 8999: årets resultat is calculated
|
||||
if (accountNumber === '8999') continue
|
||||
|
||||
const mapping = findMappingForAccount(accountNumber)
|
||||
|
||||
if (!mapping) {
|
||||
// BAS accounts 4500-4599, 4700-4899, and 1300-1310 have no standard SRU
|
||||
// mapping. These are unusual and may indicate custom accounts.
|
||||
const hasBalance =
|
||||
Math.abs(balanceSheetBalances.get(accountNumber) ?? 0) >= 0.01
|
||||
|| Math.abs(incomeBalances.get(accountNumber) ?? 0) >= 0.01
|
||||
const classChar = accountNumber.charAt(0)
|
||||
if (hasBalance && classChar >= '1' && classChar <= '8') {
|
||||
// Only warn for standard BAS range accounts that weren't mapped
|
||||
warnings.push(`Konto ${accountNumber} (${accountNameMap.get(accountNumber) || 'okänt'}) kunde inte mappas till ett SRU-fält.`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const balance =
|
||||
mapping.section === 'income_statement'
|
||||
? incomeBalances.get(accountNumber) ?? 0
|
||||
: balanceSheetBalances.get(accountNumber) ?? 0
|
||||
if (Math.abs(balance) < 0.01) continue
|
||||
|
||||
const list = contributions.get(mapping.sruCode)
|
||||
const contribution: AccountContribution = {
|
||||
accountNumber,
|
||||
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
|
||||
balance,
|
||||
}
|
||||
if (list) {
|
||||
list.push(contribution)
|
||||
} else {
|
||||
contributions.set(mapping.sruCode, [contribution])
|
||||
}
|
||||
}
|
||||
|
||||
applySignReclassifications(contributions, balanceSheetBalances, warnings)
|
||||
|
||||
// Initialize INK2R rutor and breakdown
|
||||
const ink2r = createEmptyINK2RRutor()
|
||||
const allCodes = Object.keys(ink2r) as INK2RSRUCode[]
|
||||
@@ -784,65 +941,17 @@ export async function generateINK2Declaration(
|
||||
breakdown[code] = { accounts: [], total: 0 }
|
||||
}
|
||||
|
||||
const warnings: string[] = []
|
||||
|
||||
// Process each account balance against INK2R mappings
|
||||
for (const [accountNumber, balance] of accountBalances) {
|
||||
if (Math.abs(balance) < 0.01) continue
|
||||
|
||||
// Skip account 8999: årets resultat is calculated
|
||||
if (accountNumber === '8999') continue
|
||||
|
||||
let mapped = false
|
||||
for (const mapping of INK2R_ACCOUNT_MAPPINGS) {
|
||||
if (isAccountInMapping(accountNumber, mapping)) {
|
||||
let amount: number
|
||||
|
||||
if (mapping.section === 'income_statement') {
|
||||
// Income statement sign convention per Skatteverket INK2R:
|
||||
// All amounts are reported as positive values on the form.
|
||||
// Revenue (credit normal): balance is negative in ledger, negate → positive
|
||||
// Cost (debit normal): balance is positive in ledger, keep → positive
|
||||
// Net: negate so positive = income, negative = cost
|
||||
if (mapping.normalBalance === 'credit') {
|
||||
amount = -balance
|
||||
} else if (mapping.normalBalance === 'debit') {
|
||||
// Costs: debit balance is positive in ledger, keep positive (Skatteverket convention)
|
||||
amount = balance
|
||||
} else {
|
||||
// Net: negate to match accounting convention
|
||||
amount = -balance
|
||||
}
|
||||
} else {
|
||||
// Balance sheet: all amounts reported as positive
|
||||
if (mapping.normalBalance === 'debit') {
|
||||
amount = balance
|
||||
} else {
|
||||
amount = -balance
|
||||
}
|
||||
}
|
||||
|
||||
ink2r[mapping.sruCode] += amount
|
||||
|
||||
breakdown[mapping.sruCode].accounts.push({
|
||||
accountNumber,
|
||||
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
|
||||
amount: truncateToKrona(amount),
|
||||
})
|
||||
|
||||
mapped = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!mapped) {
|
||||
// BAS accounts 4500-4599, 4700-4899, and 1300-1310 have no standard SRU mapping
|
||||
// These are unusual and may indicate custom accounts
|
||||
const classChar = accountNumber.charAt(0)
|
||||
if (classChar >= '1' && classChar <= '8') {
|
||||
// Only warn for standard BAS range accounts that weren't mapped
|
||||
warnings.push(`Konto ${accountNumber} (${accountNameMap.get(accountNumber) || 'okänt'}) kunde inte mappas till ett SRU-fält.`)
|
||||
}
|
||||
for (const [code, list] of contributions) {
|
||||
const mapping = MAPPING_BY_CODE.get(code)
|
||||
if (!mapping) continue
|
||||
for (const contribution of list) {
|
||||
const amount = orientedAmount(contribution.balance, mapping)
|
||||
ink2r[code] += amount
|
||||
breakdown[code].accounts.push({
|
||||
accountNumber: contribution.accountNumber,
|
||||
accountName: contribution.accountName,
|
||||
amount: truncateToKrona(amount),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -876,21 +985,24 @@ export async function generateINK2Declaration(
|
||||
const resultBeforeTax = operatingResult + financialItems + bokslutsdispositioner
|
||||
|
||||
// Result after tax (7528 is positive, subtract it)
|
||||
const resultAfterFinancial = resultBeforeTax - ink2r['7528']
|
||||
const aretsResultat = resultBeforeTax - ink2r['7528']
|
||||
|
||||
// Set årets resultat: vinst (7450) or förlust (7550)
|
||||
if (resultAfterFinancial >= 0) {
|
||||
ink2r['7450'] = resultAfterFinancial
|
||||
if (aretsResultat >= 0) {
|
||||
ink2r['7450'] = aretsResultat
|
||||
ink2r['7550'] = 0
|
||||
} else {
|
||||
ink2r['7450'] = 0
|
||||
ink2r['7550'] = Math.abs(resultAfterFinancial)
|
||||
ink2r['7550'] = Math.abs(aretsResultat)
|
||||
}
|
||||
|
||||
// Add calculated result to fritt eget kapital for balance
|
||||
// During open fiscal year, 2099 may have no balance; the result only exists
|
||||
// as net of income statement accounts. Adding it here handles both cases.
|
||||
const adjustedEquityLiabilities = totalEquityLiabilities + resultAfterFinancial
|
||||
// During an open fiscal year 2099 has no balance yet: the result exists only
|
||||
// as the net of the income statement accounts, so add it to make the balance
|
||||
// sheet tie out. Once the resultatavslut is posted, 7302 already carries it
|
||||
// via 2099 and adding it again would double-count årets resultat.
|
||||
const adjustedEquityLiabilities = resultClosedIntoEquity
|
||||
? totalEquityLiabilities
|
||||
: totalEquityLiabilities + aretsResultat
|
||||
|
||||
// Fiscal year dates as YYYYMMDD
|
||||
const fyStart = (period.period_start as string).replace(/-/g, '')
|
||||
@@ -906,7 +1018,7 @@ export async function generateINK2Declaration(
|
||||
const nonDeductibleExpenses = Math.trunc(taxAdjustments.nonDeductibleExpenses)
|
||||
const nonTaxableIncome = Math.trunc(taxAdjustments.nonTaxableIncome)
|
||||
const taxableResult =
|
||||
resultAfterFinancial + taxAmount
|
||||
aretsResultat + taxAmount
|
||||
+ nonDeductibleExpenses - nonTaxableIncome
|
||||
|
||||
const ink2: INK2Rutor = {
|
||||
@@ -920,8 +1032,8 @@ export async function generateINK2Declaration(
|
||||
const ink2s: INK2SRutor = {
|
||||
'7011': fyStart,
|
||||
'7012': fyEnd,
|
||||
'7650': resultAfterFinancial >= 0 ? resultAfterFinancial : 0,
|
||||
'7750': resultAfterFinancial < 0 ? Math.abs(resultAfterFinancial) : 0,
|
||||
'7650': aretsResultat >= 0 ? aretsResultat : 0,
|
||||
'7750': aretsResultat < 0 ? Math.abs(aretsResultat) : 0,
|
||||
'7651': taxAmount, // Skatt (ej avdragsgill)
|
||||
'7653': nonDeductibleExpenses,
|
||||
'7754': nonTaxableIncome,
|
||||
@@ -943,6 +1055,25 @@ export async function generateINK2Declaration(
|
||||
warnings.push(balanceWarning)
|
||||
}
|
||||
|
||||
// Cross-surface self-check. When the year is closed, the resultaträkning the
|
||||
// form reports must equal the årets resultat the books actually carry on 2099,
|
||||
// which is also the figure the fastställda årsredovisningen shows. Mirrors the
|
||||
// equivalent check in lib/bokslut/ixbrl/k2-mapper.ts so both statutory reports
|
||||
// catch the same disagreement.
|
||||
//
|
||||
// This is the alarm that was missing: when INK2R reported 0 kr against a
|
||||
// booked result of 469 542 kr, nothing warned, because the balance sheet
|
||||
// still tied out on its own. A customer found it instead.
|
||||
if (resultClosedIntoEquity) {
|
||||
const bookedResult = truncateToKrona(-(balanceSheetBalances.get('2099') ?? 0))
|
||||
const declaredResult = aretsResultat
|
||||
if (Math.abs(bookedResult - declaredResult) > ROUNDING_TOLERANCE_KR) {
|
||||
warnings.push(
|
||||
`Årets resultat enligt resultaträkningen (${declaredResult} kr) stämmer inte med det bokförda resultatet på konto 2099 (${bookedResult} kr). Deklarationen stämmer då inte med det fastställda bokslutet.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fiscalYear: {
|
||||
id: period.id,
|
||||
@@ -959,7 +1090,7 @@ export async function generateINK2Declaration(
|
||||
totalAssets,
|
||||
totalEquityLiabilities: adjustedEquityLiabilities,
|
||||
operatingResult,
|
||||
resultAfterFinancial,
|
||||
aretsResultat,
|
||||
},
|
||||
companyInfo: {
|
||||
companyName: settings?.company_name || 'Okänt företag',
|
||||
|
||||
@@ -162,7 +162,12 @@ export interface INK2Declaration {
|
||||
totalAssets: number
|
||||
totalEquityLiabilities: number
|
||||
operatingResult: number
|
||||
resultAfterFinancial: number
|
||||
/**
|
||||
* Årets resultat: after bokslutsdispositioner AND skatt. Named for what it
|
||||
* is; it was called resultAfterFinancial, which is a different subtotal
|
||||
* (and the name build-data.ts correctly uses for 602-style figures).
|
||||
*/
|
||||
aretsResultat: number
|
||||
}
|
||||
companyInfo: INK2CompanyInfo
|
||||
warnings: string[]
|
||||
|
||||
@@ -145,7 +145,7 @@ export async function generateKassaflodesanalys(
|
||||
// Without this filter, the closing entry for class 3-8 would inflate
|
||||
// "övriga ej-kassaflödesposter" and break the reconciliation.
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
excludeYearEndClosing: true,
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
})
|
||||
|
||||
// Net result before tax (resultat efter finansiella poster) comes from the
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { roundOre } from '@/lib/money'
|
||||
|
||||
export interface MonthlyBreakdownMonth {
|
||||
@@ -95,6 +96,14 @@ export function assembleMonthlyBreakdown(
|
||||
* Groups posted journal entry lines by month and account class:
|
||||
* - Class 3 (30xx) = revenue (credit side)
|
||||
* - Class 4-7 (40xx-79xx) = expenses (debit side)
|
||||
*
|
||||
* Year-end entries are excluded, including the storno/correction chain of a
|
||||
* REVERSED year-end entry (an undone bokslut). Without that the resultatavslut,
|
||||
* which posts the mirror image of every P&L account, showed the whole year's
|
||||
* revenue as negative income in the fiscal-year-end month: measured on
|
||||
* production as 28 companies affected, worst case a single month understated by
|
||||
* 10 347 472 kr. Mirrors tb_ex_ye_entries in get_kpi_report_aggregates, which
|
||||
* serves the same chart on the no-dimension hot path; the two must agree.
|
||||
*/
|
||||
export async function generateMonthlyBreakdown(
|
||||
supabase: SupabaseClient,
|
||||
@@ -119,6 +128,23 @@ export async function generateMonthlyBreakdown(
|
||||
return { months: [] }
|
||||
}
|
||||
|
||||
// Ids of REVERSED year-end entries, company-wide (no period filter): a storno
|
||||
// in this period can reverse a year-end entry from another period. Mirrors the
|
||||
// wave-1 fetch in lib/reports/trial-balance.ts and ye_reversed in
|
||||
// get_kpi_report_aggregates.
|
||||
const reversedYearEndIds = (
|
||||
await fetchAllRows<{ id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source_type', 'year_end')
|
||||
.eq('status', 'reversed')
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
).map((r) => r.id)
|
||||
|
||||
// Get all posted journal entry lines for this period with their entry dates,
|
||||
// via the two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -128,11 +154,19 @@ export async function generateMonthlyBreakdown(
|
||||
supabase,
|
||||
entryColumns: 'entry_date, status, company_id, fiscal_period_id',
|
||||
lineColumns: 'account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
filterEntries: (q: EntryLinesQuery) => {
|
||||
let query = q
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'posted'),
|
||||
.eq('status', 'posted')
|
||||
.neq('source_type', 'year_end')
|
||||
if (reversedYearEndIds.length > 0) {
|
||||
const idList = `(${reversedYearEndIds.join(',')})`
|
||||
query = query.or(`reverses_id.is.null,reverses_id.not.in.${idList}`)
|
||||
query = query.or(`correction_of_id.is.null,correction_of_id.not.in.${idList}`)
|
||||
}
|
||||
return query
|
||||
},
|
||||
filterLines:
|
||||
options?.dimensions && Object.keys(options.dimensions).length > 0
|
||||
? // jsonb containment (@>): served by idx_jel_dimensions_gin.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Integration tests for generateNEDeclaration against a CLOSED fiscal year.
|
||||
*
|
||||
* R1-R11 are an income statement. The resultatavslut zeroes every P&L account
|
||||
* at year-end, and NE-bilaga is always filed after bokslut, so a raw journal
|
||||
* scan reported an empty näringsverksamhet. The old test file only exercised
|
||||
* the mapping table.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/reports/trial-balance', () => ({
|
||||
generateTrialBalance: vi.fn(),
|
||||
}))
|
||||
|
||||
import { generateNEDeclaration } from '../ne-engine'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import type { TrialBalanceRow } from '@/types'
|
||||
|
||||
const COMPANY_ID = 'company-1'
|
||||
const PERIOD_ID = 'period-1'
|
||||
|
||||
function row(accountNumber: string, accountName: string, balance: number): TrialBalanceRow {
|
||||
const debit = balance > 0 ? balance : 0
|
||||
const credit = balance < 0 ? -balance : 0
|
||||
return {
|
||||
account_number: accountNumber,
|
||||
account_name: accountName,
|
||||
account_class: Number(accountNumber[0]),
|
||||
opening_debit: 0,
|
||||
opening_credit: 0,
|
||||
period_debit: debit,
|
||||
period_credit: credit,
|
||||
closing_debit: debit,
|
||||
closing_credit: credit,
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-closing books: revenue 400 000, costs 150 000, result 250 000. */
|
||||
const PRE_CLOSING_ROWS: TrialBalanceRow[] = [
|
||||
row('1930', 'Företagskonto', 250_000),
|
||||
row('3001', 'Försäljning', -400_000),
|
||||
row('5010', 'Lokalhyra', 120_000),
|
||||
row('6110', 'Kontorsmateriel', 30_000),
|
||||
]
|
||||
|
||||
function makeSupabase() {
|
||||
return {
|
||||
from: (table: string) => {
|
||||
if (table === 'fiscal_periods') {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
single: async () => ({
|
||||
data: {
|
||||
id: PERIOD_ID,
|
||||
name: 'Räkenskapsår 2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
is_closed: true,
|
||||
closing_entry_id: 'closing-entry-1',
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === 'company_settings') {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
single: async () => ({
|
||||
data: {
|
||||
company_name: 'Testfirman',
|
||||
org_number: '199001010000',
|
||||
entity_type: 'enskild_firma',
|
||||
address_line1: 'Testgatan 1',
|
||||
postal_code: '11122',
|
||||
city: 'Stockholm',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: PRE_CLOSING_ROWS,
|
||||
totalDebit: 0,
|
||||
totalCredit: 0,
|
||||
isBalanced: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateNEDeclaration: closed fiscal year', () => {
|
||||
it('requests the pre-closing trial balance', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await generateNEDeclaration(makeSupabase() as any, COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(vi.mocked(generateTrialBalance)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
PERIOD_ID,
|
||||
{ closingEntry: 'exclude-final' },
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the year the resultatavslut would have zeroed', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await generateNEDeclaration(makeSupabase() as any, COMPANY_ID, PERIOD_ID)
|
||||
|
||||
expect(result.rutor.R1).toBe(400_000)
|
||||
expect(result.rutor.R6).toBe(150_000)
|
||||
expect(result.rutor.R11).toBe(250_000)
|
||||
expect(result.warnings.some((w) => w.includes('Inga bokförda intäkter'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type {
|
||||
FiscalPeriod,
|
||||
JournalEntry,
|
||||
JournalEntryLine,
|
||||
} from '@/types'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
import type {
|
||||
NEDeclaration,
|
||||
NEDeclarationRutor,
|
||||
@@ -195,47 +191,24 @@ export async function generateNEDeclaration(
|
||||
throw new Error('NE declaration is only for enskild firma (sole proprietorship)')
|
||||
}
|
||||
|
||||
// Fetch all posted journal entries with lines for this period.
|
||||
// Paginated: a period can exceed PostgREST's 1000-row cap, and a silent
|
||||
// truncation here would under-report the NE-bilaga tax declaration. PostgREST
|
||||
// ranges count parent rows, so the embedded lines come with each entry.
|
||||
const entries = await fetchAllRows<JournalEntry>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
, { dedupeBy: (e) => e.id })
|
||||
|
||||
// Fetch chart of accounts for account names
|
||||
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('company_id', companyId)
|
||||
.order('account_number', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
// R1-R11 are an income statement, so read the PRE-CLOSING books. The
|
||||
// resultatavslut zeroes every P&L account against 2019/2099 at year-end, and
|
||||
// NE-bilaga is always filed after bokslut, so including it would report an
|
||||
// empty näringsverksamhet. 'exclude-final' drops only
|
||||
// fiscal_periods.closing_entry_id: avskrivningar and other bokslut entries
|
||||
// also carry source_type 'year_end' and belong on the form.
|
||||
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
closingEntry: 'exclude-final',
|
||||
})
|
||||
|
||||
const accountNameMap = new Map<string, string>()
|
||||
for (const acc of accounts) {
|
||||
accountNameMap.set(acc.account_number, acc.account_name)
|
||||
}
|
||||
|
||||
// Calculate balances per account
|
||||
const accountBalances = new Map<string, number>()
|
||||
|
||||
for (const entry of (entries as JournalEntry[]) || []) {
|
||||
const lines = (entry.lines as JournalEntryLine[]) || []
|
||||
for (const line of lines) {
|
||||
const current = accountBalances.get(line.account_number) || 0
|
||||
// Net amount: debit - credit
|
||||
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
|
||||
accountBalances.set(line.account_number, current + netAmount)
|
||||
}
|
||||
for (const row of trialBalance.rows) {
|
||||
accountNameMap.set(row.account_number, row.account_name)
|
||||
accountBalances.set(
|
||||
row.account_number,
|
||||
(Number(row.closing_debit) || 0) - (Number(row.closing_credit) || 0),
|
||||
)
|
||||
}
|
||||
|
||||
// Map account balances to NE rutor
|
||||
|
||||
@@ -55,7 +55,18 @@ export async function generateResultatrapport(
|
||||
const effectiveFromDate = options?.fromDate ?? period.period_start
|
||||
const effectiveToDate = options?.toDate ?? period.period_end
|
||||
|
||||
// Exclude year-end closing entries. Without this a closed year reads ZERO on
|
||||
// every line: the resultatavslut posts the mirror image of each P&L account
|
||||
// into 2099 inside the same period, so the period movements this report sums
|
||||
// net out exactly.
|
||||
//
|
||||
// 'exclude-all-year-end', NOT 'exclude-final', so this report keeps showing
|
||||
// the same profit as the formal Resultaträkning. Moving
|
||||
// generateIncomeStatement to 'exclude-final' is Stage 2 of #1051 and
|
||||
// deliberately deferred: see DECISIONS.md:632. When that lands, this call
|
||||
// site moves with it.
|
||||
const currentTb = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
fromDate: options?.fromDate,
|
||||
toDate: options?.toDate,
|
||||
dimensions: options?.dimensions,
|
||||
@@ -99,7 +110,11 @@ export async function generateResultatrapport(
|
||||
.single()
|
||||
|
||||
if (prior) {
|
||||
const priorTb = await generateTrialBalance(supabase, companyId, priorPeriodId)
|
||||
// Same exclusion as the current period: a prior year is almost always
|
||||
// closed, so without it the comparison column reads zero throughout.
|
||||
const priorTb = await generateTrialBalance(supabase, companyId, priorPeriodId, {
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
})
|
||||
priorRows = filterPnl(priorTb.rows)
|
||||
priorPeriodInfo = { start: prior.period_start, end: prior.period_end }
|
||||
}
|
||||
@@ -130,6 +145,7 @@ export async function generateResultatrapport(
|
||||
const to = shiftedTo < p.period_end ? shiftedTo : p.period_end
|
||||
if (from > to) continue
|
||||
const tbPart = await generateTrialBalance(supabase, companyId, p.id, {
|
||||
closingEntry: 'exclude-all-year-end',
|
||||
fromDate: from,
|
||||
toDate: to,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Sign-based balance sheet reclassification.
|
||||
*
|
||||
* Tax settlement and VAT accounts routinely carry the opposite economic
|
||||
* balance from their BAS class: a skattekonto (1630) with a credit balance is
|
||||
* money owed to Skatteverket, and a momsavräkningskonto (2641) with a debit
|
||||
* balance is money owed back by Skatteverket. ÅRL 3 kap. and K2 present a post
|
||||
* by the substance of its balance, so a negative asset is shown as a liability
|
||||
* and vice versa. A static BAS-range mapping cannot see that on its own.
|
||||
*
|
||||
* These rules are the single source of truth for every statutory report that
|
||||
* presents a balance sheet: the K2 iXBRL årsredovisning (lib/bokslut/ixbrl/
|
||||
* k2-mapper.ts) and the INK2R räkenskapsschema (lib/reports/ink2/ink2-engine
|
||||
* .ts). Only the rule table is shared. Each consumer applies it with its own
|
||||
* arithmetic, because the iXBRL path sums in exact öre while INK2R works in
|
||||
* kronor and truncates per SFL 22 kap. 1 §.
|
||||
*
|
||||
* Labels and warnings stay Swedish: these surface on Skatteverket and
|
||||
* Bolagsverket forms (see .claude/rules/i18n.md).
|
||||
*/
|
||||
|
||||
export type SignReclassificationId =
|
||||
| 'tax_account_credit_to_liability'
|
||||
| 'tax_liability_debit_to_receivable'
|
||||
| 'vat_liability_debit_to_receivable'
|
||||
|
||||
export interface AccountRange {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
export type SignReclassificationMode = 'net' | 'deviating_rows'
|
||||
|
||||
export interface SignReclassificationRule {
|
||||
id: SignReclassificationId
|
||||
/** Orientation the source post is normally presented in. */
|
||||
balance: 'debit' | 'credit'
|
||||
ranges: AccountRange[]
|
||||
/**
|
||||
* `net`: the accounts in range settle as one unit against Skatteverket, so
|
||||
* reclassify only when their combined balance deviates.
|
||||
*
|
||||
* `deviating_rows`: the accounts are economically independent (a
|
||||
* momsfordran must not net away a skattekontoskuld), so each deviating
|
||||
* account is reclassified on its own.
|
||||
*/
|
||||
mode: SignReclassificationMode
|
||||
warning: string
|
||||
}
|
||||
|
||||
const r = (start: string, end: string): AccountRange => ({ start, end })
|
||||
|
||||
export const SIGN_RECLASSIFICATION_RULES: SignReclassificationRule[] = [
|
||||
{
|
||||
id: 'tax_account_credit_to_liability',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
id: 'tax_liability_debit_to_receivable',
|
||||
balance: 'credit',
|
||||
ranges: [r('2500', '2599')],
|
||||
mode: 'net',
|
||||
warning:
|
||||
'Skatteskuldkonton 2500-2599 har ett nettodebetsaldo och har därför redovisats som övrig fordran.',
|
||||
},
|
||||
{
|
||||
id: 'vat_liability_debit_to_receivable',
|
||||
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.',
|
||||
},
|
||||
]
|
||||
|
||||
/** Account numbers are strings and compare lexicographically within a class. */
|
||||
export function isInRanges(accountNumber: string, ranges: AccountRange[]): boolean {
|
||||
return ranges.some((range) => accountNumber >= range.start && accountNumber <= range.end)
|
||||
}
|
||||
|
||||
/** Half an öre: below this a balance is float noise, not a real deviation. */
|
||||
const DEVIATION_THRESHOLD = 0.005
|
||||
|
||||
/**
|
||||
* Accounts whose balances must move from the rule's source post to its target
|
||||
* post, given debit-positive raw ledger balances (debit − credit).
|
||||
*
|
||||
* Returns the accounts rather than an amount so a caller can relocate whole
|
||||
* rows and keep its per-account breakdown consistent with the post totals. For
|
||||
* `net` this is exact: the moved rows sum to the deviating net by definition,
|
||||
* because every account in range moves together.
|
||||
*/
|
||||
export function selectReclassifiedAccounts(
|
||||
rule: SignReclassificationRule,
|
||||
balances: ReadonlyMap<string, number>,
|
||||
): string[] {
|
||||
const orient = (balance: number) => (rule.balance === 'debit' ? balance : -balance)
|
||||
|
||||
const inRange: Array<{ accountNumber: string; oriented: number }> = []
|
||||
for (const [accountNumber, balance] of balances) {
|
||||
if (isInRanges(accountNumber, rule.ranges)) {
|
||||
inRange.push({ accountNumber, oriented: orient(balance) })
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.mode === 'deviating_rows') {
|
||||
return inRange
|
||||
.filter((row) => row.oriented < -DEVIATION_THRESHOLD)
|
||||
.map((row) => row.accountNumber)
|
||||
}
|
||||
|
||||
const net = inRange.reduce((sum, row) => sum + row.oriented, 0)
|
||||
if (net >= -DEVIATION_THRESHOLD) return []
|
||||
return inRange
|
||||
.filter((row) => Math.abs(row.oriented) > DEVIATION_THRESHOLD)
|
||||
.map((row) => row.accountNumber)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { generateTrialBalance } from './trial-balance'
|
||||
import { generateIncomeStatement } from './income-statement'
|
||||
import { generateINK2Declaration } from './ink2/ink2-engine'
|
||||
import { generateNEDeclaration } from './ne-bilaga/ne-engine'
|
||||
|
||||
/**
|
||||
* Årets resultat, as every surface reports it, side by side.
|
||||
*
|
||||
* Every year-end problem a customer has reported was a DISAGREEMENT between two
|
||||
* of our own screens, not a single wrong screen: the årsredovisning said one
|
||||
* figure and INK2 said another, so the customer did the reconciliation for us.
|
||||
* This puts the comparison in the product.
|
||||
*
|
||||
* Two families, and the distinction is load-bearing:
|
||||
*
|
||||
* ledger + statutory must agree exactly (bar öre truncation). Both describe
|
||||
* the position after bokslut. A mismatch here is a bug or
|
||||
* an unfinished bokslut, and is reported as such.
|
||||
* operational reports the result BEFORE bokslutsdispositioner and
|
||||
* skatt, so it legitimately differs today. The gap is
|
||||
* explained rather than flagged. When Stage 2 of #1051
|
||||
* lands (DECISIONS.md:632) the families converge and
|
||||
* EXPECTED_OPERATIONAL_GAP can be dropped.
|
||||
*
|
||||
* Swedish labels: this surfaces next to the bokslut and declaration figures
|
||||
* (see .claude/rules/i18n.md).
|
||||
*/
|
||||
|
||||
/** Öre truncation across a form can legitimately accumulate a krona or two. */
|
||||
const TOLERANCE_KR = 2
|
||||
|
||||
export type ReconciliationFamily = 'ledger' | 'statutory' | 'operational'
|
||||
|
||||
export interface ReconciliationFigure {
|
||||
/** Swedish surface name, as the user sees it in the app. */
|
||||
surface: string
|
||||
family: ReconciliationFamily
|
||||
/** Whole kronor, or null when the surface cannot produce a figure. */
|
||||
aretsResultat: number | null
|
||||
/** Why the figure is null, or why it legitimately differs. */
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface StatementReconciliation {
|
||||
fiscalYear: { id: string; name: string; start: string; end: string; isClosed: boolean }
|
||||
figures: ReconciliationFigure[]
|
||||
/** Human-readable mismatches that need attention. Empty means reconciled. */
|
||||
disagreements: string[]
|
||||
isReconciled: boolean
|
||||
}
|
||||
|
||||
function truncate(value: number): number {
|
||||
return value >= 0 ? Math.floor(value) : Math.ceil(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the booked årets resultat off konto 2099 in the CLOSED books. 2099 holds
|
||||
* only the current year's result under K2: the prior year's is moved to 2098 by
|
||||
* the next year's resultatdisposition.
|
||||
*/
|
||||
async function bookedResult(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
): Promise<number> {
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
closingEntry: 'include',
|
||||
})
|
||||
const row = rows.find((r) => r.account_number === '2099')
|
||||
if (!row) return 0
|
||||
return truncate((Number(row.closing_credit) || 0) - (Number(row.closing_debit) || 0))
|
||||
}
|
||||
|
||||
export async function reconcileStatements(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
): Promise<StatementReconciliation> {
|
||||
const { data: period, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end, is_closed')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (error || !period) {
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
const figures: ReconciliationFigure[] = []
|
||||
const disagreements: string[] = []
|
||||
|
||||
// ── Ledger ────────────────────────────────────────────────────
|
||||
const booked = await bookedResult(supabase, companyId, fiscalPeriodId)
|
||||
figures.push({
|
||||
surface: 'Bokfört resultat (konto 2099)',
|
||||
family: 'ledger',
|
||||
aretsResultat: booked,
|
||||
note: period.is_closed
|
||||
? undefined
|
||||
: 'Räkenskapsåret är inte stängt, så resultatet ligger kvar på resultatkontona.',
|
||||
})
|
||||
|
||||
// ── Statutory: whichever declaration applies to this entity ───
|
||||
// Dispatched on entity_type, NOT by calling a generator and catching its
|
||||
// throw. Catch-as-control-flow swallowed genuine failures too (an internal
|
||||
// computation error, or generateTrialBalance's closing_entry_id guard on a
|
||||
// closed period) and mapped them to a null figure, which the comparison below
|
||||
// skips, so a real bug in the declaration generator made this function report
|
||||
// isReconciled: true. That is the exact opposite of what it exists to do.
|
||||
const entityType = await resolveEntityType(supabase, companyId)
|
||||
|
||||
if (entityType === 'aktiebolag' || entityType === 'enskild_firma') {
|
||||
try {
|
||||
if (entityType === 'aktiebolag') {
|
||||
const ink2 = await generateINK2Declaration(supabase, companyId, fiscalPeriodId)
|
||||
figures.push({
|
||||
surface: 'INK2R (3.26/3.27)',
|
||||
family: 'statutory',
|
||||
aretsResultat: ink2.ink2r['7450'] - ink2.ink2r['7550'],
|
||||
})
|
||||
} else {
|
||||
const ne = await generateNEDeclaration(supabase, companyId, fiscalPeriodId)
|
||||
figures.push({
|
||||
surface: 'NE-bilaga (R11)',
|
||||
family: 'statutory',
|
||||
aretsResultat: ne.rutor.R11,
|
||||
note: 'NE-bilagan redovisar resultatet före skatt; skatten beskattas hos ägaren.',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
// The applicable declaration exists but could not be produced. That is a
|
||||
// finding, not an absence: surface it instead of returning "reconciled".
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
figures.push({
|
||||
surface: entityType === 'aktiebolag' ? 'INK2R (3.26/3.27)' : 'NE-bilaga (R11)',
|
||||
family: 'statutory',
|
||||
aretsResultat: null,
|
||||
note: `Deklarationen kunde inte genereras: ${reason}`,
|
||||
})
|
||||
disagreements.push(
|
||||
`Deklarationen kunde inte genereras och kan därför inte stämmas av mot bokföringen: ${reason}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
figures.push({
|
||||
surface: 'Deklaration',
|
||||
family: 'statutory',
|
||||
aretsResultat: null,
|
||||
note: 'Ingen deklarationsblankett stöds för den här företagsformen.',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Operational ───────────────────────────────────────────────
|
||||
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
|
||||
figures.push({
|
||||
surface: 'Resultaträkning',
|
||||
family: 'operational',
|
||||
aretsResultat: truncate(incomeStatement.net_result),
|
||||
note: 'Visar resultatet före bokslutsdispositioner och skatt.',
|
||||
})
|
||||
|
||||
// ── Compare within the families that must agree ───────────────
|
||||
const statutory = figures.find((f) => f.family === 'statutory')
|
||||
if (
|
||||
period.is_closed
|
||||
&& statutory?.aretsResultat !== null
|
||||
&& statutory?.aretsResultat !== undefined
|
||||
&& Math.abs(statutory.aretsResultat - booked) > TOLERANCE_KR
|
||||
) {
|
||||
disagreements.push(
|
||||
`${statutory.surface} visar ${statutySafe(statutory.aretsResultat)} kr medan bokföringen visar ${booked} kr på konto 2099. Deklarationen stämmer inte med det fastställda bokslutet.`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
fiscalYear: {
|
||||
id: period.id as string,
|
||||
name: period.name as string,
|
||||
start: period.period_start as string,
|
||||
end: period.period_end as string,
|
||||
isClosed: period.is_closed as boolean,
|
||||
},
|
||||
figures,
|
||||
disagreements,
|
||||
isReconciled: disagreements.length === 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow a possibly-null figure for message interpolation. */
|
||||
function statutySafe(value: number | null): number {
|
||||
return value ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the entity type the same way the declaration engines do: prefer
|
||||
* company_settings, fall back to companies.entity_type (NOT NULL, always set).
|
||||
*
|
||||
* The companies error is THROWN, not swallowed. Returning null on a genuine DB
|
||||
* failure (RLS, permissions, connectivity) would be indistinguishable from "no
|
||||
* entity type set", which lands in the unsupported-form branch and reports
|
||||
* isReconciled: true: the same silent-false-reconciled bug this module exists to
|
||||
* close, one level down. A missing company_settings ROW is different and stays
|
||||
* tolerated, because .single() errors on zero rows and many companies have none.
|
||||
*/
|
||||
async function resolveEntityType(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<string | null> {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (settings?.entity_type) return settings.entity_type as string
|
||||
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('companies')
|
||||
.select('entity_type')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
if (companyError) {
|
||||
throw new Error(`Failed to resolve entity type: ${companyError.message}`)
|
||||
}
|
||||
return (company?.entity_type as string | undefined) ?? null
|
||||
}
|
||||
@@ -31,13 +31,43 @@ import type { TrialBalanceRow } from '@/types'
|
||||
* number of entries is handled without the pathological journal_entries!inner
|
||||
* embed plan (see entry-lines.ts for the full story).
|
||||
*/
|
||||
/**
|
||||
* How a caller treats the year-end closing entries. Required, with no default,
|
||||
* on purpose: picking wrong is silent and produces a plausible-looking report,
|
||||
* so every call site must state its choice and be reviewable.
|
||||
*
|
||||
* A resultatavslut posts the mirror image of every P&L account into 2099 inside
|
||||
* the same fiscal period. A caller that sums class 3-8 and forgets to exclude
|
||||
* it therefore reads ZERO across the board, and the balance sheet still ties
|
||||
* out, so nothing warns. That defect shipped three times (årsredovisning
|
||||
* 2026-07-23, INK2R and NE-bilaga 2026-07-29, Resultatrapport found in the
|
||||
* same sweep) before this parameter existed.
|
||||
*/
|
||||
export type ClosingEntryMode =
|
||||
/**
|
||||
* Every entry, resultatavslut included. Correct for balance sheets (2099
|
||||
* must carry årets resultat), for the year-end engine itself, and for
|
||||
* archives and diagnostics that must see the ledger as posted.
|
||||
*/
|
||||
| 'include'
|
||||
/**
|
||||
* Drop only fiscal_periods.closing_entry_id. Correct for statutory annual
|
||||
* reports: skatt, avskrivningar and bokslutsdispositioner also carry
|
||||
* source_type 'year_end' and belong on the form.
|
||||
*/
|
||||
| 'exclude-final'
|
||||
/**
|
||||
* Drop every source_type 'year_end' entry and its storno/correction chain.
|
||||
* The operational-report convention: pre-bokslut activity only.
|
||||
*/
|
||||
| 'exclude-all-year-end'
|
||||
|
||||
export async function generateTrialBalance(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
options?: {
|
||||
excludeYearEndClosing?: boolean
|
||||
excludeFinalClosingEntry?: boolean
|
||||
options: {
|
||||
closingEntry: ClosingEntryMode
|
||||
fromDate?: string
|
||||
toDate?: string
|
||||
dimensions?: Record<string, string>
|
||||
@@ -50,13 +80,14 @@ export async function generateTrialBalance(
|
||||
}> {
|
||||
|
||||
const dimensionFilter =
|
||||
options?.dimensions && Object.keys(options.dimensions).length > 0
|
||||
options.dimensions && Object.keys(options.dimensions).length > 0
|
||||
? options.dimensions
|
||||
: undefined
|
||||
const excludeAllYearEndEntries = options?.excludeYearEndClosing
|
||||
const excludeAllYearEndEntries = options.closingEntry === 'exclude-all-year-end'
|
||||
const excludeFinalOnly = options.closingEntry === 'exclude-final'
|
||||
|
||||
// Wave 1: the period row (for opening balance computation), the reversed
|
||||
// year-end entry ids (only needed for excludeYearEndClosing), and the
|
||||
// year-end entry ids (only needed for 'exclude-all-year-end'), and the
|
||||
// chart of accounts are mutually independent, so they share one parallel
|
||||
// round trip instead of three sequential ones. The accounts list is now
|
||||
// also fetched for reports that turn out empty or fail the closed-period
|
||||
@@ -103,7 +134,7 @@ export async function generateTrialBalance(
|
||||
// closed period without the link is ambiguous, so fail instead of silently
|
||||
// understating the statutory report.
|
||||
if (
|
||||
options?.excludeFinalClosingEntry
|
||||
excludeFinalOnly
|
||||
&& period?.is_closed === true
|
||||
&& !period.closing_entry_id
|
||||
) {
|
||||
@@ -122,7 +153,7 @@ export async function generateTrialBalance(
|
||||
return q
|
||||
}
|
||||
|
||||
const closingEntryId = options?.excludeFinalClosingEntry
|
||||
const closingEntryId = excludeFinalOnly
|
||||
? period?.closing_entry_id ?? null
|
||||
: null
|
||||
// The base query already admits only posted and reversed entries. Exclude a
|
||||
@@ -144,7 +175,7 @@ export async function generateTrialBalance(
|
||||
// "opening" of that window must include all activity since the period
|
||||
// started (rolled forward below).
|
||||
const rollForwardWindow =
|
||||
options?.fromDate && period?.period_start && options.fromDate > period.period_start
|
||||
options.fromDate && period?.period_start && options.fromDate > period.period_start
|
||||
? { periodStart: period.period_start, fromDate: options.fromDate }
|
||||
: null
|
||||
|
||||
@@ -180,7 +211,7 @@ export async function generateTrialBalance(
|
||||
if (excludeAllYearEndEntries) {
|
||||
query = excludeYearEndChain(query)
|
||||
}
|
||||
if (options?.excludeFinalClosingEntry) {
|
||||
if (excludeFinalOnly) {
|
||||
query = excludeClosingEntry(query)
|
||||
}
|
||||
|
||||
@@ -226,10 +257,10 @@ export async function generateTrialBalance(
|
||||
// increase query complexity (and break older mocks that don't stub gte
|
||||
// /lte). The fiscal_period_id constraint plus a CHECK on entry_date in
|
||||
// the engine keep activity inside the period.
|
||||
if (options?.fromDate) {
|
||||
if (options.fromDate) {
|
||||
query = query.gte('entry_date', options.fromDate)
|
||||
}
|
||||
if (options?.toDate) {
|
||||
if (options.toDate) {
|
||||
query = query.lte('entry_date', options.toDate)
|
||||
}
|
||||
|
||||
@@ -240,7 +271,7 @@ export async function generateTrialBalance(
|
||||
if (excludeAllYearEndEntries) {
|
||||
query = excludeYearEndChain(query)
|
||||
}
|
||||
if (options?.excludeFinalClosingEntry) {
|
||||
if (excludeFinalOnly) {
|
||||
query = excludeClosingEntry(query)
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +212,8 @@ async function bookedBalance(
|
||||
}
|
||||
|
||||
const tb = await generateTrialBalance(supabase, companyId, (period as { id: string }).id, {
|
||||
// Reads 29xx semesterlöneskuld accounts (class 2).
|
||||
closingEntry: 'include',
|
||||
toDate: asOfDate,
|
||||
})
|
||||
const balanceOf = (account: string): number => {
|
||||
|
||||
Reference in New Issue
Block a user