diff --git a/DECISIONS.md b/DECISIONS.md index a87154f5..a9766fdd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -662,6 +662,23 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index dbd0b91b..1825fdef 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -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 diff --git a/app/api/extensions/enable-banking/sync/cron/__tests__/route.test.ts b/app/api/extensions/enable-banking/sync/cron/__tests__/route.test.ts index 792d79fa..5f9449b3 100644 --- a/app/api/extensions/enable-banking/sync/cron/__tests__/route.test.ts +++ b/app/api/extensions/enable-banking/sync/cron/__tests__/route.test.ts @@ -14,7 +14,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' interface ClientState { active: Record[] probeCandidates: Record[] - updates: { id: unknown; payload: Record }[] + /** + * 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 }[] } 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 }, }, ]) diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 3c7f0ad4..e54dbcdb 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -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() + 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[number] + const probeGroups = new Map() + // 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, }) } } diff --git a/app/api/reports/kpi/__tests__/route.test.ts b/app/api/reports/kpi/__tests__/route.test.ts index 743b04e8..ff0951b5 100644 --- a/app/api/reports/kpi/__tests__/route.test.ts +++ b/app/api/reports/kpi/__tests__/route.test.ts @@ -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() diff --git a/app/api/reports/kpi/route.ts b/app/api/reports/kpi/route.ts index cbd29d77..ff5017ec 100644 --- a/app/api/reports/kpi/route.ts +++ b/app/api/reports/kpi/route.ts @@ -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 diff --git a/app/api/reports/kpi/xlsx/route.ts b/app/api/reports/kpi/xlsx/route.ts index 4ffaef13..46227af8 100644 --- a/app/api/reports/kpi/xlsx/route.ts +++ b/app/api/reports/kpi/xlsx/route.ts @@ -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 diff --git a/app/api/reports/statement-reconciliation/__tests__/route.test.ts b/app/api/reports/statement-reconciliation/__tests__/route.test.ts new file mode 100644 index 00000000..f52a2256 --- /dev/null +++ b/app/api/reports/statement-reconciliation/__tests__/route.test.ts @@ -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(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(res) + expect(body.isReconciled).toBe(false) + expect(body.disagreements[0]).toContain('konto 2099') + }) +}) diff --git a/app/api/reports/statement-reconciliation/route.ts b/app/api/reports/statement-reconciliation/route.ts new file mode 100644 index 00000000..c6b99b5e --- /dev/null +++ b/app/api/reports/statement-reconciliation/route.ts @@ -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 }, + ) + } + }, +) diff --git a/app/api/reports/trial-balance/route.ts b/app/api/reports/trial-balance/route.ts index 9b616503..9b4fdb14 100644 --- a/app/api/reports/trial-balance/route.ts +++ b/app/api/reports/trial-balance/route.ts @@ -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( diff --git a/app/api/reports/trial-balance/xlsx/route.ts b/app/api/reports/trial-balance/xlsx/route.ts index fe01acf1..11e3c32d 100644 --- a/app/api/reports/trial-balance/xlsx/route.ts +++ b/app/api/reports/trial-balance/xlsx/route.ts @@ -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([ { diff --git a/app/api/v1/companies/[companyId]/reports/trial-balance/route.ts b/app/api/v1/companies/[companyId]/reports/trial-balance/route.ts index 22b7d2b9..f2192728 100644 --- a/app/api/v1/companies/[companyId]/reports/trial-balance/route.ts +++ b/app/api/v1/companies/[companyId]/reports/trial-balance/route.ts @@ -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 diff --git a/components/reports/INK2DeclarationView.tsx b/components/reports/INK2DeclarationView.tsx index 746c2abc..d8bb9fe2 100644 --- a/components/reports/INK2DeclarationView.tsx +++ b/components/reports/INK2DeclarationView.tsx @@ -296,10 +296,10 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) { Årets resultat = 0 ? 'text-success' : 'text-destructive' + data.totals.aretsResultat >= 0 ? 'text-success' : 'text-destructive' }`} > - {formatWholeKronor(data.totals.resultAfterFinancial)} + {formatWholeKronor(data.totals.aretsResultat)} diff --git a/components/settings/AccountDangerZone.tsx b/components/settings/AccountDangerZone.tsx index 3cf5ef11..3afbbf34 100644 --- a/components/settings/AccountDangerZone.tsx +++ b/components/settings/AccountDangerZone.tsx @@ -185,6 +185,12 @@ export function AccountDangerZone() { {/* Live region always mounted so the failure is announced when it appears, not merely inserted. */}
+ {/* 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 && ( + {t('blocked_reason', { count: blockers.length })} + )} {loadError && ( 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')} diff --git a/extensions/general/enable-banking/__tests__/disconnect-route.test.ts b/extensions/general/enable-banking/__tests__/disconnect-route.test.ts index baa3242f..bbaca87b 100644 --- a/extensions/general/enable-banking/__tests__/disconnect-route.test.ts +++ b/extensions/general/enable-banking/__tests__/disconnect-route.test.ts @@ -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 = {} + 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 () => { diff --git a/extensions/general/enable-banking/__tests__/session-expired.test.ts b/extensions/general/enable-banking/__tests__/session-expired.test.ts index 7cebe00b..b447956a 100644 --- a/extensions/general/enable-banking/__tests__/session-expired.test.ts +++ b/extensions/general/enable-banking/__tests__/session-expired.test.ts @@ -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). diff --git a/extensions/general/enable-banking/__tests__/session-sharing.test.ts b/extensions/general/enable-banking/__tests__/session-sharing.test.ts new file mode 100644 index 00000000..c28dd83b --- /dev/null +++ b/extensions/general/enable-banking/__tests__/session-sharing.test.ts @@ -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): 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 } + +/** Per-table result queues; each from(table) shifts the next result. */ +function makeSupabase(queues: Record>>): MockClient { + const used: Record = {} + 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 { + 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') + }) +}) diff --git a/extensions/general/enable-banking/components/BankingSettingsPanel.tsx b/extensions/general/enable-banking/components/BankingSettingsPanel.tsx index 22831950..3f96117b 100644 --- a/extensions/general/enable-banking/components/BankingSettingsPanel.tsx +++ b/extensions/general/enable-banking/components/BankingSettingsPanel.tsx @@ -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([]) + const [attachingConnectionId, setAttachingConnectionId] = useState(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() { )} + {/* 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 && ( + +

+ Du har redan en giltig bankanslutning i ett annat bolag, och den ser konton + som inget bolag använder ännu. +

+

+ 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. +

+

+ 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. +

+
+ } + > + {reusableSessions.map((offer) => ( + + + Ansluten för{' '} + + {offer.company_name ?? 'ett annat bolag'} + + . {offer.available_account_count}{' '} + {offer.available_account_count === 1 ? 'ledigt konto' : 'lediga konton'} kan + kopplas till{' '} + + {company?.name ?? 'det här bolaget'} + {' '} + utan nytt BankID. + + + + + + ))} + + )} + {/* 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 diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index 24c6e93c..786da78e 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -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) { diff --git a/extensions/general/enable-banking/lib/session-sharing.ts b/extensions/general/enable-banking/lib/session-sharing.ts new file mode 100644 index 00000000..09c5cf89 --- /dev/null +++ b/extensions/general/enable-banking/lib/session-sharing.ts @@ -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, +): StoredAccount[] { + const out: StoredAccount[] = [] + const seen = new Set() + 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 | 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() + 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 { + 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>> { + 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>() + 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> { + 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 { + 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() + 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 { + 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 } +} diff --git a/extensions/general/mcp-server/__tests__/report-dimension-filter.test.ts b/extensions/general/mcp-server/__tests__/report-dimension-filter.test.ts index 72eebe67..72b79341 100644 --- a/extensions/general/mcp-server/__tests__/report-dimension-filter.test.ts +++ b/extensions/general/mcp-server/__tests__/report-dimension-filter.test.ts @@ -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 - 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. diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index a448eb60..6a5fc355 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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 diff --git a/lib/bokslut/accruals/accrual-detector.ts b/lib/bokslut/accruals/accrual-detector.ts index a000298b..89d678ea 100644 --- a/lib/bokslut/accruals/accrual-detector.ts +++ b/lib/bokslut/accruals/accrual-detector.ts @@ -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) diff --git a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts index 9bd66d66..92c18000 100644 --- a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts +++ b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts @@ -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' }, ) }) diff --git a/lib/bokslut/arsredovisning/build-data.ts b/lib/bokslut/arsredovisning/build-data.ts index aabfa1e4..c82be3fe 100644 --- a/lib/bokslut/arsredovisning/build-data.ts +++ b/lib/bokslut/arsredovisning/build-data.ts @@ -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 { diff --git a/lib/bokslut/dispositions-proposal-builder.ts b/lib/bokslut/dispositions-proposal-builder.ts index 17aef7af..37edc105 100644 --- a/lib/bokslut/dispositions-proposal-builder.ts +++ b/lib/bokslut/dispositions-proposal-builder.ts @@ -256,7 +256,10 @@ export async function buildLatentTaxProposal(params: { }): Promise { 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 diff --git a/lib/bokslut/ixbrl/build-input.ts b/lib/bokslut/ixbrl/build-input.ts index cf208bbd..58346b4d 100644 --- a/lib/bokslut/ixbrl/build-input.ts +++ b/lib/bokslut/ixbrl/build-input.ts @@ -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 { diff --git a/lib/bokslut/ixbrl/k2-mapper.ts b/lib/bokslut/ixbrl/k2-mapper.ts index d2734fb3..44b0e805 100644 --- a/lib/bokslut/ixbrl/k2-mapper.ts +++ b/lib/bokslut/ixbrl/k2-mapper.ts @@ -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 = { } /** - * 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) diff --git a/lib/bokslut/tax-provision/bolagsskatt-calculator.ts b/lib/bokslut/tax-provision/bolagsskatt-calculator.ts index 287c617c..53089a2f 100644 --- a/lib/bokslut/tax-provision/bolagsskatt-calculator.ts +++ b/lib/bokslut/tax-provision/bolagsskatt-calculator.ts @@ -220,7 +220,13 @@ export async function getBookedBolagsskatt( companyId: string, fiscalPeriodId: string, ): Promise { - 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) diff --git a/lib/bokslut/tax-provision/tax-adjustment-service.ts b/lib/bokslut/tax-provision/tax-adjustment-service.ts index 79370380..80937043 100644 --- a/lib/bokslut/tax-provision/tax-adjustment-service.ts +++ b/lib/bokslut/tax-provision/tax-adjustment-service.ts @@ -60,7 +60,7 @@ export async function loadTaxAdjustmentSnapshot( ): Promise { const [trialBalance, persistedResult] = await Promise.all([ generateTrialBalance(supabase, companyId, fiscalPeriodId, { - excludeYearEndClosing: true, + closingEntry: 'exclude-all-year-end', }), supabase .from('fiscal_period_tax_adjustments') diff --git a/lib/core/bookkeeping/year-end-service.ts b/lib/core/bookkeeping/year-end-service.ts index a7d860be..11decc4d 100644 --- a/lib/core/bookkeeping/year-end-service.ts +++ b/lib/core/bookkeeping/year-end-service.ts @@ -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( diff --git a/lib/reports/__tests__/closed-year-fixture.ts b/lib/reports/__tests__/closed-year-fixture.ts new file mode 100644 index 00000000..6cbffe15 --- /dev/null +++ b/lib/reports/__tests__/closed-year-fixture.ts @@ -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 +} diff --git a/lib/reports/__tests__/closed-year-statements.test.ts b/lib/reports/__tests__/closed-year-statements.test.ts new file mode 100644 index 00000000..cfe4998f --- /dev/null +++ b/lib/reports/__tests__/closed-year-statements.test.ts @@ -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 { + const c: Record = {} + 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 +} + +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 }).ink2r['7410'], + netResult: (r) => (r as { ink2r: Record }).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 }).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) + }) +}) diff --git a/lib/reports/__tests__/cross-surface-agreement.test.ts b/lib/reports/__tests__/cross-surface-agreement.test.ts new file mode 100644 index 00000000..cbb5fe67 --- /dev/null +++ b/lib/reports/__tests__/cross-surface-agreement.test.ts @@ -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 { + const c: Record = {} + 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. + }) +}) diff --git a/lib/reports/__tests__/dimension-pnl.test.ts b/lib/reports/__tests__/dimension-pnl.test.ts index 99c68fff..90796226 100644 --- a/lib/reports/__tests__/dimension-pnl.test.ts +++ b/lib/reports/__tests__/dimension-pnl.test.ts @@ -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. diff --git a/lib/reports/__tests__/monthly-breakdown.test.ts b/lib/reports/__tests__/monthly-breakdown.test.ts index 95dc1caf..2b1eedc2 100644 --- a/lib/reports/__tests__/monthly-breakdown.test.ts +++ b/lib/reports/__tests__/monthly-breakdown.test.ts @@ -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 = {} + 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) + }) +}) diff --git a/lib/reports/__tests__/resultatrapport.test.ts b/lib/reports/__tests__/resultatrapport.test.ts index 06251db5..9d395782 100644 --- a/lib/reports/__tests__/resultatrapport.test.ts +++ b/lib/reports/__tests__/resultatrapport.test.ts @@ -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) + }) +}) diff --git a/lib/reports/__tests__/sign-reclassification-scope.test.ts b/lib/reports/__tests__/sign-reclassification-scope.test.ts new file mode 100644 index 00000000..1de2502a --- /dev/null +++ b/lib/reports/__tests__/sign-reclassification-scope.test.ts @@ -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) + }) +}) diff --git a/lib/reports/__tests__/sign-reclassification.test.ts b/lib/reports/__tests__/sign-reclassification.test.ts new file mode 100644 index 00000000..4b1f766b --- /dev/null +++ b/lib/reports/__tests__/sign-reclassification.test.ts @@ -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): ReadonlyMap { + 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']) + }) +}) diff --git a/lib/reports/__tests__/statement-reconciliation.test.ts b/lib/reports/__tests__/statement-reconciliation.test.ts new file mode 100644 index 00000000..fa9473f8 --- /dev/null +++ b/lib/reports/__tests__/statement-reconciliation.test.ts @@ -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 { + const c: Record = {} + 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 => { + const c: Record = {} + 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 => { + const c: Record = {} + 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 => { + const c: Record = {} + 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)') + }) +}) diff --git a/lib/reports/__tests__/trial-balance-dimensions.test.ts b/lib/reports/__tests__/trial-balance-dimensions.test.ts index 82a47a0a..81ca9ab7 100644 --- a/lib/reports/__tests__/trial-balance-dimensions.test.ts +++ b/lib/reports/__tests__/trial-balance-dimensions.test.ts @@ -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' }, }) diff --git a/lib/reports/__tests__/trial-balance.test.ts b/lib/reports/__tests__/trial-balance.test.ts index f39cec47..8f326e01 100644 --- a/lib/reports/__tests__/trial-balance.test.ts +++ b/lib/reports/__tests__/trial-balance.test.ts @@ -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', }) diff --git a/lib/reports/balance-sheet.ts b/lib/reports/balance-sheet.ts index e0b8f51a..012057f8 100644 --- a/lib/reports/balance-sheet.ts +++ b/lib/reports/balance-sheet.ts @@ -22,6 +22,8 @@ export async function generateBalanceSheet( options?: { fromDate?: string; toDate?: string } ): Promise { 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, }) diff --git a/lib/reports/balansrapport.ts b/lib/reports/balansrapport.ts index 07786098..af382866 100644 --- a/lib/reports/balansrapport.ts +++ b/lib/reports/balansrapport.ts @@ -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, }) diff --git a/lib/reports/continuity-check.ts b/lib/reports/continuity-check.ts index 1befdcf4..a0ed17b5 100644 --- a/lib/reports/continuity-check.ts +++ b/lib/reports/continuity-check.ts @@ -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() diff --git a/lib/reports/dimension-pnl.ts b/lib/reports/dimension-pnl.ts index 4d8b5ea8..06254127 100644 --- a/lib/reports/dimension-pnl.ts +++ b/lib/reports/dimension-pnl.ts @@ -29,10 +29,10 @@ const CLASS_LABELS: Record = { * 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) diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 07c3061c..70ca2015 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -357,7 +357,7 @@ async function generatePeriodReports( ): Promise { 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), diff --git a/lib/reports/imbalance-diagnosis.ts b/lib/reports/imbalance-diagnosis.ts index f923ae23..7b474176 100644 --- a/lib/reports/imbalance-diagnosis.ts +++ b/lib/reports/imbalance-diagnosis.ts @@ -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) diff --git a/lib/reports/income-statement.ts b/lib/reports/income-statement.ts index d1320e53..be959fb1 100644 --- a/lib/reports/income-statement.ts +++ b/lib/reports/income-statement.ts @@ -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, diff --git a/lib/reports/ink2/__tests__/ink2-declaration.test.ts b/lib/reports/ink2/__tests__/ink2-declaration.test.ts new file mode 100644 index 00000000..7cf8e58a --- /dev/null +++ b/lib/reports/ink2/__tests__/ink2-declaration.test.ts @@ -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) + }) +}) diff --git a/lib/reports/ink2/__tests__/ink2-engine.test.ts b/lib/reports/ink2/__tests__/ink2-engine.test.ts index 11f4e227..d4b9468f 100644 --- a/lib/reports/ink2/__tests__/ink2-engine.test.ts +++ b/lib/reports/ink2/__tests__/ink2-engine.test.ts @@ -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() + 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 = { + 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]) + } + } + } + }) +}) diff --git a/lib/reports/ink2/__tests__/sru-generator.test.ts b/lib/reports/ink2/__tests__/sru-generator.test.ts index de6b2954..4835164c 100644 --- a/lib/reports/ink2/__tests__/sru-generator.test.ts +++ b/lib/reports/ink2/__tests__/sru-generator.test.ts @@ -59,7 +59,7 @@ function makeDeclaration(overrides?: Partial): INK2Declaration totalAssets: 175000, totalEquityLiabilities: 175000, operatingResult: 305000, - resultAfterFinancial: 302000, + aretsResultat: 302000, }, companyInfo: { companyName: 'Test AB', diff --git a/lib/reports/ink2/ink2-engine.ts b/lib/reports/ink2/ink2-engine.ts index 2ae7b51f..b5a10b3c 100644 --- a/lib/reports/ink2/ink2-engine.ts +++ b/lib/reports/ink2/ink2-engine.ts @@ -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( + 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 { + const balances = new Map() + 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, + balanceSheetBalances: ReadonlyMap, + 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 { + 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(({ 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() - 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() + 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() + 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', diff --git a/lib/reports/ink2/types.ts b/lib/reports/ink2/types.ts index 32d46cb4..9261b7e7 100644 --- a/lib/reports/ink2/types.ts +++ b/lib/reports/ink2/types.ts @@ -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[] diff --git a/lib/reports/kassaflodesanalys.ts b/lib/reports/kassaflodesanalys.ts index 66a36786..90c374c9 100644 --- a/lib/reports/kassaflodesanalys.ts +++ b/lib/reports/kassaflodesanalys.ts @@ -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 diff --git a/lib/reports/monthly-breakdown.ts b/lib/reports/monthly-breakdown.ts index 5dddeb7b..af9a7819 100644 --- a/lib/reports/monthly-breakdown.ts +++ b/lib/reports/monthly-breakdown.ts @@ -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. diff --git a/lib/reports/ne-bilaga/__tests__/ne-declaration.test.ts b/lib/reports/ne-bilaga/__tests__/ne-declaration.test.ts new file mode 100644 index 00000000..e060a8c0 --- /dev/null +++ b/lib/reports/ne-bilaga/__tests__/ne-declaration.test.ts @@ -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) + }) +}) diff --git a/lib/reports/ne-bilaga/ne-engine.ts b/lib/reports/ne-bilaga/ne-engine.ts index f6647fd3..7bc19e40 100644 --- a/lib/reports/ne-bilaga/ne-engine.ts +++ b/lib/reports/ne-bilaga/ne-engine.ts @@ -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(({ 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() - for (const acc of accounts) { - accountNameMap.set(acc.account_number, acc.account_name) - } - - // Calculate balances per account const accountBalances = new Map() - - 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 diff --git a/lib/reports/resultatrapport.ts b/lib/reports/resultatrapport.ts index a9b64d32..c63517ed 100644 --- a/lib/reports/resultatrapport.ts +++ b/lib/reports/resultatrapport.ts @@ -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, }) diff --git a/lib/reports/sign-reclassification.ts b/lib/reports/sign-reclassification.ts new file mode 100644 index 00000000..dbe815a5 --- /dev/null +++ b/lib/reports/sign-reclassification.ts @@ -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[] { + 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) +} diff --git a/lib/reports/statement-reconciliation.ts b/lib/reports/statement-reconciliation.ts new file mode 100644 index 00000000..3cc298bb --- /dev/null +++ b/lib/reports/statement-reconciliation.ts @@ -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 { + 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 { + 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 { + 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 +} diff --git a/lib/reports/trial-balance.ts b/lib/reports/trial-balance.ts index 248a9fb0..225735f9 100644 --- a/lib/reports/trial-balance.ts +++ b/lib/reports/trial-balance.ts @@ -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 @@ -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) } diff --git a/lib/salary/semesterberedning.ts b/lib/salary/semesterberedning.ts index 7a01a3aa..c898aba2 100644 --- a/lib/salary/semesterberedning.ts +++ b/lib/salary/semesterberedning.ts @@ -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 => { diff --git a/messages/en.json b/messages/en.json index 608e869d..b2bb957d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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)", diff --git a/messages/sv.json b/messages/sv.json index 396309f8..077371bc 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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)", diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index 8ee9700e..097b81cd 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -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" + ] } } diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs index 569a12fd..630dd962 100644 --- a/scripts/checks/no-new-antipatterns.mjs +++ b/scripts/checks/no-new-antipatterns.mjs @@ -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).`, ) diff --git a/supabase/migrations/20260729110000_vat_totals_exclude_closing_entry.sql b/supabase/migrations/20260729110000_vat_totals_exclude_closing_entry.sql new file mode 100644 index 00000000..dd885fb7 --- /dev/null +++ b/supabase/migrations/20260729110000_vat_totals_exclude_closing_entry.sql @@ -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$; diff --git a/supabase/migrations/20260730090000_kpi_monthly_exclude_year_end.sql b/supabase/migrations/20260730090000_kpi_monthly_exclude_year_end.sql new file mode 100644 index 00000000..8c7ae159 --- /dev/null +++ b/supabase/migrations/20260730090000_kpi_monthly_exclude_year_end.sql @@ -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'; diff --git a/tests/pg/kpi-report-aggregates-rpc.pg.test.ts b/tests/pg/kpi-report-aggregates-rpc.pg.test.ts index c3881e52..b6ed018b 100644 --- a/tests/pg/kpi-report-aggregates-rpc.pg.test.ts +++ b/tests/pg/kpi-report-aggregates-rpc.pg.test.ts @@ -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 () => { diff --git a/tests/pg/vat-totals-closing-entry.pg.test.ts b/tests/pg/vat-totals-closing-entry.pg.test.ts new file mode 100644 index 00000000..725c2795 --- /dev/null +++ b/tests/pg/vat-totals-closing-entry.pg.test.ts @@ -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 +} + +async function callRpc(companyId: string, start: string, end: string): Promise { + 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 { + 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) + }) +})