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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
0ecf1d7fc4
commit
0e3c0af841
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user