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
@@ -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)
})
})