From 0e3c0af8411f7fb34c16dbcea1516517b46079fd Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sat, 5 Sep 2026 19:13:28 +0200 Subject: [PATCH] =?UTF-8?q?fix(sie-import):=20refuse=20a=20closed=20or=20l?= =?UTF-8?q?ocked=20target=20year=20up=20front=20and=20point=20at=20=C3=96p?= =?UTF-8?q?pna=20igen=20(#2334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SIE file whose #RAR falls inside an existing fiscal year answered 'match' in precheckFiscalPeriod without looking at is_closed or locked_at, so the import ran into the atomic voucher RPC and surfaced the DB trigger's own text ("Cannot write to locked/closed fiscal period"). Observed 2026-09-04: an owner klarmarkerade an empty prior year, could not import its single aktiekapital voucher, and never found the "Öppna igen" button that undoes klarmarkera. The precheck now returns a conflict verdict for a closed or locked containing year, with the remedy per state: Öppna igen for a klarmarkerad year, Lås upp for a locked one, and no false hope for a year closed by a year-end run. The parse preview shows the same text and disables the import; executeSIEImport's no-create branch gets the same refusal. The årsredovisning builder now warns when the comparison year exists but holds no entries in Accounted: the column reads 0 kr, ÅRL 3 kap. 5 § requires the prior year's amounts, and the warning says where the fix lives instead of printing zeros silently. Claude-Session: https://claude.ai/code/session_015kJVo845t3ZMtFcCMhFEuk Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + .../__tests__/arsredovisning-k3.test.ts | 40 +++++++ lib/bokslut/arsredovisning/build-data.ts | 15 +++ lib/import/__tests__/sie-import.test.ts | 100 ++++++++++++++++++ lib/import/sie-import.ts | 85 ++++++++++++++- lib/import/types.ts | 3 +- 6 files changed, 240 insertions(+), 4 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index d6f8c1d4..91a931a7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1607,3 +1607,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-05] Utlägg becomes an answer, not a page: the Underlag pane asks "Vem betalade?" (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims; the person owed surfaces as a Betala row in Att göra (lib/worklist expense_payout, one item per person) and the Utlägg nav row is gated on existing claims like Körjournal. Chosen over a fourth item in the Bokföring split button (that menu is three ways to type one verifikat, not a list of document kinds) and over keeping the two-step wizard as the entry point: a kvitto paid with a private card differs from any other purchase only in the credit account, and 93 percent of companies on prod are owner-only, for whom a module for that one bit is the wrong shape. Phase 2 (bank-driven repayment, open items shared with leverantörsfakturor, via lön) and phase 3 (retire the wizard, per-person list under Löner) are filed as follow-ups. [2026-09-05] Cross-tab company guard (WL-09) stays a blocking two-exit dialog, founder re-confirmed today after a forensic pass on a real firing (a switch made elsewhere under the same login, no server-side or agent path involved): auto-follow, per-tab company scoping and a reads-continue banner were offered and declined. Only change: the dialog now names the company the other tab switched to (resolved from the memberships the shell already ships to the client, no request), so the two exits read as a choice between two named companies instead of a named one and "the new one". [2026-09-05] Björn Lundén connect: a 403 whose body says "out of allowed scope for service provider" is mapped to its own BL_INTEGRATION_NOT_ACTIVATED verdict (the key is right, the company never activated the integration) instead of the generic "leverantören avvisade autentiseringen"; live-verified against a real customer key, where every read endpoint answered exactly that while a made-up key answered 500. Root cause of every failed BL connect in prod (10 consents, only BL's own sandbox company ever got tokens): the integration is still a sandbox listing at BL, so no real company can activate it. Chose a message that names the fix (activate in Lundify, else SIE) over hiding the provider state; the Lundify activation-redirect flow and document/line-level fetching are filed as follow-ups rather than built blind before BL releases the integration. +[2026-09-05] SIE precheck refuses a closed or locked containing year up front (conflict verdict with the remedy: Öppna igen / Lås upp) instead of letting the voucher RPC fail with the trigger text; the årsredovisning warns when the comparison year has no entries instead of deriving BR comparatives from the IB voucher: derivation would hide that the RR comparatives are still unknown, and manual/IB comparatives after a migration are a product decision (follow-up issue). diff --git a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts index a1425281..1e39208a 100644 --- a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts +++ b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts @@ -747,3 +747,43 @@ describe('buildArsredovisningData: prior-period TB dedupe (multi-year)', () => { expect(data.warnings).toEqual(multiYearSnapshot.warnings) }) }) + +describe('buildArsredovisningData: comparison year without bookkeeping', () => { + // A previous year that exists but holds no entries in Accounted (done in + // another system, klarmarkerad here) gives an all-zero comparison column. + // ÅRL 3 kap. 5 § requires the prior year's amount per post, so the report + // must say why the column reads 0 kr and where the fix lives. + const TWO_PERIODS = [ + { id: 'fp1', name: 'Räkenskapsår 2025/2026', period_start: '2025-09-01', period_end: '2026-08-31' }, + { id: 'fp0', name: 'Räkenskapsår 2024/2025', period_start: '2024-06-03', period_end: '2025-08-31' }, + ] + const EMPTY_TB = { rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true } + + it('warns when the previous year has no entries at all', async () => { + mockFetchAllRows.mockResolvedValue(TWO_PERIODS) + const standard = await mockedTrialBalance.getMockImplementation() + mockedTrialBalance.mockImplementation(async (client, companyId, periodId, opts) => + periodId === 'fp0' ? EMPTY_TB : standard!(client, companyId, periodId, opts), + ) + const supabase = makeSupabase({ accountingFramework: 'k2', previousPeriodId: 'fp0' }) + // @ts-expect-error: chainable mock isn't fully typed as SupabaseClient + const data = await buildArsredovisningData(supabase, 'co1', 'fp1') + + const warning = data.warnings.find((w) => w.startsWith('Föregående räkenskapsår')) + expect(warning).toBeDefined() + expect(warning).toContain('Räkenskapsår 2024/2025') + expect(warning).toContain('saknar bokföring i Accounted') + expect(warning).toContain('Inställningar > Bokföring > Räkenskapsår') + // The comparison year is still there: the column reads 0 kr, not "none". + expect(data.previous_period?.name).toBe('Räkenskapsår 2024/2025') + }) + + it('stays silent when the previous year carries balances', async () => { + mockFetchAllRows.mockResolvedValue(TWO_PERIODS) + const supabase = makeSupabase({ accountingFramework: 'k2', previousPeriodId: 'fp0' }) + // @ts-expect-error: chainable mock isn't fully typed as SupabaseClient + const data = await buildArsredovisningData(supabase, 'co1', 'fp1') + + expect(data.warnings.find((w) => w.startsWith('Föregående räkenskapsår'))).toBeUndefined() + }) +}) diff --git a/lib/bokslut/arsredovisning/build-data.ts b/lib/bokslut/arsredovisning/build-data.ts index 91e3682d..0cc51152 100644 --- a/lib/bokslut/arsredovisning/build-data.ts +++ b/lib/bokslut/arsredovisning/build-data.ts @@ -180,6 +180,21 @@ export async function buildArsredovisningData( 'Jämförelsesiffror kunde inte hämtas för föregående räkenskapsår, balans- och resultaträkningen visas utan jämförelseår. Kontrollera det föregående årets bokföring.', ) } + // A previous year that exists but holds no bookkeeping in Accounted (the + // year was done in another system and klarmarkerad here) yields an all-zero + // comparison column, not a missing one. ÅRL 3 kap. 5 § requires the prior + // year's amount for every post, so say so instead of printing 0 kr silently + // (observed 2026-09-04: 25 000 kr aktiekapital shown as 0 kr). + if ( + prevPeriodRow && + previousTb && + previousTb.full.length === 0 && + previousTb.preClosing.length === 0 + ) { + statementWarnings.push( + `Föregående räkenskapsår (${prevPeriodRow.name}) saknar bokföring i Accounted, så jämförelsesiffrorna visar 0 kr. ÅRL 3 kap. 5 § kräver föregående års belopp för varje post. Bokför eller SIE-importera året innan årsredovisningen lämnas in; ett klarmarkerat år öppnas igen under Inställningar > Bokföring > Räkenskapsår.`, + ) + } const mapping = mapTrialBalancesToK2( { full: tbFull.rows, preClosing: tbPreClosing.rows }, previousTb, diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index 46b957fd..2ddf7332 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -1580,6 +1580,106 @@ describe('precheckFiscalPeriod', () => { expect(verdict).toEqual({ verdict: 'match', periodId: 'existing-period-id' }) }) + // A containing period that is closed or locked used to answer 'match', so + // the import ran into the DB trigger ("Cannot write to locked/closed fiscal + // period") with no way forward. Each state names its own remedy. + const closedPeriod = { + id: 'fy-2024-2025', + name: 'Räkenskapsår 2024/2025', + period_start: '2024-06-03', + period_end: '2025-08-31', + } + + it('refuses a klarmarkerad containing period and points at Öppna igen', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { + data: { + ...closedPeriod, + is_closed: true, + locked_at: '2026-09-04T10:55:54Z', + closed_externally: true, + closing_entry_id: null, + }, + error: null, + }, + ]) + + const verdict = await precheckFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2024-06-03', + '2025-08-31', + ) + + expect(verdict.verdict).toBe('conflict') + if (verdict.verdict !== 'conflict') return + expect(verdict.existingPeriod).toEqual({ + id: 'fy-2024-2025', + name: 'Räkenskapsår 2024/2025', + periodStart: '2024-06-03', + periodEnd: '2025-08-31', + }) + expect(verdict.message).toMatch(/avslutat i ett tidigare program/) + expect(verdict.message).toMatch(/Öppna igen/) + expect(verdict.message).toMatch(/Inställningar > Bokföring > Räkenskapsår/) + }) + + it('refuses a period closed by a year-end run without offering Öppna igen', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { + data: { + ...closedPeriod, + is_closed: true, + locked_at: '2026-09-04T12:14:16Z', + closed_externally: false, + closing_entry_id: 'closing-entry', + }, + error: null, + }, + ]) + + const verdict = await precheckFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2024-06-03', + '2025-08-31', + ) + + expect(verdict.verdict).toBe('conflict') + if (verdict.verdict !== 'conflict') return + expect(verdict.message).toMatch(/stängt med ett årsbokslut/) + expect(verdict.message).not.toMatch(/Öppna igen/) + }) + + it('refuses a locked (not closed) containing period and points at Lås upp', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { + data: { + ...closedPeriod, + is_closed: false, + locked_at: '2026-09-04T10:55:54Z', + closed_externally: false, + closing_entry_id: null, + }, + error: null, + }, + ]) + + const verdict = await precheckFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2024-06-03', + '2025-08-31', + ) + + expect(verdict.verdict).toBe('conflict') + if (verdict.verdict !== 'conflict') return + expect(verdict.message).toMatch(/är låst\. Lås upp det/) + }) + it('reports create with nothing to replace when no period overlaps', async () => { const { supabase, enqueueMany } = createQueuedMockSupabase() enqueueMany([ diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index dbb8cd52..7d0d30b6 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -463,13 +463,65 @@ async function cleanupStaleImportRecords( .lt('created_at', fiveMinutesAgo) } +interface ClosedPeriodState { + name: string + period_start: string + period_end: string + is_closed: boolean | null + locked_at: string | null + closed_externally?: boolean | null + closing_entry_id?: string | null +} + +/** + * Why a closed or locked fiscal year cannot take imported vouchers, with the + * way out spelled out per state, or null when the year is open. Three states + * map to three different remedies: + * + * - klarmarkerad (closed_externally, no closing entry): "Öppna igen" in + * Inställningar > Bokföring > Räkenskapsår undoes it, so say so. + * - closed by a year-end run (closing entry): nothing self-serve reopens it. + * - locked only: "Lås upp" in the same settings section. + */ +export function closedPeriodRefusal(period: ClosedPeriodState): string | null { + const label = `Räkenskapsåret ${period.name} (${period.period_start} till ${period.period_end})` + if (period.is_closed) { + if (period.closed_externally && !period.closing_entry_id) { + return ( + `${label} är markerat som avslutat i ett tidigare program och tar inte emot verifikationer. ` + + `Öppna det igen under Inställningar > Bokföring > Räkenskapsår (knappen Öppna igen), ` + + `importera filen på nytt och klarmarkera året igen efteråt.` + ) + } + if (period.closing_entry_id) { + return ( + `${label} är stängt med ett årsbokslut i Accounted och tar inte emot fler verifikationer. ` + + `Bokför rättelser i det öppna räkenskapsåret i stället.` + ) + } + return ( + `${label} är stängt och tar inte emot fler verifikationer. ` + + `Öppna räkenskapsåret under Inställningar > Bokföring > Räkenskapsår och importera sedan filen på nytt.` + ) + } + if (period.locked_at) { + return ( + `${label} är låst. Lås upp det under Inställningar > Bokföring > Räkenskapsår ` + + `och importera sedan filen på nytt.` + ) + } + return null +} + /** * Read-only verdict on how the SIE file's räkenskapsår relates to the * company's existing fiscal periods. Shared by the parse preview and by * ensureFiscalPeriod, so what the wizard says before import is exactly what * the import will do: * - * - match: a period already contains the file's date range; it is reused. + * - match: an open period already contains the file's date range; it is + * reused. A closed or locked containing period is a conflict instead, + * with the remedy (Öppna igen / Lås upp) in the message. * - create: no period covers the range; one is created. If an overlapping * period is empty (onboarding-seeded with the default calendar year but * never used) it is replaced: the user has a förlängt räkenskapsår per @@ -493,13 +545,31 @@ export async function precheckFiscalPeriod( // Check for an existing period that contains the SIE date range const { data: containing } = await supabase .from('fiscal_periods') - .select('id') + .select('id, name, period_start, period_end, is_closed, locked_at, closed_externally, closing_entry_id') .eq('company_id', companyId) .lte('period_start', startDate) .gte('period_end', endDate) .single() if (containing) { + // A closed or locked year would only fail later, inside the atomic + // voucher RPC, with the DB trigger's own text and no way forward. Refuse + // here so the preview says what to do (observed 2026-09-04: an owner + // klarmarkerade an empty prior year, then could not import its one + // voucher and never found "Öppna igen"). + const refusal = closedPeriodRefusal(containing as ClosedPeriodState) + if (refusal) { + return { + verdict: 'conflict', + existingPeriod: { + id: containing.id as string, + name: containing.name as string, + periodStart: containing.period_start as string, + periodEnd: containing.period_end as string, + }, + message: refusal, + } + } return { verdict: 'match', periodId: containing.id } } @@ -2412,7 +2482,7 @@ export async function executeSIEImport( // Find existing fiscal period const { data: existing } = await supabase .from('fiscal_periods') - .select('id') + .select('id, name, period_start, period_end, is_closed, locked_at, closed_externally, closing_entry_id') .eq('company_id', companyId) .lte('period_start', fiscalYearStart) .gte('period_end', fiscalYearEnd) @@ -2423,6 +2493,15 @@ export async function executeSIEImport( return result } + // Same refusal as precheckFiscalPeriod: a closed or locked year must + // fail here with the remedy, not inside the voucher RPC with the + // trigger's text. + const refusal = closedPeriodRefusal(existing as ClosedPeriodState) + if (refusal) { + result.errors.push(refusal) + return result + } + result.fiscalPeriodId = existing.id } diff --git a/lib/import/types.ts b/lib/import/types.ts index 0f22d86c..9b9b7b06 100644 --- a/lib/import/types.ts +++ b/lib/import/types.ts @@ -427,7 +427,8 @@ export type FiscalYearPrecheck = replacesEmptyPeriodId: string | null } | { - // An overlapping period carries real content; the import will refuse. + // An overlapping period carries real content, or the containing period + // is closed or locked; the import will refuse. verdict: 'conflict' existingPeriod: { id: string; name: string; periodStart: string; periodEnd: string } // The Swedish refusal text the import raises, verbatim.