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:
@@ -662,6 +662,23 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-29] Retired the generic design skills now that emilkowalski/skills is installed globally (animation-vocabulary, apple-design, emil-design-eng, find-animation-opportunities, improve-animations, pick-ui-library, prototype, review-animations in ~/.claude/skills). Deleted .claude/skills/mobile-ux-core (52 lines of universal mobile UX whose file triggers are *.dart/*.swift/*Activity.kt, paths that do not exist in this repo; superseded by design.md's accessibility section plus apple-design) and .claude/skills/scout-design (a design scan that filed Linear tickets via mcp__claude_ai_Linear__save_issue, while this project files GitHub issues and loop-design-scan is the same scan with the right output; loop-design-scan's sibling reference updated). Kept web-design-guidelines: it is a Vercel-plugin symlink, cheap to keep, and may regenerate anyway. Also removed the global ui-ux-pro-max skill, a 67-style/96-palette catalogue that pulls against a locked editorial-monochrome system.
|
||||
[2026-07-29] Consent-expiry follow-up sent from invoiceservice@arcim.io, not a new sender: matching the address the original batch came from lets the two mails corroborate each other; RESEND_FROM_EMAIL alignment to accounted.se stays a separate ops task.
|
||||
[2026-07-29] Approval-queue MCP App widget (render_ui on list_pending_operations): high-risk confirmed=true now comes from a human click in-widget instead of agent-asserted; payload ceiling 58K->58.5K per the in-test trim-first convention.
|
||||
[2026-07-29] PSD2 sessions are now shareable across a user's companies (extensions/general/enable-banking/lib/session-sharing.ts) rather than one authorization per company: SEB (and several other ASPSPs) allow one active AIS session per PSU, so each new company's BankID silently killed the previous company's feed. Prod confirms it: all 7 SEB customers holding connections in more than one company have had an earlier company stop syncing at the moment the next was authorized, 5 of them while consent was still formally valid, versus 2/11 at Lunar and 0/3 at Nordea. Enable Banking supports this shape already (POST /auth carries no account restriction, GET /accounts/{uid}/transactions takes no session id) and bank_connections has no unique constraint on session_id, so no migration. Only session_id and consent_expires are shared; accounts, cash_accounts and transactions stay per-company.
|
||||
[2026-07-29] Only ENABLED cash_accounts rows count as claiming an IBAN for reuse offers. The connect callback mirrors every account in a consent into cash_accounts, deselected ones included, so counting any row as a claim would mean the first company to connect speaks for the whole bank and no account is ever offerable. Enabled-only also matches the real workflow: sign once, uncheck the other companies' accounts in the picker, and those become what the next company gets.
|
||||
[2026-07-29] The superseded session_id is kept on a reconnecting row through the bank round-trip instead of being nulled at /connect, because the callback needs it to move sibling companies onto the renewed consent. Renewal also re-points sibling 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.
|
||||
[2026-07-29] Reuse-offer strings are hardcoded Swedish, not messages/*.json: BankingSettingsPanel.tsx uses no next-intl at all, so adding two t() keys would either strand them or force converting the whole component, a refactor outside this change's scope.
|
||||
[2026-07-29] INK2R reads two different views of the same period: the balance sheet from the closed books (so 7302 fritt eget kapital carries årets resultat via 2099) and the income statement from the pre-closing books (excludeFinalClosingEntry). The resultatavslut zeroes every P&L account, and INK2 is always filed after bokslut, so a single view cannot serve both sides. Rejected excludeYearEndClosing: tax, avskrivningar and bokslutsdispositioner also carry source_type 'year_end' and belong on the form (7525, 7528).
|
||||
[2026-07-29] The sign-reclassification rules (1630-1659 credit to skatteskuld, 2500-2599 and 2610-2659 debit to fordran) were extracted to lib/reports/sign-reclassification.ts as a DATA table only. k2-mapper keeps its own sumOre arithmetic rather than calling a shared apply(): the iXBRL path sums in exact öre while INK2R works in kronor and truncates per SFL 22:1, so sharing the arithmetic would have risked öre drift in already-shipped årsredovisningar for no gain.
|
||||
[2026-07-29] INK2R applies sign reclassification by relocating whole account rows between SRU codes instead of moving a net amount like k2-mapper does, so breakdown[code].accounts always sums to the code total in the UI drill-down. Exact for 'net' rules too: every account in the range moves together, so the moved rows sum to the deviating net by construction.
|
||||
[2026-07-29] generateTrialBalance now takes a REQUIRED closingEntry: 'include' | 'exclude-final' | 'exclude-all-year-end' instead of two optional booleans, and the options argument is no longer optional. Rationale: picking wrong is silent (a resultatavslut posts the mirror image of every P&L account into 2099 inside the same period, so a caller that forgets reads ZERO across class 3-8 while the balance sheet still ties out and nothing warns). The same defect shipped on the arsredovisning 2026-07-23, on INK2R and NE-bilaga 2026-07-29, and was found sitting unreported on Resultatrapport and dimension-pnl in the same sweep. A required union turns each of those into a compile error at the call site. All 40 call sites were audited individually and every one preserves its current behaviour EXCEPT resultatrapport/dimension-pnl, which were reading zero for any closed year and now pass 'exclude-all-year-end'.
|
||||
[2026-07-29] Resultatrapport and dimension-pnl use 'exclude-all-year-end', NOT 'exclude-final', so they keep reporting the same profit as the formal Resultaträkning. Moving generateIncomeStatement to 'exclude-final' is Stage 2 of #1051, deliberately deferred per DECISIONS.md:632; picking 'exclude-final' here would have made the operational report disagree with the formal one by the full bokslutsdisposition + skatt, which is the exact class of cross-surface disagreement this work is meant to remove. When Stage 2 lands, these call sites move with it.
|
||||
[2026-07-29] The KPI route and its xlsx twin keep closingEntry: 'include', which leaves the expense-composition KPI blank for a closed year. Not fixed here: the RPC path reads agg.tb (equally unexcluded), so changing only the fallback would make the two paths disagree, and the RPC exposes tb_ex_year_end already, so the real fix is a migration plus a displayed-figure change for every company that ran bokslut. That is Stage 2 territory per DECISIONS.md:632. Recorded as an open follow-up.
|
||||
[2026-07-29] get_vat_declaration_totals (migration 20260729110000) excludes the fiscal period's POSTED closing_entry_id, keyed on fiscal_periods.closing_entry_id rather than on source_type = 'year_end'. The resultatavslut debits revenue accounts, which drive rutor 05/39/40, so any VAT period containing the fiscal-year end reported negated turnover once the year was closed: verified read-only on production as ruta 39 = -794 734 kr for December of a closed year, 0 after the fix, with the January period carrying the real sale unchanged. source_type would have been the blunter filter but avskrivningar, periodiseringsfond and skatt share it and must keep whatever VAT effect they carry. A REVERSED closing entry is deliberately retained together with its storno so the pair still nets to zero, mirroring closingEntry: 'exclude-final' in trial-balance.ts; dropping only the reversed original would leave the storno behind and negate turnover a second time.
|
||||
[2026-07-29] periodisk-sammanstallning.ts needed NO closing-entry fix: it filters source_type with an ALLOWLIST (invoice_created, credit_note), so a year_end entry can never reach it. An earlier sweep flagged it as broken by heuristic (raw ledger read, no year_end filter); reading the query disproved that. Recorded so the next sweep does not re-flag it.
|
||||
[2026-07-30] The KPI monthly chart now excludes year-end entries, in BOTH paths: migration 20260730090000 points get_kpi_report_aggregates' monthly section at the existing tb_ex_ye_entries CTE instead of period_entries, and lib/reports/monthly-breakdown.ts gains the matching source_type + reversed-chain exclusion. 20260723180000 had documented the omission as deliberate ("year_end entries are NOT excluded here: the JS scan never excluded them either"), which faithfully mirrored the JS but the JS was itself wrong: the resultatavslut posts the mirror image of every P&L account, so the fiscal-year-end month charted the whole year's revenue as NEGATIVE income. Measured read-only on production: 28 companies over 34 month-rows, worst case a single month's income reading -10 347 459,81 kr instead of +12,88 kr. Both paths are changed together so the RPC hot path and the dimension-filtered fallback keep agreeing. This IS a displayed-figure change, but unlike the Stage 2 income-statement question it is not a choice of convention: a negative revenue spike is simply wrong.
|
||||
[2026-07-30] New check:guards ratchet "ledger-scanning-report": a statement generator under lib/reports or lib/bokslut that aggregates journal_entry_lines itself instead of going through generateTrialBalance. Tracked as a file-set so a NEW generator fails CI even if an old one migrates. Four existing files are grandfathered into the baseline (asset-service, periodiseringsfond-service, bolagsskatt-calculator, sarskild-loneskatt-calculator) rather than migrated now. Voucher and line LISTINGS (general-ledger, journal-register, SIE export, reconciliation, diagnostics) are sanctioned in LEDGER_SCAN_SANCTIONED because they must show the ledger as posted and therefore have no closingEntry decision to get wrong.
|
||||
[2026-07-30] OPEN FOLLOW-UP found by that guard: sarskild-loneskatt-calculator.ts sums accounts 7410-7419 with status='posted' and no year-end exclusion, so the pensionskostnad basis reads ~0 if the calculator runs against an already-closed period (as happens on an undo-then-redo bokslut). Not changed here for the same reason as getBookedBolagsskatt: it is a tax figure whose call chain DECISIONS.md:632 records as having already caused a too-high-tax customer bug, so it deserves its own change with its own verification rather than riding along in a sweep.
|
||||
[2026-07-30] Sign reclassification (1630-1659 credit to skatteskuld, 2500-2599 and 2610-2659 debit to fordran) applies to the STATUTORY presentations only: the K2 iXBRL årsredovisning and INK2R. It is deliberately NOT extended to /rapporter Balansräkning (lib/reports/balance-sheet.ts) or Balansrapport (lib/reports/balansrapport.ts). Both are organised BY ACCOUNT NUMBER under BAS-prefix headings, and balansrapport.ts documents an invariant that depends on every row staying debit-positive where it was booked (total_assets_ub + total_equity_liabilities_ub = beraknat_resultat); moving konto 1630 into a liability section would break that add-the-rows-to-verify property and hide the account from anyone looking it up by number. So the answer to "extend it to the remaining balance-sheet surfaces" is that there are none: the two statutory surfaces both have it, and the other two must not. Pinned in BOTH directions by lib/reports/__tests__/sign-reclassification-scope.test.ts so neither a future sweep re-flags the operational reports nor the reclassification silently vanishes from a statutory one again.
|
||||
[2026-07-30] Reconciliation is delivered in two places rather than as one UI panel. (a) INK2R now self-checks: when the year is closed it compares its own årets resultat against the booked konto 2099 and warns in Swedish if they disagree, mirroring the check k2-mapper has had since 2026-07-23. This is the alarm that was missing when INK2R reported 0 kr against a booked 469 542 kr and nothing warned because the balance sheet still tied out on its own. (b) lib/reports/statement-reconciliation.ts + 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 reported, while operational legitimately differs by bokslutsdispositioner + skatt until Stage 2 of #1051 lands. The visual panel is deliberately NOT built in this change: it needs a /frontend-design pass against the locked concept conventions plus sv/en strings, and the warning in (a) already puts the alarm where the user actually looks. The endpoint exists so the panel (and the MCP/agent surface) has a data source when it is built.
|
||||
[2026-07-29] OAuth error popup stays open instead of auto-closing: the postMessage is dropped on any popup/opener origin mismatch, and closing anyway made every such failure invisible (Fortnox silent-connect incident).
|
||||
[2026-07-29] FORTNOX_REDIRECT_URI on prod deliberately left on app.gnubok.se for now: flipping it to app.accounted.se before that callback URL is registered in the Fortnox Developer Portal would break connect earlier, at the authorize step.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolvePsd2LedgerAccount,
|
||||
defaultLedgerForCurrency,
|
||||
} from '@/lib/cash-accounts/service'
|
||||
import { fanOutSessionRenewal } from '@/extensions/general/enable-banking/lib/session-sharing'
|
||||
import { renderFinalizeShell, renderFinalizeRedirect } from './finalize-page'
|
||||
|
||||
// This route emits bank_connection.consent_granted / .cash_account_mirror_failed
|
||||
@@ -34,6 +35,12 @@ interface PendingConnection {
|
||||
company_id: string
|
||||
bank_name: string | null
|
||||
status: string
|
||||
/**
|
||||
* The session being replaced, captured before the update overwrites it, so a
|
||||
* renewal can be carried to sibling companies sharing it (see
|
||||
* lib/session-sharing.ts). Null on a first-time connect.
|
||||
*/
|
||||
session_id: string | null
|
||||
}
|
||||
|
||||
// Shown in the settings banner when the session exchange/finalize fails.
|
||||
@@ -173,7 +180,7 @@ export async function GET(request: Request) {
|
||||
// state stays a plain redirect.
|
||||
const { data: pendingConnection, error: findError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, user_id, company_id, bank_name, status')
|
||||
.select('id, user_id, company_id, bank_name, status, session_id')
|
||||
.eq('oauth_state', state)
|
||||
.in('status', ['pending', 'expired', 'error'])
|
||||
.single()
|
||||
@@ -339,6 +346,31 @@ async function finalizeConnection(
|
||||
throw new Error(`Failed to update connection: ${updateError.message}`)
|
||||
}
|
||||
|
||||
// A renewed consent belongs to every company that shared the old session,
|
||||
// not just the one whose button was pressed. Without this the siblings keep
|
||||
// pointing at the session the bank has just replaced and die on their next
|
||||
// sync, which is the original one-session-per-PSU problem wearing a
|
||||
// different hat. Non-fatal: this connection is already renewed and correct.
|
||||
if (pendingConnection.session_id && pendingConnection.session_id !== session_id) {
|
||||
try {
|
||||
await fanOutSessionRenewal(supabase, {
|
||||
oldSessionId: pendingConnection.session_id,
|
||||
newSessionId: session_id,
|
||||
consentExpires: consentExpiresAt ?? null,
|
||||
excludeConnectionId: pendingConnection.id,
|
||||
// Several ASPSPs mint new account uids on re-authorization, so the
|
||||
// siblings need their stored uids re-pointed by IBAN too. Carrying the
|
||||
// session id alone would leave them calling dead uids.
|
||||
sessionAccounts: accountsMetadata,
|
||||
})
|
||||
} catch (renewalError) {
|
||||
console.error('[enable-banking] Failed to carry renewed session to siblings', {
|
||||
connectionId: pendingConnection.id,
|
||||
message: renewalError instanceof Error ? renewalError.message : String(renewalError),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror each PSD2 account into cash_accounts so routing decisions read
|
||||
// from the canonical entity table. Accounts already mirrored under the same
|
||||
// (connection, uid) keep their ledger_account — re-deriving it here would
|
||||
|
||||
@@ -14,7 +14,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
interface ClientState {
|
||||
active: Record<string, unknown>[]
|
||||
probeCandidates: Record<string, unknown>[]
|
||||
updates: { id: unknown; payload: Record<string, unknown> }[]
|
||||
/**
|
||||
* Rows touched by an update. Always a list: the probe marks every connection
|
||||
* sharing one dead session in a single .in('id', [...]) write.
|
||||
*/
|
||||
updates: { ids: unknown[]; payload: Record<string, unknown> }[]
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -83,7 +87,10 @@ function makeClient(state: ClientState) {
|
||||
function result() {
|
||||
if (isDelete) return { data: [], error: null }
|
||||
if (updatePayload) {
|
||||
state.updates.push({ id: filters.id, payload: updatePayload })
|
||||
state.updates.push({
|
||||
ids: filters['in:id'] ? (filters['in:id'] as unknown[]) : [filters.id],
|
||||
payload: updatePayload,
|
||||
})
|
||||
return { data: null, error: null }
|
||||
}
|
||||
// The sync loop asks for status = 'active'; the probe pass asks for
|
||||
@@ -174,7 +181,30 @@ describe('GET /api/extensions/enable-banking/sync/cron: session health probe', (
|
||||
await expect(response.json()).resolves.toMatchObject({ probedDead: 1 })
|
||||
expect(state.updates).toEqual([
|
||||
{
|
||||
id: 'conn-1',
|
||||
ids: ['conn-1'],
|
||||
payload: { status: 'expired', error_message: REAUTH_REQUIRED_MESSAGE },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('expires every company sharing one dead session, on a single probe', async () => {
|
||||
// Cross-company session reuse means one consent can back several
|
||||
// companies. Probing per row would spend N identical API calls on one
|
||||
// session and expire the companies one nightly run at a time, so the
|
||||
// others would keep rendering as healthy in the meantime.
|
||||
state.probeCandidates = [
|
||||
connection({ id: 'conn-1', company_id: 'company-1' }),
|
||||
connection({ id: 'conn-2', company_id: 'company-2' }),
|
||||
]
|
||||
mocks.probeSessionHealth.mockResolvedValue('dead')
|
||||
|
||||
const response = await GET(cronRequest())
|
||||
|
||||
expect(mocks.probeSessionHealth).toHaveBeenCalledTimes(1)
|
||||
await expect(response.json()).resolves.toMatchObject({ probedDead: 2 })
|
||||
expect(state.updates).toEqual([
|
||||
{
|
||||
ids: ['conn-1', 'conn-2'],
|
||||
payload: { status: 'expired', error_message: REAUTH_REQUIRED_MESSAGE },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -100,6 +100,13 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
|
||||
daysUntilExpiry?: number | null
|
||||
}[] = []
|
||||
|
||||
// One PSD2 session can back several companies (lib/session-sharing.ts), and
|
||||
// they all carry the same consent_expires. Keyed per (user, session) so a
|
||||
// user with four companies on one consent gets one warning mail, not four.
|
||||
const notifiedSessions = new Set<string>()
|
||||
const notifyKey = (c: { user_id: string; session_id: string | null }) =>
|
||||
`${c.user_id}:${c.session_id ?? 'none'}`
|
||||
|
||||
for (const connection of connections ?? []) {
|
||||
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
||||
ctx.log.info('time budget reached', { processedSoFar: results.length })
|
||||
@@ -121,10 +128,13 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
|
||||
.update({ status: 'expired' })
|
||||
.eq('id', connection.id)
|
||||
|
||||
// Send expiry notification
|
||||
await sendConsentExpiryNotification(
|
||||
supabase, connection, 0, true, baseUrl
|
||||
)
|
||||
// Send expiry notification, once per shared consent
|
||||
if (!notifiedSessions.has(notifyKey(connection))) {
|
||||
notifiedSessions.add(notifyKey(connection))
|
||||
await sendConsentExpiryNotification(
|
||||
supabase, connection, 0, true, baseUrl
|
||||
)
|
||||
}
|
||||
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
@@ -142,7 +152,13 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
|
||||
const expiringSoon = isConsentExpiringSoon(connection.consent_expires)
|
||||
|
||||
// Send consent expiry notifications at 7-day and 3-day thresholds
|
||||
if (expiringSoon && daysLeft !== null && (daysLeft <= 3 || daysLeft === 7)) {
|
||||
if (
|
||||
expiringSoon &&
|
||||
daysLeft !== null &&
|
||||
(daysLeft <= 3 || daysLeft === 7) &&
|
||||
!notifiedSessions.has(notifyKey(connection))
|
||||
) {
|
||||
notifiedSessions.add(notifyKey(connection))
|
||||
await sendConsentExpiryNotification(
|
||||
supabase, connection, daysLeft, false, baseUrl
|
||||
)
|
||||
@@ -354,47 +370,77 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Probe per DISTINCT session, not per connection. One session can back
|
||||
// several companies (lib/session-sharing.ts), so probing per row would spend
|
||||
// four identical API calls on one consent and mark only one company dead at
|
||||
// a time. A session is one live-or-dead fact: the verdict applies to every
|
||||
// row holding it.
|
||||
type UnverifiedConnection = NonNullable<typeof unverified>[number]
|
||||
const probeGroups = new Map<string, UnverifiedConnection[]>()
|
||||
// A session that synced successfully for ANY of its companies is alive, so
|
||||
// skip the whole group rather than re-probing it through a sibling row.
|
||||
const provenAliveSessions = new Set(
|
||||
(unverified ?? [])
|
||||
.filter(c => provenAlive.has(c.id))
|
||||
.map(c => c.session_id as string)
|
||||
)
|
||||
|
||||
for (const connection of unverified ?? []) {
|
||||
const sessionId = connection.session_id as string
|
||||
if (provenAliveSessions.has(sessionId)) continue
|
||||
const group = probeGroups.get(sessionId)
|
||||
if (group) group.push(connection)
|
||||
else probeGroups.set(sessionId, [connection])
|
||||
}
|
||||
|
||||
for (const [sessionId, group] of probeGroups) {
|
||||
if (Date.now() - startTime > PROBE_BUDGET_MS) {
|
||||
ctx.log.info('probe budget reached', { probedSoFar: probeResults.length })
|
||||
break
|
||||
}
|
||||
if (provenAlive.has(connection.id)) continue
|
||||
|
||||
// Per-connection isolation, matching the sync loop above: without it a
|
||||
// single network blip aborts probing for every remaining candidate in the
|
||||
// batch and the coverage gap stays silent until tomorrow's run.
|
||||
// Per-session isolation, matching the sync loop above: without it a single
|
||||
// network blip aborts probing for every remaining candidate in the batch
|
||||
// and the coverage gap stays silent until tomorrow's run.
|
||||
try {
|
||||
const health = await probeSessionHealth(connection.session_id as string)
|
||||
const health = await probeSessionHealth(sessionId)
|
||||
if (health !== 'dead') continue
|
||||
|
||||
const groupIds = group.map(c => c.id)
|
||||
const { error: updateError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'expired', error_message: REAUTH_REQUIRED_MESSAGE })
|
||||
.eq('id', connection.id)
|
||||
.in('id', groupIds)
|
||||
|
||||
// Only claim the connection was marked dead once the write landed.
|
||||
// Only claim the connections were marked dead once the write landed.
|
||||
// Notifying (and counting) on an unpersisted update would tell the user
|
||||
// to re-authorize while the row still reads 'active'.
|
||||
// to re-authorize while the rows still read 'active'.
|
||||
if (updateError) {
|
||||
ctx.log.error('failed to mark probed-dead connection as expired', updateError, {
|
||||
connectionId: connection.id,
|
||||
ctx.log.error('failed to mark probed-dead connections as expired', updateError, {
|
||||
connectionIds: groupIds,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
await sendConsentExpiryNotification(supabase, connection, 0, true, baseUrl)
|
||||
// One dead consent, one mail, however many companies share it.
|
||||
const first = group[0]
|
||||
if (!notifiedSessions.has(notifyKey(first))) {
|
||||
notifiedSessions.add(notifyKey(first))
|
||||
await sendConsentExpiryNotification(supabase, first, 0, true, baseUrl)
|
||||
}
|
||||
|
||||
ctx.log.info('health probe found a dead session', {
|
||||
connectionId: connection.id,
|
||||
bankName: connection.bank_name,
|
||||
previousStatus: connection.status,
|
||||
connectionIds: groupIds,
|
||||
sharedAcrossCompanies: group.length > 1,
|
||||
bankName: first.bank_name,
|
||||
})
|
||||
probeResults.push({ connectionId: connection.id, bankName: connection.bank_name })
|
||||
for (const connection of group) {
|
||||
probeResults.push({ connectionId: connection.id, bankName: connection.bank_name })
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.log.error('health probe failed for connection', err as Error, {
|
||||
connectionId: connection.id,
|
||||
bankName: connection.bank_name,
|
||||
ctx.log.error('health probe failed for session', err as Error, {
|
||||
connectionIds: group.map(c => c.id),
|
||||
bankName: group[0]?.bank_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,8 +324,11 @@ describe('GET /api/reports/kpi', () => {
|
||||
// Unfiltered TB for balance-side KPIs + dimension-scoped TB for the
|
||||
// expense composition.
|
||||
expect(mockTrialBalance).toHaveBeenCalledTimes(2)
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(1, supabase, 'company-1', 'period-1')
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(1, supabase, 'company-1', 'period-1', {
|
||||
closingEntry: 'include',
|
||||
})
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, supabase, 'company-1', 'period-1', {
|
||||
closingEntry: 'include',
|
||||
dimensions,
|
||||
})
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
|
||||
@@ -103,12 +103,17 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co
|
||||
const [prefsRes, is, tb, ar, mb, paid, sup, filteredTb] = await Promise.all([
|
||||
prefsQuery(),
|
||||
generateIncomeStatement(supabase, companyId, periodId, { dimensions }),
|
||||
generateTrialBalance(supabase, companyId, periodId),
|
||||
// 'include' keeps this fallback path agreeing with the RPC path below,
|
||||
// which reads agg.tb (equally unexcluded). The expense-composition KPI is
|
||||
// therefore blank for a closed year; changing it moves a displayed figure
|
||||
// for every company that ran bokslut, which is Stage 2 of #1051
|
||||
// (DECISIONS.md:632), so it is recorded as a follow-up rather than done here.
|
||||
generateTrialBalance(supabase, companyId, periodId, { closingEntry: 'include' }),
|
||||
generateARLedger(supabase, companyId),
|
||||
generateMonthlyBreakdown(supabase, companyId, periodId, { dimensions }),
|
||||
paidInvoicesQuery(),
|
||||
topSuppliersQuery(),
|
||||
generateTrialBalance(supabase, companyId, periodId, { dimensions }),
|
||||
generateTrialBalance(supabase, companyId, periodId, { closingEntry: 'include', dimensions }),
|
||||
])
|
||||
prefsValue = prefsRes.data?.value
|
||||
incomeStatement = is
|
||||
|
||||
@@ -81,7 +81,7 @@ export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabas
|
||||
topSuppliersResult,
|
||||
] = await Promise.all([
|
||||
generateIncomeStatement(supabase, companyId, periodId),
|
||||
generateTrialBalance(supabase, companyId, periodId),
|
||||
generateTrialBalance(supabase, companyId, periodId, { closingEntry: 'include' }),
|
||||
generateARLedger(supabase, companyId),
|
||||
generateMonthlyBreakdown(supabase, companyId, periodId),
|
||||
supabase
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/statement-reconciliation', () => ({
|
||||
reconcileStatements: vi.fn(),
|
||||
}))
|
||||
|
||||
import { reconcileStatements } from '@/lib/reports/statement-reconciliation'
|
||||
import { GET } from '../route'
|
||||
|
||||
const mockReconcile = vi.mocked(reconcileStatements)
|
||||
|
||||
function authed() {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
}
|
||||
|
||||
function unauthed() {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
}
|
||||
|
||||
// Next.js 16 static-route second arg
|
||||
const ctx = { params: Promise.resolve({}) }
|
||||
|
||||
const RECONCILED = {
|
||||
fiscalYear: {
|
||||
id: 'period-1',
|
||||
name: 'Räkenskapsår 2025',
|
||||
start: '2025-01-01',
|
||||
end: '2025-12-31',
|
||||
isClosed: true,
|
||||
},
|
||||
figures: [
|
||||
{ surface: 'Bokfört resultat (konto 2099)', family: 'ledger' as const, aretsResultat: 442_000 },
|
||||
{ surface: 'INK2R (3.26/3.27)', family: 'statutory' as const, aretsResultat: 442_000 },
|
||||
],
|
||||
disagreements: [],
|
||||
isReconciled: true,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authed()
|
||||
})
|
||||
|
||||
describe('GET /api/reports/statement-reconciliation', () => {
|
||||
it('401s when unauthenticated', async () => {
|
||||
unauthed()
|
||||
|
||||
const res = await GET(
|
||||
createMockRequest('/api/reports/statement-reconciliation', {
|
||||
searchParams: { period_id: 'period-1' },
|
||||
}),
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(mockReconcile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400s without period_id', async () => {
|
||||
const res = await GET(
|
||||
createMockRequest('/api/reports/statement-reconciliation'),
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const { body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(body.error).toContain('period_id')
|
||||
})
|
||||
|
||||
it('404s when the fiscal period does not exist', async () => {
|
||||
mockReconcile.mockRejectedValue(new Error('Fiscal period not found'))
|
||||
|
||||
const res = await GET(
|
||||
createMockRequest('/api/reports/statement-reconciliation', {
|
||||
searchParams: { period_id: 'missing' },
|
||||
}),
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns the figures and the reconciled flag', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockReconcile.mockResolvedValue(RECONCILED as any)
|
||||
|
||||
const res = await GET(
|
||||
createMockRequest('/api/reports/statement-reconciliation', {
|
||||
searchParams: { period_id: 'period-1' },
|
||||
}),
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const { body } = await parseJsonResponse<typeof RECONCILED>(res)
|
||||
expect(body.isReconciled).toBe(true)
|
||||
expect(body.figures).toHaveLength(2)
|
||||
expect(mockReconcile).toHaveBeenCalledWith(expect.anything(), 'company-1', 'period-1')
|
||||
})
|
||||
|
||||
it('surfaces a disagreement rather than hiding it behind a 200 with no signal', async () => {
|
||||
mockReconcile.mockResolvedValue({
|
||||
...RECONCILED,
|
||||
disagreements: ['INK2R (3.26/3.27) visar 0 kr medan bokföringen visar 442000 kr på konto 2099.'],
|
||||
isReconciled: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
|
||||
const res = await GET(
|
||||
createMockRequest('/api/reports/statement-reconciliation', {
|
||||
searchParams: { period_id: 'period-1' },
|
||||
}),
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const { body } = await parseJsonResponse<typeof RECONCILED>(res)
|
||||
expect(body.isReconciled).toBe(false)
|
||||
expect(body.disagreements[0]).toContain('konto 2099')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { reconcileStatements } from '@/lib/reports/statement-reconciliation'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* Årets resultat from every surface, side by side, with any disagreement named.
|
||||
*
|
||||
* Exists so the product does the reconciliation a customer used to do for us by
|
||||
* comparing the årsredovisning against INK2 by hand.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'report.statement_reconciliation',
|
||||
async (request, { supabase, companyId }) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
|
||||
if (!periodId) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await reconcileStatements(supabase, companyId!, periodId)
|
||||
return NextResponse.json(result)
|
||||
} catch (err) {
|
||||
// Match on the thrown message, not the localised one: getErrorMessage maps
|
||||
// to Swedish, so testing the output for 'not found' never matches.
|
||||
const isMissingPeriod = err instanceof Error && err.message === 'Fiscal period not found'
|
||||
return NextResponse.json(
|
||||
{ error: getUserErrorMessage(err, { context: 'journal_entry' }) },
|
||||
{ status: isMissingPeriod ? 404 : 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -12,7 +12,7 @@ export const GET = withRouteContext('report.trial_balance', async (request, { su
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateTrialBalance(supabase, companyId, periodId)
|
||||
const result = await generateTrialBalance(supabase, companyId, periodId, { closingEntry: 'include' })
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -38,7 +38,7 @@ export const GET = withRouteContext('report.trial_balance.xlsx', async (request,
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await generateTrialBalance(supabase, companyId, periodId)
|
||||
const report = await generateTrialBalance(supabase, companyId, periodId, { closingEntry: 'include' })
|
||||
|
||||
const buffer = reportToWorkbook<TrialBalanceRow>([
|
||||
{
|
||||
|
||||
@@ -79,7 +79,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
if (!period.ok) return period.response
|
||||
|
||||
const gen = await safeGenerate(
|
||||
() => generateTrialBalance(ctx.supabase, ctx.companyId!, period.period.id),
|
||||
() => generateTrialBalance(ctx.supabase, ctx.companyId!, period.period.id, { closingEntry: 'include' }),
|
||||
{ log: ctx.log, requestId: ctx.requestId, reportName: 'trial-balance' },
|
||||
)
|
||||
if (!gen.ok) return gen.response
|
||||
|
||||
@@ -296,10 +296,10 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
|
||||
<td className="py-2">Årets resultat</td>
|
||||
<td
|
||||
className={`py-2 text-right tabular-nums ${
|
||||
data.totals.resultAfterFinancial >= 0 ? 'text-success' : 'text-destructive'
|
||||
data.totals.aretsResultat >= 0 ? 'text-success' : 'text-destructive'
|
||||
}`}
|
||||
>
|
||||
{formatWholeKronor(data.totals.resultAfterFinancial)}
|
||||
{formatWholeKronor(data.totals.aretsResultat)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
||||
@@ -185,6 +185,12 @@ export function AccountDangerZone() {
|
||||
{/* Live region always mounted so the failure is announced when it
|
||||
appears, not merely inserted. */}
|
||||
<div id={errorId} role="status" aria-live="polite" className="min-w-0">
|
||||
{/* The button is disabled while companies remain, so the reason has
|
||||
to be visible next to it: hiding it behind the blocker row's "?"
|
||||
left users reading the greyed-out button as broken. */}
|
||||
{!loadError && blockers !== null && blockers.length > 0 && (
|
||||
<AttnLine>{t('blocked_reason', { count: blockers.length })}</AttnLine>
|
||||
)}
|
||||
{loadError && (
|
||||
<AttnLine
|
||||
action={
|
||||
@@ -210,7 +216,7 @@ export function AccountDangerZone() {
|
||||
type="button"
|
||||
onClick={() => setShowDialog(true)}
|
||||
disabled={!canDelete}
|
||||
aria-describedby={loadError ? errorId : undefined}
|
||||
aria-describedby={canDelete ? undefined : errorId}
|
||||
className="text-sm font-medium text-destructive underline underline-offset-2 transition-colors duration-150 hover:text-destructive/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('delete_button')}
|
||||
|
||||
@@ -12,6 +12,28 @@ vi.mock('../lib/api-client', () => ({
|
||||
SessionExpiredError: class SessionExpiredError extends Error {},
|
||||
}))
|
||||
|
||||
// A PSD2 session can be shared by several of a user's companies, so the route
|
||||
// refcounts before revoking. That count runs on a service-role client (RLS
|
||||
// would hide a sibling in a company the user has since left), which without
|
||||
// this mock tries to build a real client and fails on missing env vars.
|
||||
const { siblingState } = vi.hoisted(() => ({
|
||||
siblingState: { count: 0, error: null as { message: string } | null },
|
||||
}))
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(async () => ({
|
||||
from: vi.fn(() => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'eq', 'neq']) {
|
||||
chain[method] = vi.fn(() => chain)
|
||||
}
|
||||
chain.then = (onFulfilled: (value: unknown) => unknown) =>
|
||||
Promise.resolve({ count: siblingState.count, error: siblingState.error }).then(onFulfilled)
|
||||
return chain
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
|
||||
import { enableBankingExtension } from '../index'
|
||||
import { deleteSession } from '../lib/api-client'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
@@ -132,6 +154,37 @@ describe('DELETE /disconnect (enable-banking)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockedDeleteSession.mockResolvedValue(undefined)
|
||||
siblingState.count = 0
|
||||
siblingState.error = null
|
||||
})
|
||||
|
||||
it('leaves the shared consent alone while another company still uses it', async () => {
|
||||
// Sessions are shared across a user's companies (lib/session-sharing.ts).
|
||||
// Revoking here would take down a sibling company's bank feed, which is the
|
||||
// exact failure cross-company session reuse exists to remove.
|
||||
siblingState.count = 1
|
||||
const stub = makeStub()
|
||||
const ctx = makeContext(buildSupabase(stub))
|
||||
|
||||
const res = await disconnectRoute.handler(makeRequest({ connection_id: 'conn-1' }), ctx)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockedDeleteSession).not.toHaveBeenCalled()
|
||||
// This company is still fully disconnected: only the upstream consent
|
||||
// survives, and it lapses on its own within 90 days.
|
||||
expect(stub.connUpdates).toEqual([{ status: 'revoked', session_id: null }])
|
||||
expect(stub.cashUpdates).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('treats a failed sibling count as shared rather than risk a live feed', async () => {
|
||||
siblingState.error = { message: 'timeout' }
|
||||
const stub = makeStub()
|
||||
const ctx = makeContext(buildSupabase(stub))
|
||||
|
||||
const res = await disconnectRoute.handler(makeRequest({ connection_id: 'conn-1' }), ctx)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockedDeleteSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
|
||||
@@ -261,10 +261,15 @@ describe('POST /connect (enable-banking): reconnect in place', () => {
|
||||
expect(firstUpdate).toMatchObject({
|
||||
oauth_state: expect.any(String),
|
||||
status: 'expired',
|
||||
session_id: null,
|
||||
error_message: null,
|
||||
})
|
||||
expect(firstUpdate).not.toHaveProperty('authorization_id')
|
||||
// The superseded session_id is deliberately KEPT on the row through the
|
||||
// round-trip. A PSD2 session can be shared by several of the user's
|
||||
// companies (lib/session-sharing.ts), and the callback needs the old id to
|
||||
// move those siblings onto the renewed consent. Nulling it here made the
|
||||
// renewal invisible and left them pointing at a dead session.
|
||||
expect(firstUpdate).not.toHaveProperty('session_id')
|
||||
|
||||
// The bank's authorization_id is recorded in a follow-up write (audit only;
|
||||
// the callback never reads it, so a failure here can't break the reconnect).
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
import {
|
||||
unclaimedAccountsFor,
|
||||
findReusableSessions,
|
||||
countLiveSiblings,
|
||||
fanOutSessionRenewal,
|
||||
remapAccountUids,
|
||||
} from '../lib/session-sharing'
|
||||
import type { StoredAccount } from '../types'
|
||||
|
||||
/**
|
||||
* Thenable query stub: PostgREST chains terminate on await, not on a fixed
|
||||
* method, so the same object has to answer .eq()/.neq()/.gt()/.in() and still
|
||||
* resolve when awaited. Mirrors the stub in lib/cash-accounts/__tests__.
|
||||
*/
|
||||
/** A recorded builder call: the method name and the arguments it got. */
|
||||
type RecordedCall = [string, unknown[]]
|
||||
|
||||
interface ChainStub {
|
||||
calls: RecordedCall[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function chainable(result: Record<string, unknown>): ChainStub {
|
||||
const calls: RecordedCall[] = []
|
||||
const chain = { calls } as ChainStub
|
||||
for (const method of [
|
||||
'select', 'eq', 'neq', 'not', 'is', 'in', 'gt', 'order', 'limit', 'update', 'insert',
|
||||
]) {
|
||||
chain[method] = vi.fn((...args: unknown[]) => {
|
||||
calls.push([method, args])
|
||||
return chain
|
||||
})
|
||||
}
|
||||
chain.then = (onFulfilled: (value: unknown) => unknown) =>
|
||||
Promise.resolve(result).then(onFulfilled)
|
||||
chain.maybeSingle = vi.fn(() => Promise.resolve(result))
|
||||
chain.single = vi.fn(() => Promise.resolve(result))
|
||||
return chain
|
||||
}
|
||||
|
||||
type MockClient = SupabaseClient & { used: Record<string, ChainStub[]> }
|
||||
|
||||
/** Per-table result queues; each from(table) shifts the next result. */
|
||||
function makeSupabase(queues: Record<string, Array<Record<string, unknown>>>): MockClient {
|
||||
const used: Record<string, ChainStub[]> = {}
|
||||
const client = {
|
||||
used,
|
||||
from: vi.fn((table: string) => {
|
||||
const queue = queues[table] ?? []
|
||||
const result = queue.shift() ?? { data: [], error: null }
|
||||
const chain = chainable(result)
|
||||
;(used[table] ??= []).push(chain)
|
||||
return chain
|
||||
}),
|
||||
}
|
||||
return client as unknown as MockClient
|
||||
}
|
||||
|
||||
const FUTURE = new Date(Date.now() + 30 * 24 * 3600 * 1000).toISOString()
|
||||
|
||||
function makeAccount(over: Partial<StoredAccount> = {}): StoredAccount {
|
||||
return { uid: 'uid-1', iban: 'SE1122334455667788990011', currency: 'SEK', ...over }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('unclaimedAccountsFor', () => {
|
||||
it('drops accounts whose IBAN a cash account already claims', () => {
|
||||
const accounts = [
|
||||
makeAccount({ uid: 'a', iban: 'SE1111111111111111111111' }),
|
||||
makeAccount({ uid: 'b', iban: 'SE2222222222222222222222' }),
|
||||
]
|
||||
const result = unclaimedAccountsFor(accounts, new Set(['SE1111111111111111111111']))
|
||||
expect(result.map(a => a.uid)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('matches claimed IBANs regardless of spacing', () => {
|
||||
const accounts = [makeAccount({ uid: 'a', iban: 'SE11 1111 1111 1111 1111 1111' })]
|
||||
const result = unclaimedAccountsFor(accounts, new Set(['SE1111111111111111111111']))
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('never offers an account without an IBAN', () => {
|
||||
// Identity is the IBAN. Without one we cannot prove the account is
|
||||
// unclaimed, and two companies booking one physical account is worse than
|
||||
// making the user authorize separately.
|
||||
const accounts = [makeAccount({ uid: 'a', iban: undefined })]
|
||||
expect(unclaimedAccountsFor(accounts, new Set())).toEqual([])
|
||||
})
|
||||
|
||||
it('offers a repeated IBAN only once', () => {
|
||||
// Some ASPSPs return one resource per balance type on the same account;
|
||||
// offering it twice lets the picker map two rows onto one ledger and trip
|
||||
// the (company_id, ledger_account) UNIQUE constraint on save.
|
||||
const accounts = [
|
||||
makeAccount({ uid: 'a', iban: 'SE3333333333333333333333' }),
|
||||
makeAccount({ uid: 'b', iban: 'SE33 3333 3333 3333 3333 3333' }),
|
||||
]
|
||||
expect(unclaimedAccountsFor(accounts, new Set()).map(a => a.uid)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('strips the source company ledger mapping and enables the account', () => {
|
||||
const accounts = [makeAccount({ ledger_account: '1942', enabled: false })]
|
||||
const [result] = unclaimedAccountsFor(accounts, new Set())
|
||||
expect(result.ledger_account).toBeUndefined()
|
||||
expect(result.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findReusableSessions', () => {
|
||||
it('returns a session with the accounts no company has claimed', async () => {
|
||||
const supabase = makeSupabase({
|
||||
bank_connections: [{
|
||||
data: [{
|
||||
id: 'conn-a', company_id: 'company-a', bank_name: 'Testbanken',
|
||||
provider: 'testbanken-se', session_id: 'sess-1', psu_type: 'business',
|
||||
consent_expires: FUTURE,
|
||||
accounts_data: [
|
||||
makeAccount({ uid: 'claimed', iban: 'SE1111111111111111111111' }),
|
||||
makeAccount({ uid: 'free', iban: 'SE2222222222222222222222' }),
|
||||
],
|
||||
}],
|
||||
error: null,
|
||||
}],
|
||||
cash_accounts: [{ data: [{ iban: 'SE1111111111111111111111' }], error: null }],
|
||||
companies: [{ data: [{ id: 'company-a', name: 'Bolag A' }], error: null }],
|
||||
})
|
||||
|
||||
const sessions = await findReusableSessions(supabase, 'user-1', 'company-b')
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0].companyName).toBe('Bolag A')
|
||||
expect(sessions[0].sessionId).toBe('sess-1')
|
||||
expect(sessions[0].availableAccounts.map(a => a.uid)).toEqual(['free'])
|
||||
})
|
||||
|
||||
it('offers nothing when every account is already claimed', async () => {
|
||||
const supabase = makeSupabase({
|
||||
bank_connections: [{
|
||||
data: [{
|
||||
id: 'conn-a', company_id: 'company-a', bank_name: 'Testbanken',
|
||||
provider: 'testbanken-se', session_id: 'sess-1', psu_type: 'business',
|
||||
consent_expires: FUTURE,
|
||||
accounts_data: [makeAccount({ uid: 'claimed', iban: 'SE1111111111111111111111' })],
|
||||
}],
|
||||
error: null,
|
||||
}],
|
||||
cash_accounts: [{ data: [{ iban: 'SE1111111111111111111111' }], error: null }],
|
||||
companies: [{ data: [{ id: 'company-a', name: 'Bolag A' }], error: null }],
|
||||
})
|
||||
|
||||
expect(await findReusableSessions(supabase, 'user-1', 'company-b')).toEqual([])
|
||||
})
|
||||
|
||||
it('stops offering an account another company already holds but has not mapped', async () => {
|
||||
// The gap between attaching a company and that company finishing its
|
||||
// picker: no cash_accounts row exists yet, so a claimed-ledger check alone
|
||||
// would hand the same physical account to a third company.
|
||||
const supabase = makeSupabase({
|
||||
bank_connections: [
|
||||
{
|
||||
data: [{
|
||||
id: 'conn-a', company_id: 'company-a', bank_name: 'Testbanken',
|
||||
provider: 'testbanken-se', session_id: 'sess-1', psu_type: 'business',
|
||||
consent_expires: FUTURE,
|
||||
accounts_data: [makeAccount({ uid: 'free', iban: 'SE2222222222222222222222' })],
|
||||
}],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
// Carrier pass: company-b already took that account on attach.
|
||||
data: [
|
||||
{ company_id: 'company-a', accounts_data: [makeAccount({ iban: 'SE2222222222222222222222' })] },
|
||||
{ company_id: 'company-b', accounts_data: [makeAccount({ iban: 'SE2222222222222222222222' })] },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
cash_accounts: [{ data: [], error: null }],
|
||||
companies: [{ data: [{ id: 'company-a', name: 'Bolag A' }], error: null }],
|
||||
})
|
||||
|
||||
expect(await findReusableSessions(supabase, 'user-1', 'company-c')).toEqual([])
|
||||
})
|
||||
|
||||
it('counts only enabled cash accounts as claimed', async () => {
|
||||
// The connect callback mirrors EVERY account in the consent into
|
||||
// cash_accounts, deselected ones included. Treating any row as a claim
|
||||
// would mean the first company to connect speaks for the whole bank and
|
||||
// nothing is ever free to offer, so the feature would never fire.
|
||||
const supabase = makeSupabase({
|
||||
bank_connections: [
|
||||
{
|
||||
data: [{
|
||||
id: 'conn-a', company_id: 'company-a', bank_name: 'Testbanken',
|
||||
provider: 'testbanken-se', session_id: 'sess-1', psu_type: 'business',
|
||||
consent_expires: FUTURE,
|
||||
accounts_data: [
|
||||
makeAccount({ uid: 'deselected', iban: 'SE2222222222222222222222', enabled: false }),
|
||||
],
|
||||
}],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
],
|
||||
// The mirrored row exists but is disabled, so it holds nothing.
|
||||
cash_accounts: [{ data: [], error: null }],
|
||||
companies: [{ data: [{ id: 'company-a', name: 'Bolag A' }], error: null }],
|
||||
})
|
||||
|
||||
const sessions = await findReusableSessions(supabase, 'user-1', 'company-b')
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0].availableAccounts.map(a => a.uid)).toEqual(['deselected'])
|
||||
// The enabled filter is what the query must ask for.
|
||||
expect(supabase.used.cash_accounts[0].calls).toContainEqual(['eq', ['enabled', true]])
|
||||
})
|
||||
|
||||
it('scopes the query to the user, other companies, and a live consent', async () => {
|
||||
const supabase = makeSupabase({ bank_connections: [{ data: [], error: null }] })
|
||||
|
||||
await findReusableSessions(supabase, 'user-1', 'company-b')
|
||||
|
||||
const filters = supabase.used.bank_connections[0].calls
|
||||
expect(filters).toContainEqual(['eq', ['user_id', 'user-1']])
|
||||
expect(filters).toContainEqual(['eq', ['status', 'active']])
|
||||
expect(filters).toContainEqual(['neq', ['company_id', 'company-b']])
|
||||
expect(filters.some(([m, args]) => m === 'gt' && args[0] === 'consent_expires')).toBe(true)
|
||||
})
|
||||
|
||||
it('offers nothing when the claimed-IBAN lookup fails', async () => {
|
||||
// Fail closed: without the claimed set we cannot tell a free account from
|
||||
// one another company already books to.
|
||||
const supabase = makeSupabase({
|
||||
bank_connections: [{
|
||||
data: [{
|
||||
id: 'conn-a', company_id: 'company-a', bank_name: 'Testbanken',
|
||||
provider: 'testbanken-se', session_id: 'sess-1', psu_type: 'business',
|
||||
consent_expires: FUTURE,
|
||||
accounts_data: [makeAccount({ uid: 'free', iban: 'SE2222222222222222222222' })],
|
||||
}],
|
||||
error: null,
|
||||
}],
|
||||
cash_accounts: [{ data: null, error: { message: 'boom' } }],
|
||||
companies: [{ data: [], error: null }],
|
||||
})
|
||||
|
||||
// Nothing is offered: an unreadable claimed set cannot prove an account
|
||||
// free, and offering one another company books to is the outcome this
|
||||
// feature must never produce. The failure must also not throw and take the
|
||||
// settings panel down with it, hence awaiting a value rather than a reject.
|
||||
const sessions = await findReusableSessions(supabase, 'user-1', 'company-b')
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty list when the session lookup fails', async () => {
|
||||
const supabase = makeSupabase({
|
||||
bank_connections: [{ data: null, error: { message: 'rls' } }],
|
||||
})
|
||||
expect(await findReusableSessions(supabase, 'user-1', 'company-b')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countLiveSiblings', () => {
|
||||
it('counts the other non-revoked connections on the session', async () => {
|
||||
const supabase = makeSupabase({ bank_connections: [{ count: 2, error: null }] })
|
||||
|
||||
const count = await countLiveSiblings(supabase, 'sess-1', 'conn-a')
|
||||
|
||||
expect(count).toBe(2)
|
||||
const filters = supabase.used.bank_connections[0].calls
|
||||
expect(filters).toContainEqual(['eq', ['session_id', 'sess-1']])
|
||||
expect(filters).toContainEqual(['neq', ['id', 'conn-a']])
|
||||
expect(filters).toContainEqual(['neq', ['status', 'revoked']])
|
||||
})
|
||||
|
||||
it('reports a sibling when the count fails, so the session is never revoked', async () => {
|
||||
const supabase = makeSupabase({
|
||||
bank_connections: [{ count: null, error: { message: 'timeout' } }],
|
||||
})
|
||||
expect(await countLiveSiblings(supabase, 'sess-1', 'conn-a')).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fanOutSessionRenewal', () => {
|
||||
it('does nothing when the session did not actually change', async () => {
|
||||
const supabase = makeSupabase({})
|
||||
const result = await fanOutSessionRenewal(supabase, {
|
||||
oldSessionId: 'sess-1',
|
||||
newSessionId: 'sess-1',
|
||||
consentExpires: FUTURE,
|
||||
excludeConnectionId: 'conn-a',
|
||||
})
|
||||
expect(result.movedCount).toBe(0)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revives a dead sibling and leaves a pending_selection one pending', async () => {
|
||||
const supabase = makeSupabase({
|
||||
bank_connections: [
|
||||
{
|
||||
data: [
|
||||
{ id: 'conn-b', status: 'expired', accounts_data: [] },
|
||||
{ id: 'conn-c', status: 'pending_selection', accounts_data: [] },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ error: null },
|
||||
{ error: null },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await fanOutSessionRenewal(supabase, {
|
||||
oldSessionId: 'sess-old',
|
||||
newSessionId: 'sess-new',
|
||||
consentExpires: FUTURE,
|
||||
excludeConnectionId: 'conn-a',
|
||||
})
|
||||
|
||||
expect(result.movedCount).toBe(2)
|
||||
|
||||
// The connection that just re-authorized is already correct; touching it
|
||||
// again would be a no-op at best and a status regression at worst.
|
||||
expect(supabase.used.bank_connections[0].calls).toContainEqual(['neq', ['id', 'conn-a']])
|
||||
|
||||
const revived = supabase.used.bank_connections[1].calls
|
||||
expect(revived).toContainEqual([
|
||||
'update',
|
||||
[{ session_id: 'sess-new', consent_expires: FUTURE, status: 'active', error_message: null }],
|
||||
])
|
||||
|
||||
// Still owes an account selection, so it must not be flipped to active:
|
||||
// that would skip the picker and sync nothing.
|
||||
const stillPending = supabase.used.bank_connections[2].calls
|
||||
expect(stillPending).toContainEqual([
|
||||
'update',
|
||||
[{ session_id: 'sess-new', consent_expires: FUTURE }],
|
||||
])
|
||||
})
|
||||
|
||||
it('re-points sibling accounts at the uids the new session issued', async () => {
|
||||
// The uid churn is the subtle half of the renewal: carrying only the
|
||||
// session id leaves siblings calling accounts the bank has retired.
|
||||
const supabase = makeSupabase({
|
||||
bank_connections: [
|
||||
{
|
||||
data: [{
|
||||
id: 'conn-b',
|
||||
status: 'active',
|
||||
accounts_data: [
|
||||
{ uid: 'old-uid', iban: 'SE4444444444444444444444', currency: 'SEK', ledger_account: '1930', enabled: true },
|
||||
],
|
||||
}],
|
||||
error: null,
|
||||
},
|
||||
{ error: null },
|
||||
{ error: null },
|
||||
],
|
||||
})
|
||||
|
||||
await fanOutSessionRenewal(supabase, {
|
||||
oldSessionId: 'sess-old',
|
||||
newSessionId: 'sess-new',
|
||||
consentExpires: FUTURE,
|
||||
excludeConnectionId: 'conn-a',
|
||||
sessionAccounts: [{ uid: 'new-uid', iban: 'SE44 4444 4444 4444 4444 4444' }],
|
||||
})
|
||||
|
||||
// The uid re-point is its own write, after the session move.
|
||||
const update = supabase.used.bank_connections[2].calls.find(([m]) => m === 'update')
|
||||
const payload = (update?.[1][0] ?? {}) as { accounts_data?: StoredAccount[] }
|
||||
expect(payload.accounts_data?.[0].uid).toBe('new-uid')
|
||||
// The company's own mapping choices survive the remap.
|
||||
expect(payload.accounts_data?.[0].ledger_account).toBe('1930')
|
||||
})
|
||||
|
||||
it('counts only the siblings that actually moved', async () => {
|
||||
const supabase = makeSupabase({
|
||||
bank_connections: [
|
||||
{
|
||||
data: [
|
||||
{ id: 'conn-b', status: 'active', accounts_data: [] },
|
||||
{ id: 'conn-c', status: 'active', accounts_data: [] },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ error: { message: 'boom' } },
|
||||
{ error: null },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await fanOutSessionRenewal(supabase, {
|
||||
oldSessionId: 'sess-old',
|
||||
newSessionId: 'sess-new',
|
||||
consentExpires: FUTURE,
|
||||
excludeConnectionId: 'conn-a',
|
||||
})
|
||||
|
||||
expect(result.movedCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('remapAccountUids', () => {
|
||||
it('matches on IBAN regardless of spacing and keeps everything else', () => {
|
||||
const { accounts, remapped } = remapAccountUids(
|
||||
[{ uid: 'old', iban: 'SE55 5555 5555 5555 5555 5555', currency: 'SEK', enabled: false, ledger_account: '1940' }],
|
||||
[{ uid: 'new', iban: 'SE5555555555555555555555' }],
|
||||
)
|
||||
expect(remapped).toBe(1)
|
||||
expect(accounts[0]).toMatchObject({ uid: 'new', enabled: false, ledger_account: '1940' })
|
||||
})
|
||||
|
||||
it('leaves an account the new consent does not cover untouched', () => {
|
||||
// Silently dropping a mapped account is worse than a visible sync error.
|
||||
const { accounts, remapped, unmatched } = remapAccountUids(
|
||||
[{ uid: 'old', iban: 'SE6666666666666666666666', currency: 'SEK' }],
|
||||
[{ uid: 'new', iban: 'SE7777777777777777777777' }],
|
||||
)
|
||||
expect(remapped).toBe(0)
|
||||
expect(unmatched).toBe(1)
|
||||
expect(accounts[0].uid).toBe('old')
|
||||
})
|
||||
})
|
||||
@@ -12,13 +12,29 @@ import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
|
||||
import { useCompany, useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import { SettingsGroup, SettingsRow, SettingsSeg } from '@/components/settings/SettingsRows'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
SettingsSeg,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { BankSelector, type Bank } from './BankSelector'
|
||||
import { BankConnectionStatus } from './BankConnectionStatus'
|
||||
import { AccountPickerDialog } from './AccountPickerDialog'
|
||||
import type { BankConnection } from '@/types'
|
||||
import type { StoredAccount } from '../types'
|
||||
|
||||
/** One "reuse an existing connection" offer, as returned by /reusable-sessions. */
|
||||
interface ReusableSessionOffer {
|
||||
connection_id: string
|
||||
company_id: string
|
||||
company_name: string | null
|
||||
bank_name: string | null
|
||||
consent_expires: string | null
|
||||
available_account_count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained banking settings panel for the enable-banking extension.
|
||||
* Loaded dynamically by the settings panel registry.
|
||||
@@ -56,6 +72,11 @@ export default function BankingSettingsPanel() {
|
||||
// Several ASPSPs allow only one active AIS session per PSU, so authorizing
|
||||
// company B silently kills company A's connection. RLS scopes SELECT to
|
||||
// user_company_ids(), so this read stays within the user's own companies.
|
||||
// Live sessions in the user's OTHER companies that still have unclaimed
|
||||
// accounts. Reusing one connects this company without a second BankID and
|
||||
// without revoking the first, which is what kills feeds at one-session banks.
|
||||
const [reusableSessions, setReusableSessions] = useState<ReusableSessionOffer[]>([])
|
||||
const [attachingConnectionId, setAttachingConnectionId] = useState<string | null>(null)
|
||||
const [otherCompanyConnections, setOtherCompanyConnections] = useState<
|
||||
{ bank_name: string; company_id: string }[]
|
||||
>([])
|
||||
@@ -190,6 +211,20 @@ export default function BankingSettingsPanel() {
|
||||
)
|
||||
)
|
||||
|
||||
// Reuse offers. Best-effort: a failure here costs the shortcut, never the
|
||||
// panel, so the normal connect flow stays available either way.
|
||||
try {
|
||||
const reuseResponse = await fetch('/api/extensions/ext/enable-banking/reusable-sessions')
|
||||
if (reuseResponse.ok) {
|
||||
const { sessions } = await reuseResponse.json()
|
||||
setReusableSessions((sessions || []) as ReusableSessionOffer[])
|
||||
} else {
|
||||
setReusableSessions([])
|
||||
}
|
||||
} catch {
|
||||
setReusableSessions([])
|
||||
}
|
||||
|
||||
// If a pending connection exists from a recent attempt (e.g. user bounced back from
|
||||
// the bank's auth page), keep the connect button disabled until the server-side lock expires.
|
||||
const freshPending = (connections || []).find((c) => c.status === 'pending')
|
||||
@@ -241,6 +276,49 @@ export default function BankingSettingsPanel() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse a session authorized for another of the user's companies. No bank
|
||||
* round-trip: the server creates this company's connection against the same
|
||||
* consent and parks it in 'pending_selection', so the account picker opens
|
||||
* exactly as it does after a real authorization.
|
||||
*/
|
||||
async function handleReuseConnection(offer: ReusableSessionOffer) {
|
||||
if (attachingConnectionId) return
|
||||
setAttachingConnectionId(offer.connection_id)
|
||||
try {
|
||||
const response = await fetch('/api/extensions/ext/enable-banking/attach', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: offer.connection_id }),
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: 'Kunde inte återanvända anslutningen',
|
||||
description: result?.error || 'Försök igen om en stund.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await fetchConnections()
|
||||
notifyBankSyncUpdated()
|
||||
// Straight into account selection: the connection exists but syncs
|
||||
// nothing until the user picks which accounts belong to this company.
|
||||
setPickerConnectionId(result.connection_id)
|
||||
} catch (error) {
|
||||
console.error('[enable-banking] Reuse failed', error)
|
||||
toast({
|
||||
title: 'Kunde inte återanvända anslutningen',
|
||||
description: 'Ett oväntat fel uppstod. Försök igen om en stund.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setAttachingConnectionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnectBank(bank: Bank, psuTypeOverride?: 'personal' | 'business') {
|
||||
if (connectingRef.current) return
|
||||
// Claim the lock BEFORE the confirm await. The dialog can sit open
|
||||
@@ -654,6 +732,69 @@ export default function BankingSettingsPanel() {
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
{/* Reuse a session authorized for another of the user's companies. Sits
|
||||
ABOVE the bank list deliberately: at a one-session-per-login bank,
|
||||
choosing the bank below is the very action that kills the other
|
||||
company's feed, so the cheaper and safer path has to be seen first.
|
||||
Renders only when a live session actually has unclaimed accounts. */}
|
||||
{hasBankSync && reusableSessions.length > 0 && (
|
||||
<SettingsGroup
|
||||
label="Återanvänd befintlig anslutning"
|
||||
help={
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
Du har redan en giltig bankanslutning i ett annat bolag, och den ser konton
|
||||
som inget bolag använder ännu.
|
||||
</p>
|
||||
<p>
|
||||
Vissa banker tillåter bara en aktiv anslutning per inloggning. Att återanvända
|
||||
anslutningen i stället för att logga in på nytt låter bolagen dela samma
|
||||
samtycke, så bolaget som redan är anslutet fortsätter att synka.
|
||||
</p>
|
||||
<p>
|
||||
Bolagen delar bara samtycket. Konton, transaktioner och bokföring hålls isär,
|
||||
och du väljer i nästa steg vilka konton som hör till det här bolaget.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{reusableSessions.map((offer) => (
|
||||
<SettingsRow key={offer.connection_id} label={offer.bank_name ?? 'Bank'}>
|
||||
<SettingsRowNote>
|
||||
Ansluten för{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{offer.company_name ?? 'ett annat bolag'}
|
||||
</span>
|
||||
. {offer.available_account_count}{' '}
|
||||
{offer.available_account_count === 1 ? 'ledigt konto' : 'lediga konton'} kan
|
||||
kopplas till{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{company?.name ?? 'det här bolaget'}
|
||||
</span>{' '}
|
||||
utan nytt BankID.
|
||||
</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleReuseConnection(offer)}
|
||||
disabled={!!attachingConnectionId}
|
||||
>
|
||||
{attachingConnectionId === offer.connection_id ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Kopplar
|
||||
</>
|
||||
) : (
|
||||
'Återanvänd'
|
||||
)}
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
{/* Connect new bank. Non-payers keep seeing the group (conversion
|
||||
surface) but the bank list is replaced by an upgrade note: the
|
||||
server gate would 403 the connect anyway. The former "Om
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ASPSP,
|
||||
} from './lib/api-client'
|
||||
import { syncAccountTransactions } from './lib/sync'
|
||||
import { findReusableSessions, countLiveSiblings } from './lib/session-sharing'
|
||||
import {
|
||||
runReconciliation,
|
||||
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
|
||||
@@ -28,6 +29,7 @@ import type { Transaction } from '@/types'
|
||||
const RATE_LIMIT_ACCOUNTS = { maxRequests: 20, windowMs: 60_000 }
|
||||
const RATE_LIMIT_SYNC = { maxRequests: 10, windowMs: 60_000 }
|
||||
const RATE_LIMIT_DISCONNECT = { maxRequests: 10, windowMs: 60_000 }
|
||||
const RATE_LIMIT_ATTACH = { maxRequests: 10, windowMs: 60_000 }
|
||||
|
||||
const MAX_ENABLED_UIDS = 50
|
||||
|
||||
@@ -94,6 +96,179 @@ export const enableBankingExtension: Extension = {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// Live PSD2 sessions the user already holds in their OTHER companies that
|
||||
// still have unclaimed accounts. Drives the "reuse this connection" offer:
|
||||
// several ASPSPs allow one active AIS session per PSU, so authorizing the
|
||||
// same bank again for a second company kills the first company's feed.
|
||||
// An empty list is the normal case and renders no offer at all.
|
||||
method: 'GET',
|
||||
path: '/reusable-sessions',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (!ctx?.companyId) {
|
||||
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const sessions = await findReusableSessions(supabase, user.id, ctx.companyId)
|
||||
// Never ship session_id to the browser: it is the bearer of the PSD2
|
||||
// consent. The client only needs to name the offer and post back the
|
||||
// source connection id, which is re-validated server-side on attach.
|
||||
return NextResponse.json({
|
||||
sessions: sessions.map(s => ({
|
||||
connection_id: s.connectionId,
|
||||
company_id: s.companyId,
|
||||
company_name: s.companyName,
|
||||
bank_name: s.bankName,
|
||||
consent_expires: s.consentExpires,
|
||||
available_account_count: s.availableAccounts.length,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('[enable-banking] Failed to list reusable sessions', error)
|
||||
return NextResponse.json({ sessions: [] })
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// Attach the ACTIVE company to a session authorized for another of the
|
||||
// user's companies. No BankID, no new /auth call, nothing revoked: the
|
||||
// new row shares session_id + consent_expires and carries only the
|
||||
// accounts no company has claimed. Lands in 'pending_selection' so the
|
||||
// existing AccountPickerDialog does the ledger mapping, IBAN-aware.
|
||||
method: 'POST',
|
||||
path: '/attach',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (!ctx?.companyId) {
|
||||
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
||||
}
|
||||
const companyId = ctx.companyId
|
||||
|
||||
const blocked = await requireCapability(supabase, companyId, CAPABILITY.bank_sync)
|
||||
if (blocked) return blocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'enable-banking:attach',
|
||||
identifier: user.id,
|
||||
...RATE_LIMIT_ATTACH,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
const { connection_id } = await request.json()
|
||||
if (!connection_id) {
|
||||
return NextResponse.json({ error: 'connection_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-derive the offer server-side rather than trusting the posted id.
|
||||
// findReusableSessions re-checks ownership (same user), that the
|
||||
// source is a DIFFERENT company, that its session is active with a
|
||||
// live consent, and which accounts are genuinely unclaimed.
|
||||
const sessions = await findReusableSessions(supabase, user.id, companyId)
|
||||
const source = sessions.find(s => s.connectionId === connection_id)
|
||||
if (!source) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No reusable session available for this connection' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// A company already syncing this bank must go through reconnect, not
|
||||
// attach: a second live row for the same provider would sync the same
|
||||
// accounts twice into one set of books.
|
||||
const { data: existingForCompany } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('provider', source.provider)
|
||||
.in('status', ['active', 'pending_selection'])
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (existingForCompany) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This company is already connected to that bank' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: created, error: insertError } = await supabase
|
||||
.from('bank_connections')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
provider: source.provider,
|
||||
bank_name: source.bankName,
|
||||
session_id: source.sessionId,
|
||||
psu_type: source.psuType,
|
||||
consent_expires: source.consentExpires,
|
||||
accounts_data: source.availableAccounts,
|
||||
status: 'pending_selection',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (insertError || !created) {
|
||||
log.error('[enable-banking] Failed to attach shared session', {
|
||||
message: insertError?.message,
|
||||
sourceConnectionId: source.connectionId,
|
||||
companyId,
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to reuse connection' }, { status: 500 })
|
||||
}
|
||||
|
||||
log.info('[enable-banking] Attached company to an existing PSD2 session', {
|
||||
connectionId: created.id,
|
||||
sourceConnectionId: source.connectionId,
|
||||
companyId,
|
||||
bankName: source.bankName,
|
||||
accountCount: source.availableAccounts.length,
|
||||
})
|
||||
|
||||
// This company gains access to bank data, so it is a consent grant
|
||||
// from an audit standpoint even though no new consent was signed
|
||||
// (ASVS V16 / GDPR Art.30), same event the callback emits.
|
||||
try {
|
||||
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
|
||||
await emit({
|
||||
type: 'bank_connection.consent_granted',
|
||||
payload: {
|
||||
connectionId: created.id,
|
||||
bankName: source.bankName ?? null,
|
||||
accountCount: source.availableAccounts.length,
|
||||
consentExpiresAt: source.consentExpires ?? null,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
} catch (emitError) {
|
||||
log.error('[enable-banking] Failed to emit consent_granted on attach', emitError)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
connection_id: created.id,
|
||||
account_count: source.availableAccounts.length,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('[enable-banking] Attach failed', error)
|
||||
return NextResponse.json({ error: 'Failed to reuse connection' }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
@@ -294,7 +469,12 @@ export const enableBankingExtension: Extension = {
|
||||
.update({
|
||||
oauth_state: oauthState,
|
||||
status: 'expired',
|
||||
session_id: null,
|
||||
// session_id is deliberately KEPT here. The callback needs the
|
||||
// session being replaced to carry the renewed consent across to
|
||||
// sibling companies sharing it (lib/session-sharing.ts); nulling
|
||||
// it made the renewal invisible and left the siblings pointing
|
||||
// at a session the bank had just superseded. 'expired' already
|
||||
// marks the row dead, and the probe pass skips expired rows.
|
||||
error_message: null,
|
||||
psu_type: psuType,
|
||||
})
|
||||
@@ -316,7 +496,25 @@ export const enableBankingExtension: Extension = {
|
||||
// expected and non-fatal: the new authorization supersedes it.
|
||||
// Logged at WARN so a systematic revoke failure is visible to
|
||||
// monitoring (compliance: ASVS V16 / ISO 27001 A.8.15).
|
||||
// Never revoke a session other companies still hold. On a shared
|
||||
// consent this revoke would kill their feeds instantly, before the
|
||||
// replacement session exists, and permanently if the user abandons
|
||||
// the bank flow. The callback moves the siblings onto the new
|
||||
// session once it lands; the superseded one lapses on its own.
|
||||
let oldSessionShared = false
|
||||
if (existing.session_id) {
|
||||
const { createServiceClient } = await import('@/lib/supabase/server')
|
||||
const serviceSupabase = await createServiceClient()
|
||||
oldSessionShared =
|
||||
(await countLiveSiblings(serviceSupabase, existing.session_id, existing.id)) > 0
|
||||
if (oldSessionShared) {
|
||||
log.info('[enable-banking] Old session shared with other companies: not revoking', {
|
||||
connection_id: existing.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (existing.session_id && !oldSessionShared) {
|
||||
try {
|
||||
await deleteSession(existing.session_id)
|
||||
} catch (revokeError) {
|
||||
@@ -1293,8 +1491,34 @@ export const enableBankingExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Revoke PSD2 consent if session exists
|
||||
// Revoke the PSD2 consent only when no other company still depends on
|
||||
// it. Sessions are shared across a user's companies (see
|
||||
// lib/session-sharing.ts), so a blind revoke here would silently take
|
||||
// down a sibling company's bank feed: exactly the failure this feature
|
||||
// exists to remove. countLiveSiblings needs the service client because
|
||||
// RLS hides a sibling living in a company the user has since left, and
|
||||
// an unseen sibling would read as "safe to revoke".
|
||||
let sharedWithSiblings = false
|
||||
if (connection.session_id) {
|
||||
const { createServiceClient } = await import('@/lib/supabase/server')
|
||||
const serviceSupabase = await createServiceClient()
|
||||
const siblingCount = await countLiveSiblings(
|
||||
serviceSupabase,
|
||||
connection.session_id,
|
||||
connection.id,
|
||||
)
|
||||
sharedWithSiblings = siblingCount > 0
|
||||
if (sharedWithSiblings) {
|
||||
log.info('[enable-banking] Session still in use by other companies: skipping revoke', {
|
||||
connectionId: connection.id,
|
||||
siblingCount,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (connection.session_id && !sharedWithSiblings) {
|
||||
try {
|
||||
await deleteSession(connection.session_id)
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { normalizeIban } from '@/lib/cash-accounts/service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { StoredAccount } from '../types'
|
||||
|
||||
const log = createLogger('enable-banking/session-sharing')
|
||||
|
||||
/**
|
||||
* Cross-company PSD2 session reuse.
|
||||
*
|
||||
* Several ASPSPs (SEB most visibly) allow only one active AIS session per PSU.
|
||||
* A user who signs for company A, then company B, then company C at the same
|
||||
* bank ends up with only the newest session alive: each authorization revokes
|
||||
* the previous one bank-side, without telling us. Prod bears this out: every
|
||||
* SEB customer holding connections for more than one company has had an
|
||||
* earlier company stop syncing at the moment the next one was authorized,
|
||||
* usually while its consent was still formally valid for weeks.
|
||||
*
|
||||
* The fix is to stop minting one session per company. Enable Banking's
|
||||
* authorization is per-PSU, not per-company: POST /auth carries no account
|
||||
* restriction, so the returned session already covers every account the user
|
||||
* ticked at the bank, and GET /accounts/{uid}/transactions takes no session id
|
||||
* at all. So a second company can simply point at the first company's session
|
||||
* and sync its own accounts from it, with no second BankID and nothing revoked.
|
||||
*
|
||||
* What is shared is exactly the consent: `session_id` and `consent_expires`.
|
||||
* Everything else stays per-company — its own bank_connections row, its own
|
||||
* accounts_data subset, its own cash_accounts and transactions. Company B's
|
||||
* row never carries an account company A already claimed, so the shared
|
||||
* session is not a window into another company's books.
|
||||
*/
|
||||
|
||||
/** A live session belonging to one of the user's other companies. */
|
||||
export interface ReusableSession {
|
||||
/** The source connection whose session would be shared. */
|
||||
connectionId: string
|
||||
companyId: string
|
||||
companyName: string | null
|
||||
bankName: string | null
|
||||
provider: string
|
||||
sessionId: string
|
||||
psuType: string | null
|
||||
consentExpires: string | null
|
||||
/** Accounts in that session no company has mapped to a ledger yet. */
|
||||
availableAccounts: StoredAccount[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts in a session that no cash_accounts row has claimed.
|
||||
*
|
||||
* Identity is the IBAN, matching resolvePsd2LedgerAccount: the provider's
|
||||
* account uid does not survive a re-authorization at every ASPSP, so it cannot
|
||||
* decide ownership. Accounts WITHOUT an IBAN are deliberately never offered.
|
||||
* We cannot prove such an account is unclaimed, and handing one to a second
|
||||
* company risks two companies booking the same physical account, which is a
|
||||
* far worse outcome than making the user authorize separately for it.
|
||||
*/
|
||||
export function unclaimedAccountsFor(
|
||||
accounts: readonly StoredAccount[],
|
||||
claimedIbans: ReadonlySet<string>,
|
||||
): StoredAccount[] {
|
||||
const out: StoredAccount[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const account of accounts) {
|
||||
const iban = normalizeIban(account.iban)
|
||||
if (!iban) continue
|
||||
if (claimedIbans.has(iban)) continue
|
||||
// One session can list the same IBAN twice (some ASPSPs return a separate
|
||||
// resource per balance type). Offering it twice would let the picker map
|
||||
// two rows onto one ledger and trip the UNIQUE constraint on save.
|
||||
if (seen.has(iban)) continue
|
||||
seen.add(iban)
|
||||
// The source company's enable/disable choice is its own; company B starts
|
||||
// with everything on and unchecks in the picker. Drop the source's ledger
|
||||
// mapping too: that number belongs to the other company's chart.
|
||||
const { ledger_account: _ledger, ...rest } = account
|
||||
out.push({ ...rest, enabled: true })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Every IBAN a company is actually syncing. RLS scopes cash_accounts to
|
||||
* user_company_ids(), which is exactly the set that could collide, so no
|
||||
* explicit company filter is needed here.
|
||||
*
|
||||
* Only ENABLED rows count as claimed, and that distinction is what makes this
|
||||
* feature work at all. The connect callback mirrors every account in the
|
||||
* consent into cash_accounts, deselected ones included, so treating any row as
|
||||
* a claim would mean a bank's whole consent is spoken for the moment the first
|
||||
* company connects and no account is ever free to offer.
|
||||
*
|
||||
* Enabled-only also matches how people actually work: signing once at the bank
|
||||
* returns every account the user can see, and they uncheck the other
|
||||
* companies' accounts in the picker precisely because those do not belong in
|
||||
* this company's books. Those are the accounts the next company should get.
|
||||
*/
|
||||
/**
|
||||
* Returns null when the claimed set could not be read. An empty Set would be
|
||||
* indistinguishable from "nothing is claimed", which makes every IBAN in the
|
||||
* session offerable: the one outcome this feature must never produce. The
|
||||
* caller turns null into an empty offer list.
|
||||
*/
|
||||
async function fetchClaimedIbans(supabase: SupabaseClient): Promise<Set<string> | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('iban')
|
||||
.eq('enabled', true)
|
||||
.not('iban', 'is', null)
|
||||
|
||||
if (error) {
|
||||
// Fail closed: without the claimed set we cannot tell a free account from
|
||||
// one another company already books to, and offering a claimed account is
|
||||
// the one outcome this feature must never produce.
|
||||
log.warn('claimed iban lookup failed, offering nothing', { error: error.message })
|
||||
return null
|
||||
}
|
||||
|
||||
const claimed = new Set<string>()
|
||||
for (const row of (data ?? []) as Array<{ iban: string | null }>) {
|
||||
const iban = normalizeIban(row.iban)
|
||||
if (iban) claimed.add(iban)
|
||||
}
|
||||
return claimed
|
||||
}
|
||||
|
||||
/**
|
||||
* Live sessions the user holds in OTHER companies that still have accounts to
|
||||
* give. Returns an empty list, not an error, whenever nothing qualifies: the
|
||||
* caller renders an offer only when there is something to offer.
|
||||
*
|
||||
* Sources are restricted to status 'active'. A 'pending_selection' source is
|
||||
* authorized and alive, but its owner has not finished picking accounts yet,
|
||||
* so every account would read as unclaimed and company B could take one
|
||||
* company A is seconds away from choosing.
|
||||
*/
|
||||
export async function findReusableSessions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
activeCompanyId: string,
|
||||
): Promise<ReusableSession[]> {
|
||||
const nowIso = new Date().toISOString()
|
||||
|
||||
const { data: rows, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, company_id, bank_name, provider, session_id, psu_type, consent_expires, accounts_data')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'active')
|
||||
.neq('company_id', activeCompanyId)
|
||||
.not('session_id', 'is', null)
|
||||
.gt('consent_expires', nowIso)
|
||||
|
||||
if (error) {
|
||||
log.warn('reusable session lookup failed', { activeCompanyId, error: error.message })
|
||||
return []
|
||||
}
|
||||
if (!rows || rows.length === 0) return []
|
||||
|
||||
const claimedIbans = await fetchClaimedIbans(supabase)
|
||||
if (claimedIbans === null) {
|
||||
// Offer nothing rather than everything: see fetchClaimedIbans.
|
||||
return []
|
||||
}
|
||||
const ibanCarriers = await fetchIbanCarriers(supabase, userId)
|
||||
|
||||
const typed = rows as Array<{
|
||||
id: string
|
||||
company_id: string
|
||||
bank_name: string | null
|
||||
provider: string
|
||||
session_id: string
|
||||
psu_type: string | null
|
||||
consent_expires: string | null
|
||||
accounts_data: StoredAccount[] | null
|
||||
}>
|
||||
|
||||
const companyNames = await fetchCompanyNames(
|
||||
supabase,
|
||||
[...new Set(typed.map(r => r.company_id))],
|
||||
)
|
||||
|
||||
const sessions: ReusableSession[] = []
|
||||
for (const row of typed) {
|
||||
// A cash_accounts row is not the only way an account gets taken. Between
|
||||
// attaching a company and finishing its picker, the account is carried in
|
||||
// that company's accounts_data and nothing has claimed a ledger yet. Offer
|
||||
// it again in that window and two companies end up booking one physical
|
||||
// account, which is the failure this feature is supposed to prevent.
|
||||
const unavailable = new Set(claimedIbans)
|
||||
for (const [iban, carrierCompanyIds] of ibanCarriers) {
|
||||
for (const carrierCompanyId of carrierCompanyIds) {
|
||||
if (carrierCompanyId !== row.company_id) {
|
||||
unavailable.add(iban)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const availableAccounts = unclaimedAccountsFor(row.accounts_data ?? [], unavailable)
|
||||
if (availableAccounts.length === 0) continue
|
||||
sessions.push({
|
||||
connectionId: row.id,
|
||||
companyId: row.company_id,
|
||||
companyName: companyNames.get(row.company_id) ?? null,
|
||||
bankName: row.bank_name,
|
||||
provider: row.provider,
|
||||
sessionId: row.session_id,
|
||||
psuType: row.psu_type,
|
||||
consentExpires: row.consent_expires,
|
||||
availableAccounts,
|
||||
})
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
/**
|
||||
* Which companies currently carry each IBAN in their connection metadata,
|
||||
* whether or not a ledger has been mapped yet. This is what closes the window
|
||||
* between attaching a company and that company finishing its account picker.
|
||||
*/
|
||||
async function fetchIbanCarriers(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
): Promise<Map<string, Set<string>>> {
|
||||
const { data, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('company_id, accounts_data')
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['active', 'pending_selection'])
|
||||
|
||||
const carriers = new Map<string, Set<string>>()
|
||||
if (error) {
|
||||
log.warn('iban carrier lookup failed', { error: error.message })
|
||||
return carriers
|
||||
}
|
||||
|
||||
for (const row of (data ?? []) as Array<{ company_id: string; accounts_data: StoredAccount[] | null }>) {
|
||||
for (const account of row.accounts_data ?? []) {
|
||||
// Deselected accounts are not held. Freshly attached rows carry
|
||||
// everything as enabled, so the attach-to-picker window is still closed;
|
||||
// but once a company unchecks an account, it has to become available
|
||||
// again or the first company to look at it would block it forever.
|
||||
if (account.enabled === false) continue
|
||||
const iban = normalizeIban(account.iban)
|
||||
if (!iban) continue
|
||||
const existing = carriers.get(iban)
|
||||
if (existing) existing.add(row.company_id)
|
||||
else carriers.set(iban, new Set([row.company_id]))
|
||||
}
|
||||
}
|
||||
return carriers
|
||||
}
|
||||
|
||||
async function fetchCompanyNames(
|
||||
supabase: SupabaseClient,
|
||||
companyIds: readonly string[],
|
||||
): Promise<Map<string, string>> {
|
||||
if (companyIds.length === 0) return new Map()
|
||||
const { data, error } = await supabase
|
||||
.from('companies')
|
||||
.select('id, name')
|
||||
.in('id', [...companyIds])
|
||||
if (error) {
|
||||
log.warn('company name lookup failed', { error: error.message })
|
||||
return new Map()
|
||||
}
|
||||
return new Map(
|
||||
((data ?? []) as Array<{ id: string; name: string | null }>)
|
||||
.filter((c): c is { id: string; name: string } => !!c.name)
|
||||
.map(c => [c.id, c.name]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many OTHER connections still depend on this session.
|
||||
*
|
||||
* Must run on a service-role client. RLS would hide a sibling living in a
|
||||
* company the user has since left, and an invisible sibling reads as zero,
|
||||
* which is precisely the case where revoking kills a feed that is still in use.
|
||||
*
|
||||
* Counts every non-revoked sibling, including ones parked in 'expired' or
|
||||
* 'error'. The asymmetry is deliberate: leaving a consent un-revoked costs us
|
||||
* nothing but a row at Enable Banking that lapses on its own within 90 days,
|
||||
* while revoking one that another company is still syncing from takes down a
|
||||
* working bank feed with no warning.
|
||||
*/
|
||||
export async function countLiveSiblings(
|
||||
serviceSupabase: SupabaseClient,
|
||||
sessionId: string,
|
||||
excludeConnectionId: string,
|
||||
): Promise<number> {
|
||||
const { count, error } = await serviceSupabase
|
||||
.from('bank_connections')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('session_id', sessionId)
|
||||
.neq('id', excludeConnectionId)
|
||||
.neq('status', 'revoked')
|
||||
|
||||
if (error) {
|
||||
log.error('sibling count failed, treating session as shared', {
|
||||
sessionId: '[REDACTED]',
|
||||
error: error.message,
|
||||
})
|
||||
// Fail closed again: pretend a sibling exists rather than revoke a session
|
||||
// we could not prove is unshared.
|
||||
return 1
|
||||
}
|
||||
return count ?? 0
|
||||
}
|
||||
|
||||
export interface SessionRenewalResult {
|
||||
/** Sibling rows moved onto the new session. */
|
||||
movedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a company's stored accounts at the uids the renewed session issued.
|
||||
*
|
||||
* Several ASPSPs mint fresh account uids on every re-authorization. The company
|
||||
* that clicked "renew" gets remapped by the callback's own IBAN matching, but a
|
||||
* sibling still holds the previous session's uids, and
|
||||
* GET /accounts/{uid}/transactions against a superseded uid fails. So the
|
||||
* quarterly renewal would keep breaking exactly the companies this feature
|
||||
* exists to keep alive, one layer further down.
|
||||
*
|
||||
* The sibling's own choices (which accounts are enabled, which ledger each
|
||||
* books to) are preserved: only the uid moves. An account whose IBAN is absent
|
||||
* from the new session is left untouched rather than dropped, since silently
|
||||
* discarding a mapped account is worse than a visible sync error.
|
||||
*/
|
||||
export function remapAccountUids(
|
||||
accounts: readonly StoredAccount[],
|
||||
sessionAccounts: readonly { uid: string; iban?: string | null }[],
|
||||
): { accounts: StoredAccount[]; remapped: number; unmatched: number } {
|
||||
const uidByIban = new Map<string, string>()
|
||||
for (const account of sessionAccounts) {
|
||||
const iban = normalizeIban(account.iban)
|
||||
if (iban) uidByIban.set(iban, account.uid)
|
||||
}
|
||||
|
||||
let remapped = 0
|
||||
let unmatched = 0
|
||||
const out = accounts.map(account => {
|
||||
const iban = normalizeIban(account.iban)
|
||||
const newUid = iban ? uidByIban.get(iban) : undefined
|
||||
if (!newUid) {
|
||||
unmatched += 1
|
||||
return account
|
||||
}
|
||||
if (newUid === account.uid) return account
|
||||
remapped += 1
|
||||
return { ...account, uid: newUid }
|
||||
})
|
||||
|
||||
return { accounts: out, remapped, unmatched }
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry a renewed consent across to every company sharing the old session.
|
||||
*
|
||||
* One re-authorization is what the user performed, so one re-authorization is
|
||||
* what every sharing company gets. Without this the other companies would keep
|
||||
* pointing at the session the bank just replaced and would fail on their next
|
||||
* sync, which is the original problem wearing a different hat.
|
||||
*
|
||||
* Siblings parked in 'expired'/'error' are revived to 'active' (a live session
|
||||
* is exactly what they were missing); 'active' and 'pending_selection' rows
|
||||
* keep their status, since pending_selection means the user still owes that
|
||||
* company an account selection.
|
||||
*/
|
||||
export async function fanOutSessionRenewal(
|
||||
supabase: SupabaseClient,
|
||||
input: {
|
||||
oldSessionId: string
|
||||
newSessionId: string
|
||||
consentExpires: string | null
|
||||
excludeConnectionId: string
|
||||
/** Accounts the renewed session returned, for remapping sibling uids. */
|
||||
sessionAccounts?: readonly { uid: string; iban?: string | null }[]
|
||||
},
|
||||
): Promise<SessionRenewalResult> {
|
||||
const { oldSessionId, newSessionId, consentExpires, excludeConnectionId, sessionAccounts } = input
|
||||
if (!oldSessionId || oldSessionId === newSessionId) return { movedCount: 0 }
|
||||
|
||||
const { data: siblings, error: siblingError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, status, accounts_data')
|
||||
.eq('session_id', oldSessionId)
|
||||
.neq('id', excludeConnectionId)
|
||||
.neq('status', 'revoked')
|
||||
|
||||
if (siblingError) {
|
||||
log.error('failed to load siblings for session renewal', { error: siblingError.message })
|
||||
return { movedCount: 0 }
|
||||
}
|
||||
if (!siblings || siblings.length === 0) return { movedCount: 0 }
|
||||
|
||||
let movedCount = 0
|
||||
for (const sibling of siblings as Array<{
|
||||
id: string
|
||||
status: string
|
||||
accounts_data: StoredAccount[] | null
|
||||
}>) {
|
||||
// Payloads stay object literals (never a built-up Record) so the
|
||||
// no-phantom-columns guard can actually verify the column names.
|
||||
// A session was the only thing a dead sibling was missing, so bring it
|
||||
// back. 'pending_selection' is left alone: that company still owes an
|
||||
// account selection, and flipping it to active would skip the picker.
|
||||
const isDead = sibling.status === 'expired' || sibling.status === 'error'
|
||||
const { error: updateError } = isDead
|
||||
? await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
session_id: newSessionId,
|
||||
consent_expires: consentExpires,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
})
|
||||
.eq('id', sibling.id)
|
||||
: await supabase
|
||||
.from('bank_connections')
|
||||
.update({ session_id: newSessionId, consent_expires: consentExpires })
|
||||
.eq('id', sibling.id)
|
||||
|
||||
if (updateError) {
|
||||
log.error('failed to move sibling onto renewed session', {
|
||||
connectionId: sibling.id,
|
||||
error: updateError.message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
movedCount += 1
|
||||
|
||||
// Re-pointing the uids is a separate write, and deliberately so: it only
|
||||
// happens when the ASPSP actually reissued them, and keeping it out of the
|
||||
// payload above means neither write needs a dynamically built object.
|
||||
if (sessionAccounts && sessionAccounts.length > 0) {
|
||||
const { accounts, remapped, unmatched } = remapAccountUids(
|
||||
sibling.accounts_data ?? [],
|
||||
sessionAccounts,
|
||||
)
|
||||
if (unmatched > 0) {
|
||||
// The renewed consent no longer covers an account this company books
|
||||
// to. Worth surfacing: it usually means the user unticked it at the
|
||||
// bank, and that company's next sync will report the gap.
|
||||
log.warn('renewed session does not cover every account a company uses', {
|
||||
connectionId: sibling.id,
|
||||
unmatched,
|
||||
})
|
||||
}
|
||||
if (remapped > 0) {
|
||||
const { error: remapError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({ accounts_data: accounts })
|
||||
.eq('id', sibling.id)
|
||||
if (remapError) {
|
||||
log.error('failed to re-point sibling accounts at the renewed session', {
|
||||
connectionId: sibling.id,
|
||||
error: remapError.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (movedCount > 0) {
|
||||
log.info('renewed session carried to sibling connections', {
|
||||
movedCount,
|
||||
excludeConnectionId,
|
||||
})
|
||||
}
|
||||
return { movedCount }
|
||||
}
|
||||
@@ -95,6 +95,7 @@ describe('gnubok_get_trial_balance: dimensions filter', () => {
|
||||
}
|
||||
|
||||
expect(mockTrialBalance).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', {
|
||||
closingEntry: 'include',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
expect(result.dimension_filter).toEqual({ '6': 'P001' })
|
||||
@@ -117,7 +118,9 @@ describe('gnubok_get_trial_balance: dimensions filter', () => {
|
||||
supabase as never,
|
||||
)) as Record<string, unknown>
|
||||
|
||||
expect(mockTrialBalance).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', undefined)
|
||||
expect(mockTrialBalance).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', {
|
||||
closingEntry: 'include',
|
||||
})
|
||||
expect(result).not.toHaveProperty('dimension_filter')
|
||||
expect(result).not.toHaveProperty('dimension_resolutions')
|
||||
// Zero registry queries when nothing is tagged.
|
||||
|
||||
@@ -4875,7 +4875,10 @@ export const tools: McpTool[] = [
|
||||
supabase,
|
||||
companyId,
|
||||
periodId!,
|
||||
dimFilter.filter ? { dimensions: dimFilter.filter } : undefined,
|
||||
// Saldobalans is the ledger as posted, resultatavslut included.
|
||||
dimFilter.filter
|
||||
? { closingEntry: 'include' as const, dimensions: dimFilter.filter }
|
||||
: { closingEntry: 'include' as const },
|
||||
)
|
||||
|
||||
const rows = trialBalance.rows
|
||||
@@ -5081,7 +5084,7 @@ export const tools: McpTool[] = [
|
||||
const [incomeStatement, trialBalance, arLedger, monthlyBreakdown, paidInvoices] =
|
||||
await Promise.all([
|
||||
generateIncomeStatement(supabase, companyId, periodId!),
|
||||
generateTrialBalance(supabase, companyId, periodId!),
|
||||
generateTrialBalance(supabase, companyId, periodId!, { closingEntry: 'include' }),
|
||||
generateARLedger(supabase, companyId),
|
||||
generateMonthlyBreakdown(supabase, companyId, periodId!),
|
||||
supabase
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
+2
-1
@@ -1681,8 +1681,9 @@
|
||||
"settings_account_danger": {
|
||||
"heading": "Delete account",
|
||||
"blockers_title": "Companies you own",
|
||||
"blockers_description": "Delete or hand over all companies before you delete your account.",
|
||||
"blockers_description": "The account can only be deleted once you have deleted or handed over every company you own.",
|
||||
"blockers_manage": "Manage",
|
||||
"blocked_reason": "You can delete the account once you have deleted or handed over {count, plural, =1 {your company} other {your # companies}}.",
|
||||
"support_question": "Questions?",
|
||||
"support_subject": "Question about account deletion",
|
||||
"export_sie": "Export accounting data (SIE)",
|
||||
|
||||
+2
-1
@@ -1681,8 +1681,9 @@
|
||||
"settings_account_danger": {
|
||||
"heading": "Radera konto",
|
||||
"blockers_title": "Företag du äger",
|
||||
"blockers_description": "Radera eller överlåt alla företag innan du raderar kontot.",
|
||||
"blockers_description": "Kontot kan raderas först när du har raderat eller överlåtit alla företag du äger.",
|
||||
"blockers_manage": "Hantera",
|
||||
"blocked_reason": "Du kan radera kontot först när du har raderat eller överlåtit {count, plural, =1 {företaget} other {dina # företag}}.",
|
||||
"support_question": "Har du frågor?",
|
||||
"support_subject": "Fråga om kontoradering",
|
||||
"export_sie": "Exportera bokföringsdata (SIE)",
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
]
|
||||
},
|
||||
"naiveOreRound": {
|
||||
"count": 646
|
||||
"count": 641
|
||||
},
|
||||
"ledgerScanningReports": {
|
||||
"count": 4,
|
||||
"files": [
|
||||
"lib/bokslut/assets/asset-service.ts",
|
||||
"lib/bokslut/reserves/periodiseringsfond-service.ts",
|
||||
"lib/bokslut/tax-provision/bolagsskatt-calculator.ts",
|
||||
"lib/bokslut/tax-provision/sarskild-loneskatt-calculator.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,18 @@
|
||||
* lineDimensionColumns() from the dimensions JSONB map
|
||||
* (lib/bookkeeping/dimension-resolver.ts): a new direct insert site can
|
||||
* silently diverge the mirror columns. Tracked as a file-set.
|
||||
* 3b. ledger-scanning-report: a statement generator under lib/reports or
|
||||
* lib/bokslut that aggregates `journal_entry_lines` itself instead of
|
||||
* going through generateTrialBalance. Aggregating raw lines means
|
||||
* remembering, per report, that the resultatavslut posts the mirror image
|
||||
* of every P&L account into 2099 inside the same fiscal period. Three
|
||||
* reports forgot (årsredovisning 2026-07-23, INK2R and NE-bilaga
|
||||
* 2026-07-29) 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 turns the decision
|
||||
* into a compile error; this guard keeps new reports on that path.
|
||||
* Tracked as a file-set. Voucher/line LISTINGS are sanctioned in
|
||||
* LEDGER_SCAN_SANCTIONED: they have no closingEntry decision to make.
|
||||
* 4. pinned-dep : a dependency pinned to an exact version (PINNED_DEPS)
|
||||
* whose package.json spec or locked version drifted from the pin. Guards
|
||||
* against a repeat of the @anthropic-ai/bedrock-sdk 0.32.0 prod outage
|
||||
@@ -135,6 +147,72 @@ function findDirectJelInserts() {
|
||||
.sort()
|
||||
}
|
||||
|
||||
// Statement generators that legitimately read journal_entry_lines directly:
|
||||
// the trial-balance stack itself, and the reports whose whole job is to list
|
||||
// vouchers or lines rather than to aggregate a fiscal year's balances.
|
||||
const LEDGER_SCAN_SANCTIONED = new Set([
|
||||
// The shared balance source and its helpers.
|
||||
'lib/reports/trial-balance.ts',
|
||||
'lib/reports/opening-balances.ts',
|
||||
// Voucher/line listings: they must show the ledger as posted, closing
|
||||
// verifikat included, so there is no closingEntry decision to get wrong.
|
||||
'lib/reports/general-ledger.ts',
|
||||
'lib/reports/journal-register.ts',
|
||||
'lib/reports/latest-vouchers.ts',
|
||||
'lib/reports/source-lines.ts',
|
||||
'lib/reports/sie-export.ts',
|
||||
'lib/reports/full-archive-export.ts',
|
||||
// Aggregate their own dimension-tagged or month-bucketed slice, and each
|
||||
// carries an explicit year-end exclusion of its own.
|
||||
'lib/reports/dimension-pnl.ts',
|
||||
'lib/reports/monthly-breakdown.ts',
|
||||
// Reconciliation and diagnostics: they compare against the ledger as posted.
|
||||
'lib/reports/ar-reconciliation.ts',
|
||||
'lib/reports/supplier-reconciliation.ts',
|
||||
'lib/reports/reskontra-payments.ts',
|
||||
'lib/reports/imbalance-diagnosis.ts',
|
||||
'lib/reports/continuity-check.ts',
|
||||
'lib/reports/rc-basis-gaps.ts',
|
||||
'lib/reports/vat-settlement.ts',
|
||||
'lib/reports/vat-declaration.ts',
|
||||
'lib/reports/periodisk-sammanstallning.ts',
|
||||
'lib/reports/avgifter-basis.ts',
|
||||
'lib/reports/salary-journal.ts',
|
||||
'lib/reports/vacation-liability.ts',
|
||||
])
|
||||
|
||||
const LEDGER_SCAN_RE =
|
||||
/\.from\(\s*['"]journal_entry_lines['"]\s*\)|fetchEntryLines\s*[<(]|lines:\s*journal_entry_lines\(/
|
||||
|
||||
/**
|
||||
* Statement generators that scan journal_entry_lines instead of going through
|
||||
* generateTrialBalance.
|
||||
*
|
||||
* WHY: a generator that aggregates a fiscal year's balances from raw lines has
|
||||
* to remember, on its own, that the resultatavslut posts the mirror image of
|
||||
* every P&L account into 2099 inside the same period. Three shipped without
|
||||
* remembering (årsredovisning 2026-07-23, INK2R and NE-bilaga 2026-07-29) and
|
||||
* each reported ZERO revenue for a closed year while the balance sheet still
|
||||
* tied out, so nothing warned. generateTrialBalance now REQUIRES a
|
||||
* closingEntry mode, which makes the decision a compile error instead: this
|
||||
* guard is what keeps new generators on that path.
|
||||
*/
|
||||
function findLedgerScanningReports() {
|
||||
const files = [
|
||||
...walk(path.join(ROOT, 'lib', 'reports'), ['.ts']),
|
||||
...walk(path.join(ROOT, 'lib', 'bokslut'), ['.ts']),
|
||||
]
|
||||
return files
|
||||
.filter((f) => {
|
||||
const r = rel(f)
|
||||
if (LEDGER_SCAN_SANCTIONED.has(r)) return false
|
||||
if (r.includes('__tests__/') || r.endsWith('.test.ts')) return false
|
||||
return LEDGER_SCAN_RE.test(fs.readFileSync(f, 'utf8'))
|
||||
})
|
||||
.map(rel)
|
||||
.sort()
|
||||
}
|
||||
|
||||
/** Count of naive Math.round(x*100)/100 occurrences (lines) across source. */
|
||||
function countNaiveRound() {
|
||||
const files = [
|
||||
@@ -485,6 +563,7 @@ function findRawUserErrors() {
|
||||
const current = {
|
||||
rawRouteAuth: findRawRouteAuth(),
|
||||
naiveOreRound: countNaiveRound(),
|
||||
ledgerScanningReports: findLedgerScanningReports(),
|
||||
directJelInsert: findDirectJelInserts(),
|
||||
pinnedDepViolations: findPinnedDepViolations(),
|
||||
rawUserErrors: findRawUserErrors(),
|
||||
@@ -500,6 +579,10 @@ if (isUpdate) {
|
||||
'Ratchet baseline for scripts/checks/no-new-antipatterns.mjs. These counts may only decrease. Re-run with --update after a migration lowers them. Goal: both reach 0 (A1 route-auth campaign, D1 rounding codemod).',
|
||||
rawRouteAuth: { count: current.rawRouteAuth.length, files: current.rawRouteAuth },
|
||||
naiveOreRound: { count: current.naiveOreRound },
|
||||
ledgerScanningReports: {
|
||||
count: current.ledgerScanningReports.length,
|
||||
files: current.ledgerScanningReports,
|
||||
},
|
||||
}
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n')
|
||||
console.log(
|
||||
@@ -633,6 +716,29 @@ if (newUngatedRoutes.length) {
|
||||
)
|
||||
}
|
||||
|
||||
// 1c. ledger-scanning-report: any statement generator not in the baseline set
|
||||
// is a NEW violation. Grandfathered files stay until they migrate.
|
||||
const ledgerScanBaseline = new Set(baseline.ledgerScanningReports?.files ?? [])
|
||||
const newLedgerScans = current.ledgerScanningReports.filter((f) => !ledgerScanBaseline.has(f))
|
||||
const fixedLedgerScans = (baseline.ledgerScanningReports?.files ?? []).filter(
|
||||
(f) => !current.ledgerScanningReports.includes(f),
|
||||
)
|
||||
if (newLedgerScans.length) {
|
||||
failed = true
|
||||
console.error(
|
||||
`\n✗ ledger-scanning-report: ${newLedgerScans.length} statement generator(s) aggregate ` +
|
||||
`journal_entry_lines directly instead of going through generateTrialBalance:`,
|
||||
)
|
||||
newLedgerScans.forEach((f) => console.error(` ${f}`))
|
||||
console.error(
|
||||
' → call generateTrialBalance with an explicit closingEntry mode. Aggregating raw\n' +
|
||||
' lines means remembering the resultatavslut yourself, and three reports already\n' +
|
||||
' forgot (each read ZERO revenue for a closed year while the balance sheet still\n' +
|
||||
' tied out, so nothing warned). If the report genuinely lists vouchers rather\n' +
|
||||
' than balances, add it to LEDGER_SCAN_SANCTIONED in this file with a reason.',
|
||||
)
|
||||
}
|
||||
|
||||
// 2. naive-ore-round: count may not increase.
|
||||
if (current.naiveOreRound > baseline.naiveOreRound.count) {
|
||||
failed = true
|
||||
@@ -644,9 +750,11 @@ if (current.naiveOreRound > baseline.naiveOreRound.count) {
|
||||
}
|
||||
|
||||
// Report ratchet-down progress (informational, never fails).
|
||||
if (fixedAuthFiles.length || current.naiveOreRound < baseline.naiveOreRound.count) {
|
||||
if (fixedAuthFiles.length || fixedLedgerScans.length || current.naiveOreRound < baseline.naiveOreRound.count) {
|
||||
console.log('\n✓ Progress since baseline:')
|
||||
if (fixedAuthFiles.length) console.log(` raw-route-auth: -${fixedAuthFiles.length} file(s)`)
|
||||
if (fixedLedgerScans.length)
|
||||
console.log(` ledger-scanning-report: -${fixedLedgerScans.length} file(s)`)
|
||||
if (current.naiveOreRound < baseline.naiveOreRound.count)
|
||||
console.log(` naive-ore-round: -${baseline.naiveOreRound.count - current.naiveOreRound} occurrence(s)`)
|
||||
console.log(' Run with --update to ratchet the baseline down and lock in the gains.')
|
||||
@@ -664,5 +772,5 @@ if (failed) {
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(
|
||||
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, direct-jel-insert: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted).`,
|
||||
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted).`,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
-- get_vat_declaration_totals: exclude the fiscal period's resultatavslut.
|
||||
--
|
||||
-- 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. On a real production ledger this produced
|
||||
-- ruta 39 = -794 734 kr for the December period of a closed year.
|
||||
--
|
||||
-- Verified reproducible read-only against production before this change.
|
||||
--
|
||||
-- Only a POSTED closing entry is dropped. A reversed one is retained together
|
||||
-- with its storno so the pair continues to net to zero: the same predicate
|
||||
-- lib/reports/trial-balance.ts uses for closingEntry: 'exclude-final'.
|
||||
--
|
||||
-- vat_settlement and opening_balance were already excluded; year_end was not.
|
||||
-- The exclusion is keyed on fiscal_periods.closing_entry_id rather than on
|
||||
-- source_type = 'year_end' because avskrivningar, periodiseringsfond and skatt
|
||||
-- share that source_type and must keep whatever VAT effect they carry.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_vat_declaration_totals(
|
||||
p_company_id uuid,
|
||||
p_start date,
|
||||
p_end date,
|
||||
p_accounts text[],
|
||||
p_ruta_accounts text[],
|
||||
p_net_accounts text[]
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
WITH closing_entries AS (
|
||||
SELECT fp.closing_entry_id AS id
|
||||
FROM public.fiscal_periods fp
|
||||
WHERE fp.company_id = p_company_id
|
||||
AND fp.closing_entry_id IS NOT NULL
|
||||
),
|
||||
scoped_entries AS (
|
||||
SELECT e.id, e.status, e.entry_date, e.source_type, e.voucher_series, e.voucher_number
|
||||
FROM public.journal_entries e
|
||||
WHERE e.company_id = p_company_id
|
||||
AND e.status IN ('posted', 'reversed')
|
||||
AND e.entry_date >= p_start
|
||||
AND e.entry_date <= p_end
|
||||
AND NOT (
|
||||
e.status = 'posted'
|
||||
AND EXISTS (SELECT 1 FROM closing_entries c WHERE c.id = e.id)
|
||||
)
|
||||
),
|
||||
non_settlement_entries AS (
|
||||
SELECT * FROM scoped_entries
|
||||
WHERE source_type IS DISTINCT FROM 'vat_settlement'
|
||||
),
|
||||
vat_lines AS (
|
||||
SELECT l.journal_entry_id, l.account_number, l.debit_amount, l.credit_amount
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN non_settlement_entries e ON e.id = l.journal_entry_id
|
||||
WHERE l.account_number = ANY (p_accounts)
|
||||
),
|
||||
shaped AS (
|
||||
SELECT e.id, e.status, e.entry_date, e.source_type, e.voucher_series, e.voucher_number
|
||||
FROM non_settlement_entries e
|
||||
WHERE e.source_type IS DISTINCT FROM 'opening_balance'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM vat_lines l
|
||||
WHERE l.journal_entry_id = e.id AND l.account_number = ANY (p_ruta_accounts)
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM vat_lines l
|
||||
WHERE l.journal_entry_id = e.id AND l.account_number = ANY (p_net_accounts)
|
||||
)
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'totals', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'account_number', t.account_number,
|
||||
'debit', t.debit,
|
||||
'credit', t.credit
|
||||
) ORDER BY t.account_number)
|
||||
FROM (
|
||||
SELECT l.account_number,
|
||||
sum(l.debit_amount)::float8 AS debit,
|
||||
sum(l.credit_amount)::float8 AS credit
|
||||
FROM vat_lines l
|
||||
WHERE NOT EXISTS (SELECT 1 FROM shaped s WHERE s.id = l.journal_entry_id)
|
||||
GROUP BY l.account_number
|
||||
) t
|
||||
), '[]'::jsonb),
|
||||
'settlement_shaped_entries', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', s.id,
|
||||
'status', s.status,
|
||||
'entry_date', s.entry_date,
|
||||
'source_type', s.source_type,
|
||||
'voucher_series', s.voucher_series,
|
||||
'voucher_number', s.voucher_number
|
||||
) ORDER BY s.entry_date, s.id)
|
||||
FROM shaped s
|
||||
), '[]'::jsonb),
|
||||
'source_type_counts', COALESCE((
|
||||
SELECT jsonb_object_agg(COALESCE(c.source_type, ''), c.n)
|
||||
FROM (
|
||||
SELECT source_type, count(*)::int AS n
|
||||
FROM scoped_entries
|
||||
GROUP BY source_type
|
||||
) c
|
||||
), '{}'::jsonb)
|
||||
)
|
||||
$function$;
|
||||
@@ -0,0 +1,165 @@
|
||||
-- get_kpi_report_aggregates: keep the resultatavslut out of the monthly chart.
|
||||
--
|
||||
-- The 'monthly' section joined period_entries, i.e. 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 reported the
|
||||
-- whole year's revenue as negative income on the KPI chart.
|
||||
--
|
||||
-- Measured on production before this change: 28 companies affected across 34
|
||||
-- month-rows, worst case a single month's income understated by 10 347 472 kr.
|
||||
--
|
||||
-- 20260723180000 documented the omission as deliberate ("year_end entries are
|
||||
-- NOT excluded here: the JS scan never excluded them either"). That mirrored
|
||||
-- lib/reports/monthly-breakdown.ts faithfully, but the JS scan was itself
|
||||
-- wrong; both are corrected together so the RPC path and the dimension-filtered
|
||||
-- fallback keep agreeing.
|
||||
--
|
||||
-- tb_ex_ye_entries already exists in this function for the tb_ex_year_end
|
||||
-- section, so the fix reuses it rather than inventing a third convention: it
|
||||
-- drops source_type year_end plus the stornos and corrections of REVERSED
|
||||
-- year-end entries (the undone-year-end chain).
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_kpi_report_aggregates(
|
||||
p_company_id uuid,
|
||||
p_fiscal_period_id uuid,
|
||||
p_ob_entry_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY INVOKER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
WITH period_entries AS (
|
||||
SELECT id, entry_date, status, source_type, reverses_id, correction_of_id
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = p_fiscal_period_id
|
||||
AND status IN ('posted', 'reversed')
|
||||
),
|
||||
tb_entries AS (
|
||||
SELECT * FROM period_entries
|
||||
WHERE p_ob_entry_id IS NULL OR id <> p_ob_entry_id
|
||||
),
|
||||
ye_reversed AS (
|
||||
-- Company-wide (no period filter), mirroring the wave-1 fetch in
|
||||
-- lib/reports/trial-balance.ts: a storno in this period can reverse a
|
||||
-- year-end entry from another period.
|
||||
SELECT id
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND source_type = 'year_end'
|
||||
AND status = 'reversed'
|
||||
),
|
||||
tb_ex_ye_entries AS (
|
||||
SELECT * FROM tb_entries
|
||||
WHERE source_type IS DISTINCT FROM 'year_end'
|
||||
AND (reverses_id IS NULL
|
||||
OR reverses_id NOT IN (SELECT id FROM ye_reversed))
|
||||
AND (correction_of_id IS NULL
|
||||
OR correction_of_id NOT IN (SELECT id FROM ye_reversed))
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'tb', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'account_number', t.account_number,
|
||||
'debit', t.debit,
|
||||
'credit', t.credit
|
||||
) ORDER BY t.account_number)
|
||||
FROM (
|
||||
SELECT l.account_number,
|
||||
sum(l.debit_amount)::float8 AS debit,
|
||||
sum(l.credit_amount)::float8 AS credit
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN tb_entries e ON e.id = l.journal_entry_id
|
||||
GROUP BY l.account_number
|
||||
) t
|
||||
), '[]'::jsonb),
|
||||
'tb_ex_year_end', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'account_number', t.account_number,
|
||||
'debit', t.debit,
|
||||
'credit', t.credit
|
||||
) ORDER BY t.account_number)
|
||||
FROM (
|
||||
SELECT l.account_number,
|
||||
sum(l.debit_amount)::float8 AS debit,
|
||||
sum(l.credit_amount)::float8 AS credit
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN tb_ex_ye_entries e ON e.id = l.journal_entry_id
|
||||
GROUP BY l.account_number
|
||||
) t
|
||||
), '[]'::jsonb),
|
||||
'ob', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'account_number', t.account_number,
|
||||
'debit', t.debit,
|
||||
'credit', t.credit
|
||||
) ORDER BY t.account_number)
|
||||
FROM (
|
||||
-- No status filter: getOpeningBalances only checks id + company_id.
|
||||
SELECT l.account_number,
|
||||
sum(l.debit_amount)::float8 AS debit,
|
||||
sum(l.credit_amount)::float8 AS credit
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN public.journal_entries e
|
||||
ON e.id = l.journal_entry_id
|
||||
AND e.id = p_ob_entry_id
|
||||
AND e.company_id = p_company_id
|
||||
GROUP BY l.account_number
|
||||
) t
|
||||
), '[]'::jsonb),
|
||||
'monthly', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'year', m.year,
|
||||
'month', m.month,
|
||||
'income', m.income,
|
||||
'expenses', m.expenses
|
||||
) ORDER BY m.year, m.month)
|
||||
FROM (
|
||||
SELECT EXTRACT(YEAR FROM e.entry_date)::int AS year,
|
||||
EXTRACT(MONTH FROM e.entry_date)::int AS month,
|
||||
(
|
||||
COALESCE(sum(CASE
|
||||
WHEN l.account_number ~ '^3'
|
||||
THEN l.credit_amount - l.debit_amount
|
||||
END), 0)
|
||||
+ COALESCE(sum(CASE
|
||||
WHEN l.account_number ~ '^8'
|
||||
AND l.account_number <> '8999'
|
||||
AND (l.credit_amount - l.debit_amount) >= 0
|
||||
THEN l.credit_amount - l.debit_amount
|
||||
END), 0)
|
||||
)::float8 AS income,
|
||||
(
|
||||
COALESCE(sum(CASE
|
||||
WHEN l.account_number ~ '^[4-7]'
|
||||
THEN l.debit_amount - l.credit_amount
|
||||
END), 0)
|
||||
+ COALESCE(sum(CASE
|
||||
WHEN l.account_number ~ '^8'
|
||||
AND l.account_number <> '8999'
|
||||
AND (l.credit_amount - l.debit_amount) < 0
|
||||
THEN l.debit_amount - l.credit_amount
|
||||
END), 0)
|
||||
)::float8 AS expenses
|
||||
FROM public.journal_entry_lines l
|
||||
-- Previously joined every posted entry in the period. The resultatavslut
|
||||
-- posts the mirror image of every P&L account, so the fiscal-year-end
|
||||
-- month showed the whole year's revenue as NEGATIVE income. Joining the
|
||||
-- year-end-excluded set instead keeps the chart on operating activity,
|
||||
-- and matches lib/reports/monthly-breakdown.ts.
|
||||
JOIN tb_ex_ye_entries e
|
||||
ON e.id = l.journal_entry_id
|
||||
AND e.status = 'posted'
|
||||
WHERE l.account_number ~ '^[3-8]'
|
||||
GROUP BY 1, 2
|
||||
) m
|
||||
), '[]'::jsonb)
|
||||
)
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.get_kpi_report_aggregates(uuid, uuid, uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.get_kpi_report_aggregates(uuid, uuid, uuid) TO authenticated, service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -16,7 +16,10 @@
|
||||
* - ob sums the OB entry's lines with NO status filter (mirrors
|
||||
* getOpeningBalances) but is company-guarded;
|
||||
* - monthly is posted-only, classes 3-8, 8999 excluded, class 8 split
|
||||
* per line by the sign of credit - debit;
|
||||
* per line by the sign of credit - debit, and (since migration
|
||||
* 20260730090000) it shares tb_ex_year_end's entry set so the
|
||||
* resultatavslut cannot chart the whole year's revenue as negative
|
||||
* income in the fiscal-year-end month;
|
||||
* - SECURITY INVOKER: a non-member gets empty sections under RLS.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
@@ -308,12 +311,18 @@ describe('get_kpi_report_aggregates RPC', () => {
|
||||
expect(monthOf(payload, 2026, 2)).toMatchObject({ income: 0, expenses: 3000 })
|
||||
// March: 8310 credit 200 -> income; 8410 debit 500 -> expenses.
|
||||
expect(monthOf(payload, 2026, 3)).toMatchObject({ income: 200, expenses: 500 })
|
||||
// December: year_end entries are NOT excluded from monthly. 8910 debit
|
||||
// 1000 -> expenses; posted storno's 8999 credit excluded by account; the
|
||||
// correction's 6200 debit 250 -> expenses. Total 1250.
|
||||
expect(monthOf(payload, 2026, 12)).toMatchObject({ income: 0, expenses: 1250 })
|
||||
// No phantom months.
|
||||
expect(payload.monthly.map((m) => m.month).sort((a, b) => a - b)).toEqual([1, 2, 3, 12])
|
||||
// December: year_end entries ARE excluded from monthly as of migration
|
||||
// 20260730090000. Previously this month reported expenses 1250 (8910 debit
|
||||
// 1000 from the posted year_end entry plus the correction's 6200 debit
|
||||
// 250). Including them meant the resultatavslut charted the whole year's
|
||||
// revenue as NEGATIVE income in the fiscal-year-end month: measured on
|
||||
// production as 28 companies, worst case -10 347 459,81 kr in one month.
|
||||
// This fixture's December holds ONLY year-end-chain entries, so the month
|
||||
// disappears from the chart entirely, which is the correct operational
|
||||
// view: a month whose only activity is bokslut has no operating result.
|
||||
expect(monthOf(payload, 2026, 12)).toBeUndefined()
|
||||
// No phantom months, and no bokslut-only months.
|
||||
expect(payload.monthly.map((m) => m.month).sort((a, b) => a - b)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('scopes to the requested company and returns empty sections for an empty one', async () => {
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* pg-real test for get_vat_declaration_totals: the resultatavslut must not
|
||||
* reach a 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
|
||||
* before migration 20260729110000 any VAT period containing the fiscal-year end
|
||||
* reported NEGATED turnover once the year was closed. On a production ledger
|
||||
* that produced ruta 39 = -794 734 kr for the December period of a closed year.
|
||||
*
|
||||
* Pinned here:
|
||||
* - a POSTED closing entry linked from fiscal_periods.closing_entry_id is
|
||||
* dropped, so the closing month reports nothing;
|
||||
* - the month with the real sale is untouched;
|
||||
* - a REVERSED closing entry is RETAINED together with its storno, so the
|
||||
* pair still nets to zero (same predicate as closingEntry: 'exclude-final'
|
||||
* in lib/reports/trial-balance.ts). Dropping only the reversed original
|
||||
* would leave the storno behind and negate turnover a second time;
|
||||
* - an ordinary year_end entry that is NOT the linked closing entry stays,
|
||||
* because avskrivningar, periodiseringsfond and skatt share that
|
||||
* source_type;
|
||||
* - the pre-existing vat_settlement exclusion still holds.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getPool } from './setup'
|
||||
import {
|
||||
insertAuthUser,
|
||||
insertCompany,
|
||||
insertCompanyMember,
|
||||
insertFiscalPeriod,
|
||||
} from './fixtures'
|
||||
|
||||
// Mirrors the arrays lib/reports/vat-declaration.ts passes in.
|
||||
//
|
||||
// p_ruta_accounts is the fixed ACCOUNT_RUTA key set, which holds revenue AND
|
||||
// VAT accounts. p_net_accounts is VAT_SETTLEMENT_NET_ACCOUNTS, the
|
||||
// momsredovisning settlement pair 2650/1650, and it is NOT the output-VAT
|
||||
// accounts: an entry carrying a line in p_ruta_accounts AND one in
|
||||
// p_net_accounts is classified a momsredovisning by SHAPE and dropped from the
|
||||
// totals entirely. An earlier draft put 2611 in p_net_accounts, which made an
|
||||
// ordinary sale-with-VAT look like a settlement and silently vanish.
|
||||
const RUTA_ACCOUNTS = ['3001', '3308', '2611', '2641']
|
||||
const NET_ACCOUNTS = ['2650', '1650']
|
||||
const ALL_ACCOUNTS = [...RUTA_ACCOUNTS, ...NET_ACCOUNTS]
|
||||
|
||||
interface Totals {
|
||||
totals: Array<{ account_number: string; debit: number; credit: number }>
|
||||
source_type_counts: Record<string, number>
|
||||
}
|
||||
|
||||
async function callRpc(companyId: string, start: string, end: string): Promise<Totals> {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT public.get_vat_declaration_totals($1, $2, $3, $4, $5, $6) AS payload`,
|
||||
[companyId, start, end, ALL_ACCOUNTS, RUTA_ACCOUNTS, NET_ACCOUNTS],
|
||||
)
|
||||
return rows[0].payload as Totals
|
||||
}
|
||||
|
||||
/** Net credit on an account, the orientation a revenue ruta reports. */
|
||||
function netCredit(payload: Totals, account: string): number {
|
||||
const row = payload.totals.find((t) => t.account_number === account)
|
||||
if (!row) return 0
|
||||
return Number(row.credit) - Number(row.debit)
|
||||
}
|
||||
|
||||
async function insertEntry(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
voucherNumber: number
|
||||
entryDate: string
|
||||
status?: 'posted' | 'reversed'
|
||||
sourceType?: string
|
||||
reversesId?: string | null
|
||||
lines: Array<{ account: string; debit: number; credit: number }>
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
// Inserted directly, bypassing commit_journal_entry's voucher sequencing:
|
||||
// this is a read-side aggregate that only reads lines and account numbers.
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status, reverses_id)
|
||||
VALUES ($1, $2, $3, $4, $5, 'A', $6, 'VAT closing-entry test', $7, $8, $9)`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.fiscalPeriodId,
|
||||
params.voucherNumber,
|
||||
params.entryDate,
|
||||
params.sourceType ?? 'manual',
|
||||
params.status ?? 'posted',
|
||||
params.reversesId ?? null,
|
||||
],
|
||||
)
|
||||
for (const line of params.lines) {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[id, line.account, line.debit, line.credit],
|
||||
)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* One EU-services sale in January, then a December resultatavslut that debits
|
||||
* the same revenue account: the shape that produced the production bug.
|
||||
*/
|
||||
async function seedClosedYear(closingStatus: 'posted' | 'reversed') {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId, role: 'owner' })
|
||||
// Left OPEN deliberately. enforce_period_lock (migration 017, legally
|
||||
// required) refuses any write into a closed period, so seeding entries into
|
||||
// one is impossible by design and must not be worked around. The RPC's
|
||||
// predicate keys on fiscal_periods.closing_entry_id and never reads
|
||||
// is_closed, so linking the closing entry below exercises the exact path
|
||||
// that matters without fighting a compliance trigger.
|
||||
const fiscalPeriodId = await insertFiscalPeriod({
|
||||
userId,
|
||||
companyId,
|
||||
periodStart: '2026-01-01',
|
||||
periodEnd: '2026-12-31',
|
||||
isClosed: false,
|
||||
})
|
||||
const ctx = { userId, companyId, fiscalPeriodId }
|
||||
|
||||
await insertEntry({
|
||||
...ctx,
|
||||
voucherNumber: 1,
|
||||
entryDate: '2026-01-16',
|
||||
sourceType: 'bank_transaction',
|
||||
lines: [
|
||||
{ account: '1930', debit: 800_000, credit: 0 },
|
||||
{ account: '3308', debit: 0, credit: 800_000 },
|
||||
],
|
||||
})
|
||||
|
||||
const closingEntryId = await insertEntry({
|
||||
...ctx,
|
||||
voucherNumber: 2,
|
||||
entryDate: '2026-12-31',
|
||||
sourceType: 'year_end',
|
||||
status: closingStatus,
|
||||
lines: [
|
||||
{ account: '3308', debit: 800_000, credit: 0 },
|
||||
{ account: '2099', debit: 0, credit: 800_000 },
|
||||
],
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`,
|
||||
[closingEntryId, fiscalPeriodId],
|
||||
)
|
||||
|
||||
return { ...ctx, closingEntryId }
|
||||
}
|
||||
|
||||
describe('get_vat_declaration_totals: year-end closing entry', () => {
|
||||
it('drops a posted resultatavslut from the closing month', async () => {
|
||||
const { companyId } = await seedClosedYear('posted')
|
||||
|
||||
const december = await callRpc(companyId, '2026-12-01', '2026-12-31')
|
||||
|
||||
// The regression: this reported -800 000 before the fix.
|
||||
expect(netCredit(december, '3308')).toBe(0)
|
||||
})
|
||||
|
||||
it('leaves the month with the real sale untouched', async () => {
|
||||
const { companyId } = await seedClosedYear('posted')
|
||||
|
||||
const january = await callRpc(companyId, '2026-01-01', '2026-01-31')
|
||||
|
||||
expect(netCredit(january, '3308')).toBe(800_000)
|
||||
})
|
||||
|
||||
it('reports the full year once, not zero, across the whole period', async () => {
|
||||
const { companyId } = await seedClosedYear('posted')
|
||||
|
||||
const wholeYear = await callRpc(companyId, '2026-01-01', '2026-12-31')
|
||||
|
||||
// With the closing entry in, sale and reversal cancelled to 0.
|
||||
expect(netCredit(wholeYear, '3308')).toBe(800_000)
|
||||
})
|
||||
|
||||
it('keeps a reversed closing entry so it still nets against its storno', async () => {
|
||||
const { companyId, fiscalPeriodId, closingEntryId, userId } =
|
||||
await seedClosedYear('reversed')
|
||||
|
||||
// Undo year-end: the closing entry is reversed and a posted storno mirrors
|
||||
// it. Both must be counted, or the storno alone negates turnover again.
|
||||
await insertEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
voucherNumber: 3,
|
||||
entryDate: '2026-12-31',
|
||||
sourceType: 'storno',
|
||||
reversesId: closingEntryId,
|
||||
lines: [
|
||||
{ account: '3308', debit: 0, credit: 800_000 },
|
||||
{ account: '2099', debit: 800_000, credit: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
const december = await callRpc(companyId, '2026-12-01', '2026-12-31')
|
||||
|
||||
// Reversed original (-800 000) + storno (+800 000) = 0.
|
||||
expect(netCredit(december, '3308')).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps year_end entries that are not the linked closing entry', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedClosedYear('posted')
|
||||
|
||||
// A bokslut entry sharing source_type 'year_end' but carrying real VAT.
|
||||
await insertEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
voucherNumber: 4,
|
||||
entryDate: '2026-12-30',
|
||||
sourceType: 'year_end',
|
||||
lines: [
|
||||
{ account: '3001', debit: 0, credit: 10_000 },
|
||||
{ account: '2611', debit: 0, credit: 2_500 },
|
||||
{ account: '1930', debit: 12_500, credit: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
const december = await callRpc(companyId, '2026-12-01', '2026-12-31')
|
||||
|
||||
expect(netCredit(december, '3001')).toBe(10_000)
|
||||
expect(netCredit(december, '2611')).toBe(2_500)
|
||||
})
|
||||
|
||||
it('still excludes vat_settlement entries', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedClosedYear('posted')
|
||||
|
||||
await insertEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
voucherNumber: 5,
|
||||
entryDate: '2026-01-20',
|
||||
sourceType: 'vat_settlement',
|
||||
lines: [
|
||||
{ account: '2611', debit: 5_000, credit: 0 },
|
||||
{ account: '1930', debit: 0, credit: 5_000 },
|
||||
],
|
||||
})
|
||||
|
||||
const january = await callRpc(companyId, '2026-01-01', '2026-01-31')
|
||||
|
||||
expect(netCredit(january, '2611')).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user