Bug/year end numbers (#744)
* fix(bookkeeping): allow creating a fiscal year that fills an interior gap Fiscal-period creation only allowed chaining a new räkenskapsår before the earliest or after the latest existing period, so a company with a gap between years (e.g. 2024 + 2026 from an SIE import, missing 2025) could not create the missing year — it failed with "New period must chain before the earliest or after the latest existing period". Generalise forward chaining onto the new period's immediate predecessor, which covers both appending a new latest year and filling an interior gap. The "prior year must be locked" guard now applies only to true appends, not gap fills (a backfill, like backward chaining). previous_period_id is set to the predecessor and the successor is relinked so the BFNAR 2013:2 continuity chain stays intact. The create dialog suggests the missing year (capped so it never overlaps the next period), the settings page seeds the dialog at the earliest gap, and the default suggested name is now "Räkenskapsår <year>". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): omföra föregående års resultat (2099 → 2098) at year-end Year-end closing posts the result to 2099 "Årets resultat" and the opening balance carried it forward on 2099 every year, so 2099 accumulated across years and the prior result never moved off "Årets resultat". executeYearEndClosing now posts a separate "Omföring av föregående års resultat" verifikat (Dr 2099 / Cr 2098 for a profit, reversed for a loss) into the new period after the continuity check passes, so 2099 starts each year at zero. Kept as a standalone entry rather than folded into the opening balance so the IB stays a faithful mirror of the prior UB and IB/UB continuity still holds. Aktiebolag only; idempotent; no-op when 2099 is flat. The 2098 → 2091/2898 disposition (bolagsstämma decision) is intentionally left to a separate step. - new source_type 'result_appropriation' (migration + type + Zod enum) - generateResultAppropriation helper (planner + poster) wired as step 11 - ResultStep surfaces the omföring voucher - unit tests + pg-real invariant - scripts/repair-result-appropriation.ts: retroactive catch-up (dry-run default) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transactions): shadow-detect date-drift duplicate bank transactions The content-dedup bridge buckets on exact (date, ore), so the same transaction re-imported with a booking date that drifted a day lands in a different bucket and slips past every dedup layer. Add a measure-only ("shadow") detector that flags would-be +/-1-day duplicates and counts them, without changing what is inserted - so the gap can be validated on real data before any enforcement, mirroring the scope-drift shadow. - shiftIsoDate(): pure, deterministic adjacent-date helper - ingest: DEDUP_DATE_DRIFT_MODE flag (default on), pre-loop bucket snapshot, per-row gate with desc-bridge + cross-channel-symmetry signals; logs shadow_date_drift_candidates, never alters inserts - fail-safe date guard so the measurement can never abort an import - regression tests for both signals, account/window/distinct guards, no-double-count, and the malformed-date fail-safe Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(bookkeeping): anonymize a customer reference in fiscal-period tests Remove a real customer name ("AXMD AB") from regression-test comments; no logic change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(workflows): enhance Docker image scanning and caching mechanisms * fix(bookkeeping): enhance year-end result appropriation handling and error reporting --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5c40fa9aeb
commit
2a8bf9b42e
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ============================================================
|
||||
// Mock — client.from() returns a fresh chainable builder whose
|
||||
// terminal maybeSingle()/single() draw from a per-test results array
|
||||
// (same pattern as year-end-service.test.ts).
|
||||
// ============================================================
|
||||
|
||||
let resultIdx: number
|
||||
let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'limit', 'order']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
return b
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
return { from: vi.fn().mockImplementation(() => makeBuilder()) }
|
||||
}
|
||||
|
||||
vi.mock('@/lib/reports/opening-balances', () => ({
|
||||
getOpeningBalances: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
import { generateResultAppropriation } from '../result-appropriation-service'
|
||||
import { getOpeningBalances } from '@/lib/reports/opening-balances'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
|
||||
const FAKE_ENTRY = { id: 'ra-1', voucher_series: 'A', voucher_number: 2 }
|
||||
|
||||
/**
|
||||
* Stub the period's ingående balans (IB) with the given per-account debit/credit
|
||||
* balances. The omföring reads 2099 from here, NOT the full trial balance, so
|
||||
* current-year period activity on 2099 can never skew the reclassified amount.
|
||||
*/
|
||||
function mockOpeningBalance(
|
||||
rows: Array<{ account_number: string; debit: number; credit: number }>
|
||||
) {
|
||||
vi.mocked(getOpeningBalances).mockResolvedValue({
|
||||
balances: new Map(rows.map((r) => [r.account_number, { debit: r.debit, credit: r.credit }])),
|
||||
obEntryId: 'ob-1',
|
||||
} as never)
|
||||
}
|
||||
|
||||
const AB = { data: { entity_type: 'aktiebolag' }, error: null }
|
||||
const NO_EXISTING = { data: null, error: null }
|
||||
const PERIOD = {
|
||||
data: { period_start: '2025-01-01', name: 'FY 2025', opening_balance_entry_id: 'ob-1' },
|
||||
error: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
vi.mocked(createJournalEntry).mockResolvedValue(FAKE_ENTRY as never)
|
||||
})
|
||||
|
||||
describe('generateResultAppropriation', () => {
|
||||
it('posts Dr 2099 / Cr 2098 for a profit (AB)', async () => {
|
||||
results = [AB, NO_EXISTING, PERIOD]
|
||||
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 100000 }])
|
||||
|
||||
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
expect(entry).toEqual(FAKE_ENTRY)
|
||||
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as {
|
||||
source_type: string
|
||||
entry_date: string
|
||||
voucher_series: string
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
expect(input.source_type).toBe('result_appropriation')
|
||||
expect(input.entry_date).toBe('2025-01-01')
|
||||
expect(input.voucher_series).toBe('A')
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2099', debit_amount: 100000, credit_amount: 0 })
|
||||
)
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2098', debit_amount: 0, credit_amount: 100000 })
|
||||
)
|
||||
})
|
||||
|
||||
it('posts Dr 2098 / Cr 2099 for a loss (AB)', async () => {
|
||||
results = [AB, NO_EXISTING, PERIOD]
|
||||
mockOpeningBalance([{ account_number: '2099', debit: 40000, credit: 0 }])
|
||||
|
||||
await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as {
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2098', debit_amount: 40000, credit_amount: 0 })
|
||||
)
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2099', debit_amount: 0, credit_amount: 40000 })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for a non-aktiebolag (enskild firma) without posting', async () => {
|
||||
results = [{ data: { entity_type: 'enskild_firma' }, error: null }]
|
||||
|
||||
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
expect(entry).toBeNull()
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
expect(getOpeningBalances).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is idempotent — returns null when an appropriation entry already exists', async () => {
|
||||
results = [AB, { data: { id: 'ra-existing' }, error: null }]
|
||||
|
||||
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
expect(entry).toBeNull()
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
expect(getOpeningBalances).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 }])
|
||||
|
||||
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
expect(entry).toBeNull()
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults missing company_settings to aktiebolag and posts', async () => {
|
||||
results = [NO_EXISTING /* settings missing */, NO_EXISTING, PERIOD]
|
||||
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 5000 }])
|
||||
|
||||
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
expect(entry).toEqual(FAKE_ENTRY)
|
||||
expect(createJournalEntry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reclassifies the IB 2099 amount only — current-year 2099 activity is excluded', async () => {
|
||||
// getOpeningBalances reads the IB entry (the carried-forward prior result),
|
||||
// not the trial balance, so any current-year postings to 2099 in this period
|
||||
// (e.g. when the catch-up script runs mid-year) cannot inflate the omföring.
|
||||
results = [AB, NO_EXISTING, PERIOD]
|
||||
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 80000 }])
|
||||
|
||||
await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as {
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
// Exactly the IB amount (80000), regardless of any later 2099 activity.
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2099', debit_amount: 80000, credit_amount: 0 })
|
||||
)
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2098', debit_amount: 0, credit_amount: 80000 })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany, insertDraftJournalEntry } from '@/tests/pg/fixtures'
|
||||
import { seedCompany, insertDraftJournalEntry, insertFiscalPeriod } from '@/tests/pg/fixtures'
|
||||
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
|
||||
|
||||
/**
|
||||
@@ -111,6 +111,103 @@ describe('year-end invariants (pg-real)', () => {
|
||||
expect(Math.abs(net)).toBeLessThanOrEqual(ORE_TOLERANCE)
|
||||
})
|
||||
|
||||
it('result appropriation omföring zeros the carried-forward 2099 in the NEW period', async () => {
|
||||
// This mirrors production: the closing entry lands in year N, and the
|
||||
// omföring (2099 → 2098) is posted in the SEPARATE next period (year N+1)
|
||||
// dated its first day — NOT back in the closing period. Posting both in one
|
||||
// period (as a naive test would) hides whether 2099 actually starts the new
|
||||
// year at zero, which is the whole invariant.
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
|
||||
// Year N (2026): closing posts the result onto 2099 — a balanced 3001 → 2099
|
||||
// transfer leaving 2099 with a 5000 credit balance as that year's UB.
|
||||
const closeId = await insertDraftJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
entryDate: '2026-12-31',
|
||||
description: 'Årsbokslut',
|
||||
})
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '3001', 5000.00, 0),
|
||||
($1, '2099', 0, 5000.00)`,
|
||||
[closeId],
|
||||
)
|
||||
await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, closeId])
|
||||
|
||||
// Year N+1 (2027): a fresh open period. The omföring belongs here.
|
||||
const nextPeriodId = await insertFiscalPeriod({
|
||||
userId,
|
||||
companyId,
|
||||
name: '2027',
|
||||
periodStart: '2027-01-01',
|
||||
periodEnd: '2027-12-31',
|
||||
})
|
||||
|
||||
// Opening-balance entry mirrors year N's UB into the new period: 2099 is
|
||||
// carried forward verbatim (1930 IB balances it). This is what leaves 2099
|
||||
// non-zero at the start of the new year — exactly what the omföring fixes.
|
||||
const ibId = await insertDraftJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: nextPeriodId,
|
||||
entryDate: '2027-01-01',
|
||||
description: 'Ingående balans',
|
||||
sourceType: 'opening_balance',
|
||||
})
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '1930', 5000.00, 0),
|
||||
($1, '2099', 0, 5000.00)`,
|
||||
[ibId],
|
||||
)
|
||||
await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, ibId])
|
||||
|
||||
// The year-open omföring: Dr 2099 / Cr 2098, dated the new period's first
|
||||
// day, posted in the new period. source_type must be accepted by the CHECK
|
||||
// constraint (see source-type-constraint.pg.test.ts) and the balance trigger
|
||||
// must pass.
|
||||
const omforId = await insertDraftJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: nextPeriodId,
|
||||
entryDate: '2027-01-01',
|
||||
description: 'Omföring av föregående års resultat (2099 → 2098)',
|
||||
sourceType: 'result_appropriation',
|
||||
})
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '2099', 5000.00, 0),
|
||||
($1, '2098', 0, 5000.00)`,
|
||||
[omforId],
|
||||
)
|
||||
await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, omforId])
|
||||
|
||||
// In the NEW period: 2099 net must be 0 (IB +5000 credit cancelled by the
|
||||
// omföring's 5000 debit); 2098 must hold the result (credit-normal, so
|
||||
// debit − credit = −5000). Scoping to nextPeriodId is the point — 2099 zeros
|
||||
// out in the period the result was carried into, not the closing period.
|
||||
const { rows } = await getPool().query<{ acct: string; net: string }>(
|
||||
`SELECT l.account_number AS acct,
|
||||
COALESCE(SUM(l.debit_amount - l.credit_amount), 0) AS net
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN public.journal_entries je ON je.id = l.journal_entry_id
|
||||
WHERE je.company_id = $1
|
||||
AND je.fiscal_period_id = $2
|
||||
AND je.status = 'posted'
|
||||
AND l.account_number IN ('2099', '2098')
|
||||
GROUP BY l.account_number`,
|
||||
[companyId, nextPeriodId],
|
||||
)
|
||||
const net = Object.fromEntries(rows.map((r) => [r.acct, roundOre(Number(r.net))]))
|
||||
expect(Math.abs(net['2099'] ?? 0)).toBeLessThanOrEqual(ORE_TOLERANCE)
|
||||
expect(net['2098']).toBe(-5000)
|
||||
})
|
||||
|
||||
it('rejects a one-öre IB/UB style discrepancy in opening balance lines', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { getOpeningBalances } from '@/lib/reports/opening-balances'
|
||||
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { JournalEntry, CreateJournalEntryLineInput } from '@/types'
|
||||
|
||||
const log = createLogger('result-appropriation-service')
|
||||
|
||||
/** Årets resultat (current-year result, aktiebolag). */
|
||||
export const RESULT_ACCOUNT = '2099'
|
||||
/** Vinst eller förlust från föregående år. */
|
||||
export const PRIOR_RESULT_ACCOUNT = '2098'
|
||||
|
||||
export interface ResultAppropriationPlan {
|
||||
periodId: string
|
||||
periodName: string
|
||||
/** entry_date for the omföring — the new period's first day. */
|
||||
periodStart: string
|
||||
/** Net 2099 balance, credit-positive (a profit is > 0, a loss is < 0). */
|
||||
net: number
|
||||
/** Absolute, öre-rounded amount that moves between 2099 and 2098. */
|
||||
amount: number
|
||||
direction: 'profit' | 'loss'
|
||||
/** Balanced lines for the omföring verifikat. */
|
||||
lines: CreateJournalEntryLineInput[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only computation of the year-open omföring (no writes). Returns the plan
|
||||
* to move 2099 "Årets resultat" onto 2098 "Vinst eller förlust från föregående
|
||||
* år", or null when there is nothing to do.
|
||||
*
|
||||
* 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
|
||||
* - 2099 carries no balance (within ORE_TOLERANCE).
|
||||
*
|
||||
* Shared by generateResultAppropriation (which posts the plan) and the
|
||||
* retroactive catch-up script (which previews it in dry-run) so the preview
|
||||
* and the committed entry can never diverge.
|
||||
*/
|
||||
export async function planResultAppropriation(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periodId: string,
|
||||
): Promise<ResultAppropriationPlan | null> {
|
||||
// Aktiebolag only. Same resolution as previewYearEndClosing's closing-account
|
||||
// decision, so the omföring runs exactly when the result was posted to 2099.
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
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.
|
||||
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'])
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (existing) return null
|
||||
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, name, opening_balance_entry_id')
|
||||
.eq('id', periodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (!period) throw new Error('Fiscal period not found')
|
||||
|
||||
// Read 2099 from the period's INGÅENDE BALANS only — the carried-forward
|
||||
// prior result that the IB entry mirrored from last year's UB — NOT the full
|
||||
// trial balance. The omföring must reclassify exactly that carried amount;
|
||||
// scoping to IB makes it correct even when the period already has current-year
|
||||
// 2099 activity (e.g. the retroactive catch-up script running mid-year, where
|
||||
// closing = IB + activity would over/under-reclassify). getOpeningBalances
|
||||
// reads the committed opening_balance entry, falling back to a server-side
|
||||
// aggregate of prior posted lines when none is set. credit − debit is positive
|
||||
// for a profit (2099 is credit-normal).
|
||||
const { balances } = await getOpeningBalances(supabase, companyId, period)
|
||||
const ib2099 = balances.get(RESULT_ACCOUNT)
|
||||
const net = ib2099 ? roundOre(ib2099.credit - ib2099.debit) : 0
|
||||
if (Math.abs(net) < ORE_TOLERANCE) return null
|
||||
|
||||
const amount = roundOre(Math.abs(net))
|
||||
const lines: CreateJournalEntryLineInput[] =
|
||||
net > 0
|
||||
? [
|
||||
// Profit: move the credit balance off 2099 onto 2098.
|
||||
{
|
||||
account_number: RESULT_ACCOUNT,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: 'Omföring av föregående års resultat',
|
||||
},
|
||||
{
|
||||
account_number: PRIOR_RESULT_ACCOUNT,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: 'Föregående års resultat',
|
||||
},
|
||||
]
|
||||
: [
|
||||
// Loss: move the debit balance off 2099 onto 2098.
|
||||
{
|
||||
account_number: PRIOR_RESULT_ACCOUNT,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: 'Föregående års resultat',
|
||||
},
|
||||
{
|
||||
account_number: RESULT_ACCOUNT,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: 'Omföring av föregående års resultat',
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
periodId,
|
||||
periodName: period.name,
|
||||
periodStart: period.period_start,
|
||||
net,
|
||||
amount,
|
||||
direction: net > 0 ? 'profit' : 'loss',
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Omföring av föregående års resultat — reclassify 2099 at new-year open.
|
||||
*
|
||||
* After a new fiscal year's opening balances are generated, account 2099
|
||||
* "Årets resultat" carries the prior year's result forward (the IB entry is a
|
||||
* faithful mirror of the prior period's UB). Per BAS practice the prior result
|
||||
* must not remain on 2099: each year must start with 2099 = 0 so it only ever
|
||||
* holds the *current* year's result. This posts the year-open reclassification
|
||||
* as a SEPARATE verifikat in the new period:
|
||||
*
|
||||
* profit (2099 has a credit balance): Dr 2099 / Cr 2098
|
||||
* loss (2099 has a debit balance): Dr 2098 / Cr 2099
|
||||
*
|
||||
* It is deliberately NOT folded into the opening-balance entry. The IB entry
|
||||
* must stay a faithful mirror of the prior UB, or validateBalanceContinuity()
|
||||
* — which reads IB solely from the period's opening_balance entry — would flag
|
||||
* 2099 and 2098 as discrepancies and executeYearEndClosing would self-reverse.
|
||||
* A standalone entry is invisible to that check.
|
||||
*
|
||||
* The further disposition 2098 → 2091 (balanserat resultat) / 2898 (utdelning)
|
||||
* is the bolagsstämma's decision and is intentionally left to a separate step.
|
||||
*
|
||||
* Idempotent / AB-only — see planResultAppropriation for the no-op conditions.
|
||||
* Powers both executeYearEndClosing (steady state) and the retroactive
|
||||
* catch-up script (clears any accumulated 2099 in a company's open period).
|
||||
*/
|
||||
export async function generateResultAppropriation(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
periodId: string,
|
||||
): Promise<JournalEntry | null> {
|
||||
const plan = await planResultAppropriation(supabase, companyId, periodId)
|
||||
if (!plan) return null
|
||||
|
||||
const entry = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: periodId,
|
||||
entry_date: plan.periodStart,
|
||||
description: `Omföring av föregående års resultat (${RESULT_ACCOUNT} → ${PRIOR_RESULT_ACCOUNT})`,
|
||||
source_type: 'result_appropriation',
|
||||
voucher_series: 'A',
|
||||
lines: plan.lines,
|
||||
})
|
||||
|
||||
log.info('Posted result appropriation omföring', {
|
||||
operation: 'result_appropriation.post',
|
||||
companyId,
|
||||
entityType: 'journal_entry',
|
||||
entityId: entry.id,
|
||||
amount: plan.amount,
|
||||
direction: plan.direction,
|
||||
})
|
||||
|
||||
return entry
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const log = createLogger('year-end-service')
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { lockPeriod, closePeriod, createNextPeriod, findNextPeriod } from './period-service'
|
||||
import { generateResultAppropriation } from './result-appropriation-service'
|
||||
import {
|
||||
previewCurrencyRevaluation,
|
||||
executeCurrencyRevaluation,
|
||||
@@ -425,6 +426,7 @@ export async function previewYearEndClosing(
|
||||
* 8. Close the period (irreversible — every guard must run before this)
|
||||
* 9. Generate opening balances in next period
|
||||
* 10. Validate IB/UB continuity
|
||||
* 11. Omföra föregående års resultat (2099 → 2098) in the new period (AB only)
|
||||
*/
|
||||
export async function executeYearEndClosing(
|
||||
supabase: SupabaseClient,
|
||||
@@ -610,6 +612,38 @@ export async function executeYearEndClosing(
|
||||
)
|
||||
}
|
||||
|
||||
// 11. Omföra föregående års resultat: move 2099 "Årets resultat" off onto
|
||||
// 2098 in the new period so it starts the year at zero (aktiebolag only).
|
||||
// This is a SEPARATE verifikat by design — folding it into the IB entry
|
||||
// would make the continuity check above fail, since that check reads IB
|
||||
// solely from the opening_balance entry. Non-fatal: the close and IB are
|
||||
// already valid and immutable; a failure here is logged and left for the
|
||||
// retroactive catch-up script (scripts/repair-result-appropriation.ts).
|
||||
let resultAppropriationEntry: JournalEntry | null = null
|
||||
let resultAppropriationFailed = false
|
||||
try {
|
||||
resultAppropriationEntry = await generateResultAppropriation(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
nextPeriod.id
|
||||
)
|
||||
} catch (err) {
|
||||
resultAppropriationFailed = true
|
||||
// alert:true marks this for out-of-band alerting (the log sink / Sentry
|
||||
// integration filters on it) — a silent accounting failure must not wait
|
||||
// for a manual audit. The new period now opens with 2099 still carrying the
|
||||
// prior result; resultAppropriationFailed below drives a UI warning and the
|
||||
// catch-up script (scripts/repair-result-appropriation.ts) posts the fix.
|
||||
log.error('year-end: result appropriation omföring failed (non-fatal)', err as Error, {
|
||||
operation: 'year_end.result_appropriation',
|
||||
alert: true,
|
||||
companyId,
|
||||
entityType: 'fiscal_period',
|
||||
entityId: nextPeriod.id,
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch the now-closed period for the event payload
|
||||
const { data: closedPeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
@@ -630,6 +664,8 @@ export async function executeYearEndClosing(
|
||||
nextPeriod,
|
||||
openingBalanceEntry,
|
||||
revaluationEntry: revaluationResult?.entry ?? null,
|
||||
resultAppropriationEntry,
|
||||
resultAppropriationFailed,
|
||||
continuity,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user