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:
co-authored by
Claude Fable 5
parent
e2d6c92e3a
commit
4e47335308
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user