feat(year-end): administrative undo of executed year-end closing + skatteverket scope fixes (#1081)

* fix(skatteverket): request the ska scope for skattekonto v2

The skattekonto v2 API rejects skahmst-only tokens with 403 "The required
scopes are not authorized" (observed in prod 2026-07-20; no company has
synced since 2026-05-10). The requested `skattekonto` scope is silently
dropped from every grant, while `ska` appears in one real May grant, so
request it too: SKV grants the intersection, so this is harmless if wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): correct the skattekonto scope model around ska

Root cause of the May 10 skattekonto outage, confirmed via git history and
prod token data: the `ska` scope (the interactive skattekonto API's actual
scope, requested since the extension's first commit in March) was removed
by the "remove unused scopes" cleanup in the #431 series. Every token
issued after that hour lacks it and the API answers 403 "The required
scopes are not authorized"; no company has synced since. The May 15 repair
re-added skahmst, which per its tjanstebeskrivning is a different bulk
E-transport service and does not substitute; `skattekonto` is not a real
SKV scope name and is silently dropped from grants.

Follow-up to the ska re-request (cd8f7a30):
- document the confirmed scope model in oauth.ts so ska is never
  "cleaned up" again
- panel missing-scope warning and reconnect-button now gate on ska,
  not skahmst/skattekonto
- scope badge labels: ska takes the saldo & transaktioner label,
  skahmst relabeled as the E-transport file service
- consent-page note covers both terse scope names and says ska is
  required

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(year-end): warn on untaxed profit at verkstall, Swedish readiness messages, always-visible period selector

An aktiebolag could execute year-end with a profit and zero bolagsskatt
booked without any warning (support case: closing moved 592k to 2099
untaxed). The preview now computes bolagsskattMissing (AB + profit + no
89xx account among closed accounts, 8999 excluded) and both the preview
and execute steps render an advisory, bypassable warning.

validateYearEndReadiness messages are now Swedish (the bokslut wizard is
a stays-Swedish surface); the MCP year_end_readiness classifier matches
both the new Swedish strings and the legacy English ones.

The wizard period selector now always renders, keeps a selected-but-
ineligible period selectable, and resets a stale ?period= id from
another company instead of leaving the user stuck on the wrong year.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(year-end): administrative undo of an executed year-end closing

Storno-only reset used when a bokslut was executed prematurely (e.g.
without bolagsskatt) and no arsredovisning exists yet: reverses the next
period's result_appropriation and opening_balance entries, reopens the
period, reverses the closing entry, and detaches closing_entry_id.
Resumable if interrupted midway; attribution per BFL 5 kap 6.

Migration 20260720140000 adds the trigger escape hatch: closing_entry_id
may only change once set when the old closing entry is reversed with a
posted storno chain (status flag alone is forgeable via PostgREST), and
a non-NULL replacement must be a posted year_end entry in the same
period. Covered by a pg-real test.

planResultAppropriation idempotency is now posted-only: a reversed
omforing no longer blocks the re-run from posting a fresh 2099 -> 2098
reclassification (it previously returned null silently, leaving the new
year's equity polluted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): address CodeRabbit, PR-Agent and compliance findings

- undo script: company_id filters on verify queries, period-scope the
  arsredovisning precondition checks, validate service-key format,
  escalate audit_log insert failure to a hard error (BFNAR 2013:2)
- detach migration: company-scope the storno chain EXISTS, replace the
  em dash in the new error message

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): address round-2 compliance swarm and Swedish review findings

- undo script: require --confirm-url with --commit so an env swap fails
  loud; retry the audit_log insert 3x and direct the operator to insert
  the behandlingshistorik row manually on final failure (BFNAR 2013:2)
- year-end preview: document why resultAccountSummary is a complete 89xx
  scan; warning text now also names periodiseringsfond and
  overavskrivningar as legitimate zero-tax reasons

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-20 16:17:43 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e2d6c92e3a
commit 4e47335308
19 changed files with 1054 additions and 66 deletions
@@ -130,7 +130,7 @@ describe('buildBokslutReadinessReport', () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(
baseValidation({
ready: false,
errors: ['3 draft journal entries must be posted or deleted before closing'],
errors: ['3 utkast måste bokföras eller raderas innan bokslut'],
draftCount: 3,
}),
)
@@ -127,6 +127,32 @@ describe('generateResultAppropriation', () => {
expect(getOpeningBalances).not.toHaveBeenCalled()
})
it('idempotency filter is posted-only: a reversed omföring must not block re-planning', async () => {
// After an administrative year-end undo, the period's omföring is
// status='reversed' (storno-cancelled, net zero on 2099). The re-run has
// to be able to post a fresh one, so the existence query must filter on
// status='posted' and NOT use an .in(['posted','reversed']) filter.
results = [AB, NO_EXISTING, PERIOD]
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 470621.21 }])
const builders: Array<Record<string, ReturnType<typeof vi.fn>>> = []
const client = {
from: vi.fn().mockImplementation(() => {
const b = makeBuilder() as Record<string, ReturnType<typeof vi.fn>>
builders.push(b)
return b
}),
}
const entry = await generateResultAppropriation(client as never, 'c1', 'u1', 'p1')
expect(entry).toEqual(FAKE_ENTRY)
// Builder 1 is the journal_entries existence query (builder 0 = settings).
const existenceQuery = builders[1]
expect(existenceQuery.eq).toHaveBeenCalledWith('status', 'posted')
expect(existenceQuery.in).not.toHaveBeenCalled()
})
it('returns null when 2099 carries no IB balance', async () => {
results = [AB, NO_EXISTING, PERIOD]
mockOpeningBalance([{ account_number: '1930', debit: 5000, credit: 0 }])
@@ -110,7 +110,7 @@ describe('validateYearEndReadiness', () => {
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.errors.some((e: string) => e.includes('draft'))).toBe(true)
expect(result.errors.some((e: string) => e.includes('utkast'))).toBe(true)
})
it('returns errors when trial balance is unbalanced', async () => {
@@ -128,7 +128,7 @@ describe('validateYearEndReadiness', () => {
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.trialBalanceBalanced).toBe(false)
expect(result.errors.some((e: string) => e.includes('Trial balance'))).toBe(true)
expect(result.errors.some((e: string) => e.includes('Råbalansen balanserar inte'))).toBe(true)
})
it('returns error when period has not yet ended', async () => {
@@ -150,7 +150,7 @@ describe('validateYearEndReadiness', () => {
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.errors.some((e: string) => e.includes('not yet ended'))).toBe(true)
expect(result.errors.some((e: string) => e.includes('slutdatumet har inte passerat'))).toBe(true)
})
it('warns on explained voucher gaps', async () => {
@@ -188,7 +188,7 @@ describe('validateYearEndReadiness', () => {
} as never)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.warnings.some((w: string) => w.includes('documented'))).toBe(true)
expect(result.warnings.some((w: string) => w.includes('dokumenterat'))).toBe(true)
expect(result.voucherGaps).toHaveLength(1)
expect(result.voucherGaps[0].series).toBe('A')
expect(result.unexplainedGaps).toHaveLength(0)
@@ -231,7 +231,7 @@ describe('validateYearEndReadiness', () => {
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.errors.some((e: string) => e.includes('Unexplained voucher gap'))).toBe(true)
expect(result.errors.some((e: string) => e.includes('Oförklarat verifikationsnummerglapp'))).toBe(true)
expect(result.unexplainedGaps).toHaveLength(1)
expect(result.unexplainedGaps[0]).toEqual({ gap_start: 5, gap_end: 7, series: 'A' })
})
@@ -281,8 +281,8 @@ describe('validateYearEndReadiness', () => {
expect(result.voucherGaps[0]).toEqual({ gap_start: 3, gap_end: 3, series: 'A' })
expect(result.voucherGaps[1]).toEqual({ gap_start: 1, gap_end: 2, series: 'B' })
expect(result.unexplainedGaps).toHaveLength(2)
expect(result.errors.some((e: string) => e.includes('series A'))).toBe(true)
expect(result.errors.some((e: string) => e.includes('series B'))).toBe(true)
expect(result.errors.some((e: string) => e.includes('serie A'))).toBe(true)
expect(result.errors.some((e: string) => e.includes('serie B'))).toBe(true)
})
it('detects sequence counter mismatch (counter < actual)', async () => {
@@ -312,7 +312,7 @@ describe('validateYearEndReadiness', () => {
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.errors.some((e: string) => e.includes('Sequence counter integrity error'))).toBe(true)
expect(result.errors.some((e: string) => e.includes('Nummerserien i serie'))).toBe(true)
expect(result.sequenceMismatches).toHaveLength(1)
expect(result.sequenceMismatches[0]).toEqual({ series: 'A', sequenceCounter: 5, actualMax: 10 })
})
@@ -344,7 +344,7 @@ describe('validateYearEndReadiness', () => {
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(true) // warning, not blocking
expect(result.warnings.some((w: string) => w.includes('Sequence counter ahead'))).toBe(true)
expect(result.warnings.some((w: string) => w.includes('Nummerräknaren ligger före'))).toBe(true)
expect(result.sequenceMismatches).toHaveLength(1)
})
@@ -371,7 +371,7 @@ describe('validateYearEndReadiness', () => {
// Period name intentionally not interpolated into the warning: see
// year-end-service for rationale. We assert on the stable English
// substring instead.
expect(result.warnings.some((w: string) => w.includes('Next fiscal period already exists'))).toBe(true)
expect(result.warnings.some((w: string) => w.includes('Nästa räkenskapsperiod finns redan'))).toBe(true)
})
it('blocks when next period already has opening balances posted', async () => {
@@ -394,7 +394,7 @@ describe('validateYearEndReadiness', () => {
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.errors.some((e: string) => e.includes('already has opening balances'))).toBe(true)
expect(result.errors.some((e: string) => e.includes('redan ingående balanser bokförda'))).toBe(true)
})
})
@@ -510,4 +510,90 @@ describe('previewYearEndClosing', () => {
expect(preview.closingAccount).toBe('2010')
expect(preview.closingAccountName).toBe('Eget kapital')
})
it('flags bolagsskattMissing for AB profit year without any 89xx tax account', async () => {
results = [
{ data: { entity_type: 'aktiebolag' }, error: null },
{ data: { period_end: '2024-12-31' }, error: null },
]
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [
{ account_number: '3001', account_name: 'Tjänsteintäkter', account_class: 3, closing_debit: 0, closing_credit: 500000 },
{ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 200000, closing_credit: 0 },
{ account_number: '8811', account_name: 'Avsättning till periodiseringsfond', account_class: 8, closing_debit: 75000, closing_credit: 0 },
],
isBalanced: true,
totalDebit: 275000,
totalCredit: 500000,
} as never)
const supabase = makeClient()
const preview = await previewYearEndClosing(supabase as never, 'company-1', 'user-1', 'fp-1')
// 8811 is a disposition, not a tax account: the warning must still fire.
expect(preview.netResult).toBe(225000)
expect(preview.bolagsskattMissing).toBe(true)
})
it('does not flag bolagsskattMissing when 8910 is booked', async () => {
results = [
{ data: { entity_type: 'aktiebolag' }, error: null },
{ data: { period_end: '2024-12-31' }, error: null },
]
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [
{ account_number: '3001', account_name: 'Tjänsteintäkter', account_class: 3, closing_debit: 0, closing_credit: 500000 },
{ account_number: '8910', account_name: 'Skatt på årets resultat', account_class: 8, closing_debit: 103000, closing_credit: 0 },
],
isBalanced: true,
totalDebit: 103000,
totalCredit: 500000,
} as never)
const supabase = makeClient()
const preview = await previewYearEndClosing(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(preview.bolagsskattMissing).toBe(false)
})
it('does not flag bolagsskattMissing for a loss year or for EF', async () => {
// Loss year, AB
results = [
{ data: { entity_type: 'aktiebolag' }, error: null },
{ data: { period_end: '2024-12-31' }, error: null },
]
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [
{ account_number: '3001', account_name: 'Tjänsteintäkter', account_class: 3, closing_debit: 0, closing_credit: 100000 },
{ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 150000, closing_credit: 0 },
],
isBalanced: true,
totalDebit: 150000,
totalCredit: 100000,
} as never)
const supabase = makeClient()
const lossPreview = await previewYearEndClosing(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(lossPreview.netResult).toBe(-50000)
expect(lossPreview.bolagsskattMissing).toBe(false)
// Profit year, EF (tax is never booked for enskild firma)
resultIdx = 0
results = [
{ data: { entity_type: 'enskild_firma' }, error: null },
{ data: { period_end: '2024-12-31' }, error: null },
]
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [
{ account_number: '3001', account_name: 'Intäkter', account_class: 3, closing_debit: 0, closing_credit: 100000 },
],
isBalanced: true,
totalDebit: 0,
totalCredit: 100000,
} as never)
const efPreview = await previewYearEndClosing(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(efPreview.netResult).toBe(100000)
expect(efPreview.bolagsskattMissing).toBe(false)
})
})
@@ -33,7 +33,10 @@ export interface ResultAppropriationPlan {
*
* Returns null when:
* - the company is not an aktiebolag (enskild firma books to 2010, no 2099),
* - the period already has a result_appropriation entry (idempotency), or
* - the period already has a POSTED result_appropriation entry (idempotency;
* a reversed one has been stornoed, no longer moves any balance, and must
* not block re-planning: the year-end undo flow reverses the omföring and
* the subsequent re-run has to be able to post a fresh one), or
* - 2099 carries no balance (within ORE_TOLERANCE).
*
* Shared by generateResultAppropriation (which posts the plan) and the
@@ -55,14 +58,17 @@ export async function planResultAppropriation(
const entityType = settings?.entity_type ?? 'aktiebolag'
if (entityType !== 'aktiebolag') return null
// Idempotency: never plan a second omföring for a period that already has one.
// Idempotency: never plan a second omföring for a period that already has a
// LIVE one. Deliberately posted-only: a reversed omföring is storno-cancelled
// (net zero effect on 2099), so it must not block the re-run after an
// administrative year-end undo (scripts/undo-year-end-closing.ts).
const { data: existing } = await supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('fiscal_period_id', periodId)
.eq('source_type', 'result_appropriation')
.in('status', ['posted', 'reversed'])
.eq('status', 'posted')
.limit(1)
.maybeSingle()
if (existing) return null
+39 -18
View File
@@ -45,10 +45,14 @@ export async function validateYearEndReadiness(
.eq('company_id', companyId)
.single()
// The error/warning strings below are Swedish: they render verbatim in the
// bokslut wizard (a "stays Swedish" surface per .claude/rules/i18n.md).
// The MCP year_end_readiness tool classifies them by regex; keep
// extensions/general/mcp-server/server.ts in sync when changing wording.
if (fetchError || !period) {
return {
ready: false,
errors: ['Fiscal period not found'],
errors: ['Räkenskapsperioden hittades inte'],
warnings: [],
draftCount: 0,
voucherGaps: [],
@@ -61,17 +65,17 @@ export async function validateYearEndReadiness(
// Check: period must have ended (BFNAR 2017:3 / ÅRL 2:1)
const today = new Date().toISOString().split('T')[0]
if (period.period_end > today) {
errors.push('Cannot close a fiscal period that has not yet ended')
errors.push('Perioden kan inte stängas: slutdatumet har inte passerat ännu')
}
// Check: period not already closed
if (period.is_closed) {
errors.push('Period is already closed')
errors.push('Perioden är redan stängd')
}
// Check: closing entry doesn't already exist
if (period.closing_entry_id) {
errors.push('Year-end closing entry already exists for this period')
errors.push('Bokslutsverifikation finns redan för perioden')
}
// Check: no draft entries
@@ -84,11 +88,11 @@ export async function validateYearEndReadiness(
const drafts = draftCount ?? 0
if (drafts > 0) {
errors.push(`${drafts} draft journal entries must be posted or deleted before closing`)
errors.push(`${drafts} utkast måste bokföras eller raderas innan bokslut`)
}
// Check: voucher continuity across all series
let voucherGaps: VoucherGap[] = []
const voucherGaps: VoucherGap[] = []
const { data: seriesRows } = await supabase
.from('voucher_sequences')
.select('voucher_series')
@@ -116,7 +120,7 @@ export async function validateYearEndReadiness(
}
// Check gap explanations: unexplained gaps block year-end (BFNAR 2013:2 punkt 5.8)
let unexplainedGaps: VoucherGap[] = []
const unexplainedGaps: VoucherGap[] = []
if (voucherGaps.length > 0) {
const { data: explanations } = await supabase
.from('voucher_gap_explanations')
@@ -135,12 +139,12 @@ export async function validateYearEndReadiness(
const key = `${gap.series}:${gap.gap_start}:${gap.gap_end}`
if (explanationSet.has(key)) {
warnings.push(
`Voucher gap in series ${gap.series} (${gap.gap_start}-${gap.gap_end}): documented`
`Verifikationsnummerglapp i serie ${gap.series} (${gap.gap_start}-${gap.gap_end}): dokumenterat`
)
} else {
unexplainedGaps.push(gap)
errors.push(
`Unexplained voucher gap in series ${gap.series}: ${gap.gap_start}-${gap.gap_end}`
`Oförklarat verifikationsnummerglapp i serie ${gap.series}: ${gap.gap_start}-${gap.gap_end}`
)
}
}
@@ -181,11 +185,11 @@ export async function validateYearEndReadiness(
if (sequenceCounter < actualMax) {
errors.push(
`Sequence counter integrity error in series ${row.voucher_series}: counter=${sequenceCounter} but max voucher=${actualMax}`
`Nummerserien i serie ${row.voucher_series} stämmer inte: räknaren står på ${sequenceCounter} men högsta verifikationsnummer är ${actualMax}`
)
} else {
warnings.push(
`Sequence counter ahead of actual entries in series ${row.voucher_series}: counter=${sequenceCounter}, max voucher=${actualMax}`
`Nummerräknaren ligger före bokförda verifikationer i serie ${row.voucher_series}: räknare=${sequenceCounter}, högsta verifikationsnummer=${actualMax}`
)
}
}
@@ -198,7 +202,7 @@ export async function validateYearEndReadiness(
if (!trialBalanceBalanced) {
errors.push(
`Trial balance is not balanced: debit=${trialBalance.totalDebit}, credit=${trialBalance.totalCredit}`
`Råbalansen balanserar inte: debet=${trialBalance.totalDebit}, kredit=${trialBalance.totalCredit}`
)
}
@@ -211,7 +215,7 @@ export async function validateYearEndReadiness(
.eq('status', 'posted')
if ((entryCount ?? 0) === 0) {
warnings.push('No posted journal entries in this period')
warnings.push('Inga bokförda verifikationer i perioden')
}
// Check: foreign currency items exist but haven't been revalued
@@ -243,14 +247,14 @@ export async function validateYearEndReadiness(
if (((fxReceivables ?? 0) + (fxPayables ?? 0)) > 0) {
warnings.push(
'Open foreign currency items exist but have not been revalued (ÅRL 4:13)'
'Öppna poster i utländsk valuta har inte omvärderats (ÅRL 4:13)'
)
}
}
// Check: continuity_verified flag from prior year-end
if (period.continuity_verified === false) {
errors.push('Opening balance continuity check failed for this period: resolve discrepancies before closing')
errors.push('IB/UB-kontinuiteten stämmer inte för perioden: åtgärda avvikelserna innan bokslut')
}
// Check: next period state. A pre-existing next period (from SIE import,
@@ -266,9 +270,9 @@ export async function validateYearEndReadiness(
const nextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
if (nextPeriod) {
if (nextPeriod.opening_balance_entry_id) {
errors.push('Next fiscal period already has opening balances posted')
errors.push('Nästa räkenskapsperiod har redan ingående balanser bokförda')
} else {
warnings.push('Next fiscal period already exists: opening balances will be booked into it')
warnings.push('Nästa räkenskapsperiod finns redan: ingående balanser bokförs i den')
}
}
@@ -408,6 +412,22 @@ export async function previewYearEndClosing(
}
}
// Advisory check: an AB closing a profit year should normally have booked
// bolagsskatt (Dr 8910 / Cr 2512) in the dispositions step. If no 89xx tax
// account is among the accounts being closed, the profit is untaxed. This
// is a warning, not a blocker: zero tax is legitimate when underskotts-
// avdrag zeroes the taxable result. 8999 is excluded: it is the manual
// result-closing account, not a tax account.
// Scanning resultAccountSummary is equivalent to a full 89xx trial-balance
// scan: it is built from every class 3-8 account with a non-zero closing
// balance, regardless of voucher series, so a booked tax entry cannot be
// missed by this check.
const hasTaxAccount = resultAccountSummary.some(
(a) => a.account_number.startsWith('89') && a.account_number !== '8999'
)
const bolagsskattMissing =
closingAccount === '2099' && netResult > ORE_TOLERANCE && !hasTaxAccount
return {
netResult,
closingAccount,
@@ -415,6 +435,7 @@ export async function previewYearEndClosing(
closingLines,
resultAccountSummary,
currencyRevaluation,
bolagsskattMissing,
}
}
@@ -442,7 +463,7 @@ export async function executeYearEndClosing(
// 1. Validate readiness
const validation = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId)
if (!validation.ready) {
throw new Error(`Year-end closing not ready: ${validation.errors.join('; ')}`)
throw new Error(`Bokslutet kan inte verkställas: ${validation.errors.join('; ')}`)
}
// Fetch the period for dates