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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user