4e47335308
* 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>
243 lines
7.3 KiB
TypeScript
243 lines
7.3 KiB
TypeScript
import { describe, it, expect, beforeAll } from 'vitest'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { getPool } from './setup'
|
|
import { seedCompany, insertDraftJournalEntry } from './fixtures'
|
|
|
|
// Escape hatch in enforce_opening_balance_immutability (migration
|
|
// 20260720140000): closing_entry_id may only change once set when the
|
|
// previously referenced closing entry is status='reversed' AND a posted
|
|
// storno entry with reverses_id pointing at it exists (the chain only the
|
|
// engine's reverseEntry() produces). A non-NULL replacement must be a posted
|
|
// year_end entry in the same company and period. Used by the administrative
|
|
// year-end undo flow (scripts/undo-year-end-closing.ts).
|
|
|
|
async function insertStornoOf(params: {
|
|
userId: string
|
|
companyId: string
|
|
fiscalPeriodId: string
|
|
reversesId: string
|
|
voucherNumber: number
|
|
status?: string
|
|
sourceType?: string
|
|
}): Promise<string> {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.journal_entries
|
|
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
|
entry_date, description, source_type, status, reverses_id)
|
|
VALUES ($1, $2, $3, $4, $5, 'A', '2026-12-31', 'Makulering', $6, $7, $8)`,
|
|
[
|
|
id,
|
|
params.userId,
|
|
params.companyId,
|
|
params.fiscalPeriodId,
|
|
params.voucherNumber,
|
|
params.sourceType ?? 'storno',
|
|
params.status ?? 'posted',
|
|
params.reversesId,
|
|
],
|
|
)
|
|
return id
|
|
}
|
|
|
|
describe('closing_entry_id detach escape hatch', () => {
|
|
let companyId: string
|
|
let userId: string
|
|
let fiscalPeriodId: string
|
|
let closingEntryId: string
|
|
|
|
beforeAll(async () => {
|
|
const seeded = await seedCompany()
|
|
companyId = seeded.companyId
|
|
userId = seeded.userId
|
|
fiscalPeriodId = seeded.fiscalPeriodId
|
|
|
|
closingEntryId = await insertDraftJournalEntry({
|
|
userId,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
entryDate: '2026-12-31',
|
|
description: 'Årsbokslut',
|
|
sourceType: 'year_end',
|
|
status: 'posted',
|
|
voucherNumber: 1,
|
|
})
|
|
|
|
await getPool().query(
|
|
`UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`,
|
|
[closingEntryId, fiscalPeriodId],
|
|
)
|
|
})
|
|
|
|
it('blocks detaching a posted (live) closing entry', async () => {
|
|
await expect(
|
|
getPool().query(
|
|
`UPDATE public.fiscal_periods SET closing_entry_id = NULL WHERE id = $1`,
|
|
[fiscalPeriodId],
|
|
),
|
|
).rejects.toThrow(/year-end closing is immutable/)
|
|
})
|
|
|
|
it('blocks detaching when status is reversed but no storno chain exists', async () => {
|
|
await getPool().query(
|
|
`UPDATE public.journal_entries SET status = 'reversed' WHERE id = $1`,
|
|
[closingEntryId],
|
|
)
|
|
|
|
await expect(
|
|
getPool().query(
|
|
`UPDATE public.fiscal_periods SET closing_entry_id = NULL WHERE id = $1`,
|
|
[fiscalPeriodId],
|
|
),
|
|
).rejects.toThrow(/year-end closing is immutable/)
|
|
})
|
|
|
|
it('blocks the escape hatch when the storno is not posted', async () => {
|
|
// closingEntryId is status='reversed' from the previous test.
|
|
await insertStornoOf({
|
|
userId,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
reversesId: closingEntryId,
|
|
voucherNumber: 2,
|
|
status: 'cancelled',
|
|
})
|
|
|
|
await expect(
|
|
getPool().query(
|
|
`UPDATE public.fiscal_periods SET closing_entry_id = NULL WHERE id = $1`,
|
|
[fiscalPeriodId],
|
|
),
|
|
).rejects.toThrow(/year-end closing is immutable/)
|
|
})
|
|
|
|
it('blocks replacing a reversed closing entry with a non-year_end entry', async () => {
|
|
// Complete the storno chain so the reversal itself is now legitimate.
|
|
await insertStornoOf({
|
|
userId,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
reversesId: closingEntryId,
|
|
voucherNumber: 3,
|
|
})
|
|
|
|
const manualId = await insertDraftJournalEntry({
|
|
userId,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
entryDate: '2026-12-31',
|
|
sourceType: 'manual',
|
|
status: 'posted',
|
|
voucherNumber: 4,
|
|
})
|
|
|
|
await expect(
|
|
getPool().query(
|
|
`UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`,
|
|
[manualId, fiscalPeriodId],
|
|
),
|
|
).rejects.toThrow(/must reference a posted year_end entry/)
|
|
})
|
|
|
|
it('allows detaching once the closing entry is reversed with a posted storno', async () => {
|
|
await getPool().query(
|
|
`UPDATE public.fiscal_periods SET closing_entry_id = NULL WHERE id = $1`,
|
|
[fiscalPeriodId],
|
|
)
|
|
|
|
const { rows } = await getPool().query(
|
|
`SELECT closing_entry_id FROM public.fiscal_periods WHERE id = $1`,
|
|
[fiscalPeriodId],
|
|
)
|
|
expect(rows[0].closing_entry_id).toBeNull()
|
|
})
|
|
|
|
it('still allows setting closing_entry_id from NULL (normal year-end run)', async () => {
|
|
const newClosingId = await insertDraftJournalEntry({
|
|
userId,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
entryDate: '2026-12-31',
|
|
sourceType: 'year_end',
|
|
status: 'posted',
|
|
voucherNumber: 5,
|
|
})
|
|
await getPool().query(
|
|
`UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`,
|
|
[newClosingId, fiscalPeriodId],
|
|
)
|
|
const { rows } = await getPool().query(
|
|
`SELECT closing_entry_id FROM public.fiscal_periods WHERE id = $1`,
|
|
[fiscalPeriodId],
|
|
)
|
|
expect(rows[0].closing_entry_id).toBe(newClosingId)
|
|
})
|
|
|
|
it('allows replacing a properly reversed closing entry with a posted year_end entry', async () => {
|
|
// Reverse the current closing entry with a full storno chain, then swap
|
|
// directly to a new posted year_end entry (re-run without detach first).
|
|
const { rows: current } = await getPool().query(
|
|
`SELECT closing_entry_id FROM public.fiscal_periods WHERE id = $1`,
|
|
[fiscalPeriodId],
|
|
)
|
|
const currentClosingId = current[0].closing_entry_id
|
|
|
|
await getPool().query(
|
|
`UPDATE public.journal_entries SET status = 'reversed' WHERE id = $1`,
|
|
[currentClosingId],
|
|
)
|
|
await insertStornoOf({
|
|
userId,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
reversesId: currentClosingId,
|
|
voucherNumber: 6,
|
|
})
|
|
|
|
const replacementId = await insertDraftJournalEntry({
|
|
userId,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
entryDate: '2026-12-31',
|
|
sourceType: 'year_end',
|
|
status: 'posted',
|
|
voucherNumber: 7,
|
|
})
|
|
|
|
await getPool().query(
|
|
`UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`,
|
|
[replacementId, fiscalPeriodId],
|
|
)
|
|
const { rows } = await getPool().query(
|
|
`SELECT closing_entry_id FROM public.fiscal_periods WHERE id = $1`,
|
|
[fiscalPeriodId],
|
|
)
|
|
expect(rows[0].closing_entry_id).toBe(replacementId)
|
|
})
|
|
|
|
it('opening balance immutability is unchanged', async () => {
|
|
const ibEntryId = await insertDraftJournalEntry({
|
|
userId,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
entryDate: '2026-01-01',
|
|
sourceType: 'opening_balance',
|
|
status: 'posted',
|
|
voucherNumber: 8,
|
|
})
|
|
await getPool().query(
|
|
`UPDATE public.fiscal_periods
|
|
SET opening_balance_entry_id = $1, opening_balances_set = true
|
|
WHERE id = $2`,
|
|
[ibEntryId, fiscalPeriodId],
|
|
)
|
|
|
|
await expect(
|
|
getPool().query(
|
|
`UPDATE public.fiscal_periods SET opening_balance_entry_id = NULL WHERE id = $1`,
|
|
[fiscalPeriodId],
|
|
),
|
|
).rejects.toThrow(/opening balances are immutable/)
|
|
})
|
|
})
|