fix(sie-import): refuse a closed or locked target year up front and point at Öppna igen (#2334)

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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-05 19:13:28 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 0ecf1d7fc4
commit 0e3c0af841
6 changed files with 240 additions and 4 deletions
+1
View File
@@ -1607,3 +1607,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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).
@@ -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()
})
})
+15
View File
@@ -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,
+100
View File
@@ -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([
+82 -3
View File
@@ -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
}
+2 -1
View File
@@ -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.