fix(bokslut): årsredovisning for a klarmarkerad year, bokslutsbilagor under Bokslut, bilagor reminder (#1875)
* fix(bokslut): årsredovisning for a klarmarkerad year, bokslutsbilagor under Bokslut in the menu, bilagor reminder on Kontroll A year closed in a previous system (Klarmarkera perioden) has no closing verifikat in these books by definition, so the statutory pre-closing guard in the trial balance has nothing to strip; it now lets that case through instead of failing the whole Årsredovisning page with a 500. Bokslutsbilagor now sits in the Bokslut fold of the menu (a byrå looks for the bilagor inside the bokslut module, not under Rapporter), and the wizard's Kontroll step says how many balance accounts are still unsigned per balansdagen with a link to the pärm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(nav): admit the bokslutsbilagor label key Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * feat(reconciliation): fold the manual accounts in the rail, with count and unsigned hint Twenty-odd balance accounts pushed the bank rows out of view on a migrated company. The group opens when a manual account is selected and otherwise remembers the last choice per browser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
9ce1ebc65f
commit
3dce94d39e
@@ -16,6 +16,10 @@ vi.mock('@/lib/reports/ar-reconciliation', () => ({
|
||||
generateARReconciliation: vi.fn(),
|
||||
}))
|
||||
|
||||
const listAccountsMock = vi.fn()
|
||||
vi.mock('@/lib/reconciliation/service', () => ({
|
||||
listReconciliationAccounts: (...args: unknown[]) => listAccountsMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/reports/supplier-reconciliation', () => ({
|
||||
generateReconciliation: vi.fn(),
|
||||
}))
|
||||
@@ -412,3 +416,50 @@ describe('buildBokslutReadinessReport', () => {
|
||||
expect(vi.mocked(generateAPReconciliation)).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bokslutsbilagor reminder', () => {
|
||||
beforeEach(() => {
|
||||
listAccountsMock.mockReset()
|
||||
})
|
||||
|
||||
it('counts balance accounts not signed off per balansdagen and links to the pärm', async () => {
|
||||
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
||||
listAccountsMock.mockResolvedValue([
|
||||
{ account_key: 'bank:1', kind: 'bank', superseded_by: null, signed_off_through: '2025-12-31' },
|
||||
{ account_key: 'bank:2', kind: 'bank', superseded_by: 'bank:1', signed_off_through: null },
|
||||
{ account_key: 'manual:1510', kind: 'manual', superseded_by: null, signed_off_through: '2025-11-30' },
|
||||
{ account_key: 'manual:2350', kind: 'manual', superseded_by: null, signed_off_through: null },
|
||||
])
|
||||
const report = await buildBokslutReadinessReport(
|
||||
makeSupabase({ period: { data: PERIOD, error: null }, settings: { data: { entity_type: 'aktiebolag' }, error: null } }),
|
||||
'company-1',
|
||||
'user-1',
|
||||
'fp-1',
|
||||
)
|
||||
const reminder = report.reminders.find((r) => r.code === 'bilagor_unsigned')
|
||||
expect(reminder).toMatchObject({ severity: 'warning', href: '/reports/bokslutsbilagor' })
|
||||
expect(reminder?.message).toMatch(/2 av 3 balanskonton/)
|
||||
expect(listAccountsMock).toHaveBeenCalledWith(expect.anything(), 'company-1', expect.objectContaining({ withStatus: false, windowTo: PERIOD.period_end }))
|
||||
})
|
||||
|
||||
it('says so when every account is signed, and stays silent when the read fails', async () => {
|
||||
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
||||
listAccountsMock.mockResolvedValue([{ account_key: 'bank:1', kind: 'bank', superseded_by: null, signed_off_through: '2025-12-31' }])
|
||||
const done = await buildBokslutReadinessReport(
|
||||
makeSupabase({ period: { data: PERIOD, error: null }, settings: { data: { entity_type: 'aktiebolag' }, error: null } }),
|
||||
'company-1',
|
||||
'user-1',
|
||||
'fp-1',
|
||||
)
|
||||
expect(done.reminders.find((r) => r.code === 'bilagor_unsigned')).toMatchObject({ severity: 'info' })
|
||||
|
||||
listAccountsMock.mockRejectedValue(new Error('down'))
|
||||
const failed = await buildBokslutReadinessReport(
|
||||
makeSupabase({ period: { data: PERIOD, error: null }, settings: { data: { entity_type: 'aktiebolag' }, error: null } }),
|
||||
'company-1',
|
||||
'user-1',
|
||||
'fp-1',
|
||||
)
|
||||
expect(failed.reminders.find((r) => r.code === 'bilagor_unsigned')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { generateReconciliation as generateAPReconciliation } from '@/lib/report
|
||||
import { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { describeFiscalYearGap, findFiscalYearGaps, type PeriodLike } from '@/lib/bookkeeping/fiscal-year-gaps'
|
||||
import { listReconciliationAccounts } from '@/lib/reconciliation/service'
|
||||
import type { YearEndBlocker, YearEndValidation } from '@/types'
|
||||
|
||||
const log = createLogger('bokslut-readiness')
|
||||
@@ -234,6 +235,32 @@ export async function buildBokslutReadinessReport(
|
||||
'Periodiseringar (förutbetalda kostnader 17xx, upplupna kostnader 29xx) bokas manuellt. Tänk på att vända dem 1 januari nästa år.',
|
||||
})
|
||||
|
||||
// Bokslutsbilagor: every balance account signed off per balansdagen is what
|
||||
// Reko 760/765 asks for. Advisory: a failed read costs only this reminder.
|
||||
try {
|
||||
const accounts = await listReconciliationAccounts(supabase, companyId, {
|
||||
today: period.period_end,
|
||||
windowFrom: period.period_start,
|
||||
windowTo: period.period_end,
|
||||
withStatus: false,
|
||||
})
|
||||
const live = accounts.filter((a) => !a.superseded_by)
|
||||
const unsigned = live.filter((a) => !a.signed_off_through || a.signed_off_through < period.period_end)
|
||||
if (live.length > 0) {
|
||||
reminders.push({
|
||||
code: 'bilagor_unsigned',
|
||||
severity: unsigned.length > 0 ? 'warning' : 'info',
|
||||
message:
|
||||
unsigned.length > 0
|
||||
? `${unsigned.length} av ${live.length} balanskonton är inte signerade per balansdagen ${period.period_end}. Bokslutsbilagorna samlar avstämning, underlag och signering per konto.`
|
||||
: `Alla ${live.length} balanskonton är signerade per balansdagen ${period.period_end}. Bokslutsbilagorna kan skrivas ut.`,
|
||||
href: '/reports/bokslutsbilagor',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('bilagor reminder failed', { companyId, fiscalPeriodId, error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
|
||||
if (entityType === 'enskild_firma') {
|
||||
// Pre-compute the EF declaration so the wizard's overview reflects what
|
||||
// the user will see when they reach the dispositions step. Egenavgifter,
|
||||
|
||||
@@ -618,6 +618,33 @@ describe('generateTrialBalance', () => {
|
||||
expect(tables).not.toContain('journal_entry_lines')
|
||||
})
|
||||
|
||||
it('does not fail closed for a period klarmarkerad in a previous system (closed_externally)', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{
|
||||
data: {
|
||||
period_start: '2024-01-01',
|
||||
period_end: '2024-12-31',
|
||||
opening_balance_entry_id: null,
|
||||
closing_entry_id: null,
|
||||
is_closed: true,
|
||||
closed_externally: true,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
journal_entries: [{ data: [], error: null }],
|
||||
journal_entry_lines: [{ data: [], error: null }],
|
||||
chart_of_accounts: [{ data: [], error: null }],
|
||||
}
|
||||
|
||||
// The closing verifikat lives in the old software: nothing to strip, the
|
||||
// booked balances are the statutory pre-closing balances.
|
||||
await expect(
|
||||
generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'exclude-final' }),
|
||||
).resolves.toMatchObject({ isBalanced: true })
|
||||
})
|
||||
|
||||
it('keeps year-end adjustments for an open period without a final closing entry', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function generateTrialBalance(
|
||||
const [periodResult, yearEndIdRows, accounts] = await Promise.all([
|
||||
supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end, opening_balance_entry_id, closing_entry_id, is_closed')
|
||||
.select('period_start, period_end, opening_balance_entry_id, closing_entry_id, is_closed, closed_externally')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single(),
|
||||
@@ -132,11 +132,15 @@ export async function generateTrialBalance(
|
||||
// Statutory annual reports must exclude only the linked final closing entry:
|
||||
// tax, depreciation, and appropriations also use source_type year_end. A
|
||||
// closed period without the link is ambiguous, so fail instead of silently
|
||||
// understating the statutory report.
|
||||
// understating the statutory report. A period klarmarkerad as closed in a
|
||||
// previous system (closed_externally) is the one unambiguous case: its
|
||||
// closing verifikat never existed in these books, so there is nothing to
|
||||
// strip and the balances as booked are the pre-closing balances.
|
||||
if (
|
||||
excludeFinalOnly
|
||||
&& period?.is_closed === true
|
||||
&& !period.closing_entry_id
|
||||
&& period.closed_externally !== true
|
||||
) {
|
||||
throw new Error(
|
||||
'Closed fiscal period is missing closing_entry_id; statutory pre-closing balances cannot be generated safely',
|
||||
|
||||
Reference in New Issue
Block a user