Files
accounted/lib/bokslut/__tests__/readiness-aggregator.test.ts
T
MattssonandClaude Fable 5 4e47335308 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>
2026-07-20 16:17:43 +02:00

215 lines
7.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { YearEndValidation } from '@/types'
// Mock both sources the aggregator composes from. Tests focus on composition
// (reminders by entity, reconciliation surfacing, error tolerance): the
// underlying validateYearEndReadiness already has its own coverage.
vi.mock('@/lib/core/bookkeeping/year-end-service', () => ({
validateYearEndReadiness: vi.fn(),
}))
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
getReconciliationStatus: vi.fn(),
}))
import { buildBokslutReadinessReport } from '../readiness-aggregator'
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
interface MockBuilder {
select: ReturnType<typeof vi.fn>
eq: ReturnType<typeof vi.fn>
single: ReturnType<typeof vi.fn>
maybeSingle: ReturnType<typeof vi.fn>
}
function makeSupabase(handlers: {
period: { data: unknown; error: unknown }
settings: { data: unknown; error: unknown }
}) {
function makeBuilder(table: string): MockBuilder {
const b: MockBuilder = {
select: vi.fn(),
eq: vi.fn(),
single: vi.fn(),
maybeSingle: vi.fn(),
}
b.select.mockReturnValue(b)
b.eq.mockReturnValue(b)
if (table === 'fiscal_periods') {
b.single.mockResolvedValue(handlers.period)
} else if (table === 'company_settings') {
b.maybeSingle.mockResolvedValue(handlers.settings)
}
return b
}
return {
from: vi.fn((table: string) => makeBuilder(table)),
} as unknown as Parameters<typeof buildBokslutReadinessReport>[0]
}
function baseValidation(overrides: Partial<YearEndValidation> = {}): YearEndValidation {
return {
ready: true,
errors: [],
warnings: [],
draftCount: 0,
voucherGaps: [],
unexplainedGaps: [],
sequenceMismatches: [],
trialBalanceBalanced: true,
...overrides,
}
}
const PERIOD = {
id: 'fp-1',
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
is_closed: false,
locked_at: null,
closing_entry_id: null,
}
const RECON_CLEAN = {
bank_transaction_total: 100,
gl_1930_balance: 100,
gl_1930_period_movement: 100,
gl_1930_opening_balance: 0,
difference: 0,
is_reconciled: true,
matched_count: 5,
unmatched_transaction_count: 0,
unmatched_gl_line_count: 0,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('buildBokslutReadinessReport', () => {
it('returns a ready report with the accruals reminder for AB', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'aktiebolag' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.ready).toBe(true)
expect(report.blockers).toEqual([])
expect(report.entityType).toBe('aktiebolag')
// Phase 3 handles depreciation + bolagsskatt + p-fond automatically: only
// the accruals reminder should remain (Phase 4 will replace it).
expect(report.reminders.map((r) => r.code)).toContain('accruals_manual')
expect(report.reminders.map((r) => r.code)).not.toContain('depreciation_manual')
expect(report.reminders.map((r) => r.code)).not.toContain('bolagsskatt_manual')
expect(report.reminders.map((r) => r.code)).not.toContain('periodiseringsfond_manual')
expect(report.reminders.find((r) => r.code === 'ef_skatt_via_ne')).toBeUndefined()
expect(report.reconciliation?.is_reconciled).toBe(true)
})
it('returns the EF-only reminder for enskild firma', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'enskild_firma' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.entityType).toBe('enskild_firma')
expect(report.reminders.find((r) => r.code === 'ef_skatt_via_ne')).toBeDefined()
})
it('surfaces blockers from the underlying validation and stays not-ready', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(
baseValidation({
ready: false,
errors: ['3 utkast måste bokföras eller raderas innan bokslut'],
draftCount: 3,
}),
)
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'aktiebolag' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.ready).toBe(false)
expect(report.blockers).toHaveLength(1)
expect(report.draftCount).toBe(3)
})
it('adds a reconciliation reminder when bank is unreconciled', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue({
...RECON_CLEAN,
is_reconciled: false,
unmatched_transaction_count: 7,
difference: 1234.56,
})
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'aktiebolag' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
const reconReminder = report.reminders.find((r) => r.code === 'bank_reconciliation_incomplete')
expect(reconReminder).toBeDefined()
expect(reconReminder?.severity).toBe('warning')
expect(reconReminder?.message).toContain('7')
// Reconciliation reminder is not a legal blocker: ready should still mirror validation
expect(report.ready).toBe(true)
})
it('does not break when reconciliation lookup throws', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockRejectedValue(new Error('boom'))
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'aktiebolag' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.reconciliation).toBeNull()
expect(report.reminders.find((r) => r.code === 'bank_reconciliation_incomplete')).toBeUndefined()
expect(report.ready).toBe(true)
})
it('throws when the fiscal period is missing', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: null, error: { message: 'not found' } },
settings: { data: null, error: null },
})
await expect(
buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-missing'),
).rejects.toThrow(/not found/i)
})
it('defaults to aktiebolag when company_settings is missing', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: null, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.entityType).toBe('aktiebolag')
expect(report.reminders.find((r) => r.code === 'ef_skatt_via_ne')).toBeUndefined()
})
})