fix(year-end): conservative historical repair for carried-forward 2099 (#1373)

* fix(year-end): conservative historical repair for carried-forward 2099

The steady-state year-end flow already reclassifies the opening 2099
(Arets resultat) to 2098 (Foregaende ars resultat) right after the
opening balance is generated. Periods opened before that fix still
carry the prior year's result on 2099.

Add lib/core/bookkeeping/result-appropriation-repair.ts: a pure
classifier plus assess/post helpers that auto-post the 2099 -> 2098
transfer only when it is unambiguous (open unlocked aktiebolag period,
posted explicit opening_balance entry, active 2099/2098 accounts, no
posted result_appropriation yet, current posted 2099 still equal to the
explicit opening amount, and no other entry touching 2099). Everything
else is skipped or listed for manual review; nothing is reconstructed
from cumulative history. All writes go through the bookkeeping engine.

Rework scripts/repair-result-appropriation.ts into a thin CLI over the
library: global/company/period dry-runs, and commit mode that requires
one exact --company-id, --period-id, and --user-id and re-assesses
immediately before posting.

Fixes #735

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(year-end): require company membership for repair attribution

Compliance review (ASVS V8.2.1): commit mode accepted any --user-id and
attributed the posted journal entry to it unvalidated. The service-role
client bypasses RLS, so nothing downstream would catch an outsider uuid.
postHistoricalResultRepair now verifies a company_members row for the
target company before posting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(year-end): reference the opening-balance underlag on the repair verifikat

Swedish compliance review (BFL 5 kap 6-7 §§): the historical repair
entry validated against a specific opening-balance entry but never
recorded it. Link it machine-readably via source_id and human-readably
in the entry note ("Underlag: ingående balans, verifikat A1 (<id>)").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(year-end): harden repair CLI arg parsing, pagination and exit code

CodeRabbit review on #1373:
- arg() rejects flag-shaped or missing values instead of silently
  consuming the next flag as an id
- global company and period scans paginate via fetchAllRows() so
  deployments past the PostgREST 1000-row cap are fully covered
- exit code is non-zero when any period failed to list, assess or post

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-03 18:41:54 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 5d7952a01e
commit a2f7132c94
3 changed files with 921 additions and 150 deletions
@@ -0,0 +1,327 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
}))
vi.mock('@/lib/bookkeeping/entry-lines', () => ({
fetchEntryLines: vi.fn(),
}))
import type { SupabaseClient } from '@supabase/supabase-js'
import {
assertRepairAttributionUser,
classifyHistoricalResultRepair,
getHistoricalResultRepairScopeError,
type HistoricalResultRepairSnapshot,
} from '../result-appropriation-repair'
function snapshot(
overrides: Partial<HistoricalResultRepairSnapshot> = {},
): HistoricalResultRepairSnapshot {
return {
companyId: 'company-1',
periodId: 'period-2025',
periodName: '2025',
periodStart: '2025-01-01',
entityType: 'aktiebolag',
isClosed: false,
lockedAt: null,
openingBalanceEntryId: 'opening-1',
openingBalanceEntryValid: true,
openingBalanceVoucherLabel: 'A1',
requiredAccountsActive: true,
existingPostedAppropriation: false,
resultAccountLines: [
{
journal_entry_id: 'opening-1',
debit_amount: 0,
credit_amount: 125_000,
},
],
...overrides,
}
}
describe('classifyHistoricalResultRepair', () => {
it('plans the current posted profit from 2099 to 2098 when it still equals the explicit opening balance', () => {
const result = classifyHistoricalResultRepair(snapshot())
expect(result.status).toBe('safe')
expect(result.reason).toBe('ready')
expect(result.openingNet).toBe(125_000)
expect(result.currentNet).toBe(125_000)
expect(result.plan?.direction).toBe('profit')
expect(result.plan?.lines).toEqual([
expect.objectContaining({
account_number: '2099',
debit_amount: 125_000,
credit_amount: 0,
}),
expect.objectContaining({
account_number: '2098',
debit_amount: 0,
credit_amount: 125_000,
}),
])
expect(result.plan?.openingBalanceEntryId).toBe('opening-1')
expect(result.plan?.openingBalanceVoucherLabel).toBe('A1')
})
it('plans the current posted loss in the opposite direction', () => {
const result = classifyHistoricalResultRepair(
snapshot({
resultAccountLines: [
{
journal_entry_id: 'opening-1',
debit_amount: 42_500.25,
credit_amount: 0,
},
],
}),
)
expect(result.status).toBe('safe')
expect(result.plan?.direction).toBe('loss')
expect(result.plan?.amount).toBe(42_500.25)
expect(result.plan?.lines).toEqual([
expect.objectContaining({
account_number: '2098',
debit_amount: 42_500.25,
credit_amount: 0,
}),
expect.objectContaining({
account_number: '2099',
debit_amount: 0,
credit_amount: 42_500.25,
}),
])
})
it('skips an already-disposed result when current posted 2099 is zero', () => {
const result = classifyHistoricalResultRepair(
snapshot({
resultAccountLines: [
{
journal_entry_id: 'opening-1',
debit_amount: 0,
credit_amount: 125_000,
},
{
journal_entry_id: 'manual-disposition',
debit_amount: 125_000,
credit_amount: 0,
},
],
}),
)
expect(result).toMatchObject({
status: 'skipped',
reason: 'already_disposed',
openingNet: 125_000,
currentNet: 0,
plan: null,
})
})
it('sends a changed current balance to manual review instead of moving the frozen opening amount', () => {
const result = classifyHistoricalResultRepair(
snapshot({
resultAccountLines: [
{
journal_entry_id: 'opening-1',
debit_amount: 0,
credit_amount: 125_000,
},
{
journal_entry_id: 'later-2099-entry',
debit_amount: 25_000,
credit_amount: 0,
},
],
}),
)
expect(result).toMatchObject({
status: 'manual_review',
reason: 'current_balance_differs',
openingNet: 125_000,
currentNet: 100_000,
nonOpeningActivityEntries: 1,
plan: null,
})
})
it('treats offsetting non-opening 2099 activity as ambiguous even when the net still matches', () => {
const result = classifyHistoricalResultRepair(
snapshot({
resultAccountLines: [
{
journal_entry_id: 'opening-1',
debit_amount: 0,
credit_amount: 125_000,
},
{
journal_entry_id: 'later-debit',
debit_amount: 10_000,
credit_amount: 0,
},
{
journal_entry_id: 'later-credit',
debit_amount: 0,
credit_amount: 10_000,
},
],
}),
)
expect(result).toMatchObject({
status: 'manual_review',
reason: 'intervening_2099_activity',
openingNet: 125_000,
currentNet: 125_000,
nonOpeningActivityEntries: 2,
plan: null,
})
})
it.each([
{
label: 'missing opening-balance pointer',
overrides: { openingBalanceEntryId: null },
status: 'manual_review',
reason: 'missing_explicit_opening_balance',
},
{
label: 'invalid opening-balance entry',
overrides: { openingBalanceEntryValid: false },
status: 'manual_review',
reason: 'invalid_opening_balance_entry',
},
{
label: 'missing required BAS account',
overrides: { requiredAccountsActive: false },
status: 'manual_review',
reason: 'missing_required_accounts',
},
{
label: 'existing posted correction',
overrides: { existingPostedAppropriation: true },
status: 'skipped',
reason: 'already_corrected',
},
{
label: 'closed period',
overrides: { isClosed: true },
status: 'skipped',
reason: 'period_closed',
},
{
label: 'locked period',
overrides: { lockedAt: '2025-06-01T00:00:00Z' },
status: 'skipped',
reason: 'period_locked',
},
{
label: 'enskild firma',
overrides: { entityType: 'enskild_firma' },
status: 'skipped',
reason: 'non_aktiebolag',
},
])('does not auto-post for $label', ({ overrides, status, reason }) => {
const result = classifyHistoricalResultRepair(
snapshot(overrides as Partial<HistoricalResultRepairSnapshot>),
)
expect(result.status).toBe(status)
expect(result.reason).toBe(reason)
expect(result.plan).toBeNull()
})
it('skips an explicit zero opening result', () => {
const result = classifyHistoricalResultRepair(
snapshot({
resultAccountLines: [
{
journal_entry_id: 'opening-1',
debit_amount: 0,
credit_amount: 0,
},
],
}),
)
expect(result).toMatchObject({
status: 'skipped',
reason: 'no_result_to_move',
plan: null,
})
})
})
function membershipSupabase(row: { user_id: string } | null, error: { message: string } | null = null) {
const maybeSingle = vi.fn().mockResolvedValue({ data: row, error })
const limit = vi.fn().mockReturnValue({ maybeSingle })
const eqUser = vi.fn().mockReturnValue({ limit })
const eqCompany = vi.fn().mockReturnValue({ eq: eqUser })
const select = vi.fn().mockReturnValue({ eq: eqCompany })
const from = vi.fn().mockReturnValue({ select })
return { client: { from } as unknown as SupabaseClient, from }
}
describe('assertRepairAttributionUser', () => {
it('accepts a user with a membership row in the company', async () => {
const { client, from } = membershipSupabase({ user_id: 'user-1' })
await expect(
assertRepairAttributionUser(client, 'company-1', 'user-1'),
).resolves.toBeUndefined()
expect(from).toHaveBeenCalledWith('company_members')
})
it('refuses to attribute the entry to a non-member', async () => {
const { client } = membershipSupabase(null)
await expect(
assertRepairAttributionUser(client, 'company-1', 'outsider'),
).rejects.toThrow('not a member of company')
})
it('surfaces membership lookup failures instead of posting blind', async () => {
const { client } = membershipSupabase(null, { message: 'connection reset' })
await expect(
assertRepairAttributionUser(client, 'company-1', 'user-1'),
).rejects.toThrow('Failed to verify company membership: connection reset')
})
})
describe('getHistoricalResultRepairScopeError', () => {
it('allows global and company-scoped dry-runs', () => {
expect(getHistoricalResultRepairScopeError({ commit: false })).toBeNull()
expect(
getHistoricalResultRepairScopeError({ commit: false, companyId: 'company-1' }),
).toBeNull()
})
it('requires a company when a dry-run targets one period', () => {
expect(
getHistoricalResultRepairScopeError({ commit: false, periodId: 'period-1' }),
).toBe('--period-id requires --company-id')
})
it('requires one exact company, period, and user for commit mode', () => {
expect(
getHistoricalResultRepairScopeError({ commit: true, companyId: 'company-1' }),
).toMatch('--commit requires')
expect(
getHistoricalResultRepairScopeError({
commit: true,
companyId: 'company-1',
periodId: 'period-1',
userId: 'user-1',
}),
).toBeNull()
})
})
@@ -0,0 +1,404 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { equalOre, isZeroOre, roundOre, sumOre } from '@/lib/money'
import type { CreateJournalEntryLineInput, JournalEntry } from '@/types'
import { PRIOR_RESULT_ACCOUNT, RESULT_ACCOUNT } from './result-appropriation-service'
export type HistoricalResultRepairReason =
| 'ready'
| 'non_aktiebolag'
| 'period_closed'
| 'period_locked'
| 'already_corrected'
| 'missing_explicit_opening_balance'
| 'invalid_opening_balance_entry'
| 'missing_required_accounts'
| 'no_result_to_move'
| 'already_disposed'
| 'current_balance_differs'
| 'intervening_2099_activity'
export interface HistoricalResultRepairSnapshot {
companyId: string
periodId: string
periodName: string
periodStart: string
entityType: string | null
isClosed: boolean
lockedAt: string | null
openingBalanceEntryId: string | null
openingBalanceEntryValid: boolean
/** "A1"-style voucher label of the opening-balance entry, for the underlag reference. */
openingBalanceVoucherLabel: string | null
requiredAccountsActive: boolean
existingPostedAppropriation: boolean
resultAccountLines: Array<{
journal_entry_id: string
debit_amount: number | string | null
credit_amount: number | string | null
}>
}
export interface HistoricalResultRepairPlan {
companyId: string
periodId: string
periodName: string
periodStart: string
openingNet: number
currentNet: number
amount: number
direction: 'profit' | 'loss'
lines: CreateJournalEntryLineInput[]
/** The validated opening-balance entry the repair reclassifies: the verifikat's underlag. */
openingBalanceEntryId: string
openingBalanceVoucherLabel: string | null
}
interface HistoricalResultRepairAssessmentBase {
companyId: string
periodId: string
periodName: string
periodStart: string
openingNet: number
currentNet: number
nonOpeningActivityEntries: number
}
export type HistoricalResultRepairAssessment =
| (HistoricalResultRepairAssessmentBase & {
status: 'safe'
reason: 'ready'
plan: HistoricalResultRepairPlan
})
| (HistoricalResultRepairAssessmentBase & {
status: 'skipped' | 'manual_review'
reason: Exclude<HistoricalResultRepairReason, 'ready'>
plan: null
})
export function getHistoricalResultRepairScopeError(input: {
commit: boolean
companyId?: string
periodId?: string
userId?: string
}): string | null {
if (input.periodId && !input.companyId) {
return '--period-id requires --company-id'
}
if (input.commit && (!input.companyId || !input.periodId || !input.userId)) {
return '--commit requires --company-id, --period-id, and --user-id so one reviewed period is attributed deliberately'
}
return null
}
function resultLines(net: number): CreateJournalEntryLineInput[] {
const amount = roundOre(Math.abs(net))
return net > 0
? [
{
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',
},
]
: [
{
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',
},
]
}
/**
* Classify one historical period without writing.
*
* The normal year-end flow knows that it just generated the opening balance,
* so it can move that opening 2099 onto 2098 immediately. A historical sweep
* has no such certainty: users and SIE imports may already have disposed of the
* result. It is safe to automate only when the explicit opening-balance amount
* is still the complete current posted 2099 balance and no other entry touched
* 2099 in the period. Everything else stays unchanged for manual review.
*/
export function classifyHistoricalResultRepair(
snapshot: HistoricalResultRepairSnapshot,
): HistoricalResultRepairAssessment {
const openingEntryId = snapshot.openingBalanceEntryId
const openingLines = openingEntryId
? snapshot.resultAccountLines.filter((line) => line.journal_entry_id === openingEntryId)
: []
const nonOpeningEntryIds = new Set(
snapshot.resultAccountLines
.filter((line) => line.journal_entry_id !== openingEntryId)
.map((line) => line.journal_entry_id),
)
const openingNet = sumOre(
openingLines.map(
(line) => (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0),
),
)
const currentNet = sumOre(
snapshot.resultAccountLines.map(
(line) => (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0),
),
)
const base: HistoricalResultRepairAssessmentBase = {
companyId: snapshot.companyId,
periodId: snapshot.periodId,
periodName: snapshot.periodName,
periodStart: snapshot.periodStart,
openingNet,
currentNet,
nonOpeningActivityEntries: nonOpeningEntryIds.size,
}
const finish = (
status: 'skipped' | 'manual_review',
reason: Exclude<HistoricalResultRepairReason, 'ready'>,
): HistoricalResultRepairAssessment => ({ ...base, status, reason, plan: null })
if ((snapshot.entityType ?? 'aktiebolag') !== 'aktiebolag') {
return finish('skipped', 'non_aktiebolag')
}
if (snapshot.isClosed) return finish('skipped', 'period_closed')
if (snapshot.lockedAt) return finish('skipped', 'period_locked')
if (snapshot.existingPostedAppropriation) {
return finish('skipped', 'already_corrected')
}
if (!openingEntryId) {
return finish('manual_review', 'missing_explicit_opening_balance')
}
if (!snapshot.openingBalanceEntryValid) {
return finish('manual_review', 'invalid_opening_balance_entry')
}
if (!snapshot.requiredAccountsActive) {
return finish('manual_review', 'missing_required_accounts')
}
if (isZeroOre(openingNet) && isZeroOre(currentNet)) {
return finish('skipped', 'no_result_to_move')
}
if (!isZeroOre(openingNet) && isZeroOre(currentNet)) {
return finish('skipped', 'already_disposed')
}
if (!equalOre(currentNet, openingNet)) {
return finish('manual_review', 'current_balance_differs')
}
if (nonOpeningEntryIds.size > 0) {
return finish('manual_review', 'intervening_2099_activity')
}
const amount = roundOre(Math.abs(currentNet))
const plan: HistoricalResultRepairPlan = {
companyId: snapshot.companyId,
periodId: snapshot.periodId,
periodName: snapshot.periodName,
periodStart: snapshot.periodStart,
openingNet,
currentNet,
amount,
direction: currentNet > 0 ? 'profit' : 'loss',
lines: resultLines(currentNet),
openingBalanceEntryId: openingEntryId,
openingBalanceVoucherLabel: snapshot.openingBalanceVoucherLabel,
}
return { ...base, status: 'safe', reason: 'ready', plan }
}
/** Load and classify one historical period. This function never writes. */
export async function assessHistoricalResultRepair(
supabase: SupabaseClient,
companyId: string,
periodId: string,
): Promise<HistoricalResultRepairAssessment> {
const [settingsResult, periodResult, existingResult] = await Promise.all([
supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle(),
supabase
.from('fiscal_periods')
.select('name, period_start, is_closed, locked_at, opening_balance_entry_id')
.eq('id', periodId)
.eq('company_id', companyId)
.single(),
supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('fiscal_period_id', periodId)
.eq('source_type', 'result_appropriation')
.eq('status', 'posted')
.limit(1)
.maybeSingle(),
])
if (settingsResult.error) {
throw new Error(`Failed to read company settings: ${settingsResult.error.message}`)
}
if (periodResult.error || !periodResult.data) {
throw new Error(`Failed to read fiscal period: ${periodResult.error?.message ?? 'not found'}`)
}
if (existingResult.error) {
throw new Error(`Failed to check existing result appropriation: ${existingResult.error.message}`)
}
const period = periodResult.data
const openingBalanceEntryId = period.opening_balance_entry_id as string | null
const baseSnapshot: HistoricalResultRepairSnapshot = {
companyId,
periodId,
periodName: period.name,
periodStart: period.period_start,
entityType: settingsResult.data?.entity_type ?? null,
isClosed: period.is_closed,
lockedAt: period.locked_at,
openingBalanceEntryId,
openingBalanceEntryValid: false,
openingBalanceVoucherLabel: null,
requiredAccountsActive: false,
existingPostedAppropriation: Boolean(existingResult.data),
resultAccountLines: [],
}
if (
(baseSnapshot.entityType ?? 'aktiebolag') !== 'aktiebolag' ||
baseSnapshot.isClosed ||
baseSnapshot.lockedAt ||
baseSnapshot.existingPostedAppropriation ||
!openingBalanceEntryId
) {
return classifyHistoricalResultRepair(baseSnapshot)
}
const [openingEntryResult, requiredAccountsResult, resultAccountLines] = await Promise.all([
supabase
.from('journal_entries')
.select('id, status, source_type, voucher_series, voucher_number')
.eq('id', openingBalanceEntryId)
.eq('company_id', companyId)
.eq('fiscal_period_id', periodId)
.maybeSingle(),
supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.eq('is_active', true)
.in('account_number', [RESULT_ACCOUNT, PRIOR_RESULT_ACCOUNT]),
fetchEntryLines<HistoricalResultRepairSnapshot['resultAccountLines'][number]>({
supabase,
lineColumns: 'id, journal_entry_id, debit_amount, credit_amount',
filterEntries: (query: EntryLinesQuery) =>
query
.eq('company_id', companyId)
.eq('fiscal_period_id', periodId)
.in('status', ['posted', 'reversed']),
filterLines: (query: EntryLinesQuery) => query.eq('account_number', RESULT_ACCOUNT),
attachEntriesAs: null,
}),
])
if (openingEntryResult.error) {
throw new Error(`Failed to read opening-balance entry: ${openingEntryResult.error.message}`)
}
if (requiredAccountsResult.error) {
throw new Error(`Failed to read required accounts: ${requiredAccountsResult.error.message}`)
}
const activeAccounts = new Set(
(requiredAccountsResult.data ?? []).map((row) => row.account_number),
)
const openingEntry = openingEntryResult.data
return classifyHistoricalResultRepair({
...baseSnapshot,
openingBalanceEntryValid:
openingEntry?.status === 'posted' && openingEntry.source_type === 'opening_balance',
openingBalanceVoucherLabel:
openingEntry?.voucher_series != null && openingEntry.voucher_number != null
? `${openingEntry.voucher_series}${openingEntry.voucher_number}`
: null,
requiredAccountsActive:
activeAccounts.has(RESULT_ACCOUNT) && activeAccounts.has(PRIOR_RESULT_ACCOUNT),
resultAccountLines,
})
}
/**
* The posted entry is attributed to userId. Require an actual membership row
* so a mistyped uuid cannot attribute a financial journal entry to a user
* outside the company (service-role scripts bypass RLS, so nothing else
* would catch it).
*/
export async function assertRepairAttributionUser(
supabase: SupabaseClient,
companyId: string,
userId: string,
): Promise<void> {
const { data, error } = await supabase
.from('company_members')
.select('user_id')
.eq('company_id', companyId)
.eq('user_id', userId)
.limit(1)
.maybeSingle()
if (error) {
throw new Error(`Failed to verify company membership: ${error.message}`)
}
if (!data) {
throw new Error(
`User ${userId} is not a member of company ${companyId}: refusing to attribute the repair entry`,
)
}
}
/**
* Re-assess immediately before posting and write only an unambiguous plan.
* All journal writes still pass through the bookkeeping engine.
*/
export async function postHistoricalResultRepair(
supabase: SupabaseClient,
companyId: string,
userId: string,
periodId: string,
): Promise<{
assessment: HistoricalResultRepairAssessment
entry: JournalEntry | null
}> {
await assertRepairAttributionUser(supabase, companyId, userId)
const assessment = await assessHistoricalResultRepair(supabase, companyId, periodId)
if (assessment.status !== 'safe') return { assessment, entry: null }
// BFL 5 kap 6-7 §§: the verifikat must reference its underlag. For this
// historical repair the underlag is the validated opening-balance entry,
// linked machine-readably via source_id and human-readably in the note.
const underlagLabel = assessment.plan.openingBalanceVoucherLabel
? `verifikat ${assessment.plan.openingBalanceVoucherLabel}`
: `verifikat ${assessment.plan.openingBalanceEntryId}`
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: periodId,
entry_date: assessment.plan.periodStart,
description: `Omföring av föregående års resultat (${RESULT_ACCOUNT} → ${PRIOR_RESULT_ACCOUNT})`,
source_type: 'result_appropriation',
source_id: assessment.plan.openingBalanceEntryId,
voucher_series: 'A',
notes: `Underlag: ingående balans, ${underlagLabel} (${assessment.plan.openingBalanceEntryId}). Historisk rättelse av kvarliggande föregående års resultat på ${RESULT_ACCOUNT}.`,
lines: assessment.plan.lines,
})
return { assessment, entry }
}
+190 -150
View File
@@ -1,66 +1,84 @@
#!/usr/bin/env npx tsx
/**
* Retroactive catch-up for the year-open result omföring (2099 → 2098).
* Conservative historical catch-up for the year-open result transfer
* (2099 -> 2098).
*
* Problem: before generateResultAppropriation existed, year-end closing posted
* the result to 2099 "Årets resultat" and the opening-balance entry carried it
* forward verbatim. 2099 was therefore re-opened on 2099 every year and the
* prior result accumulated there instead of being moved off "Årets resultat".
* Normal year-end closing already posts this transfer immediately after it
* creates the next period's opening balance. This script is only for periods
* created before that behavior existed.
*
* Fix (per affected aktiebolag): for EACH of the company's open (unlocked,
* unclosed) periods, post one balanced omföring verifikat that clears the 2099
* balance the period's ingående balans carried forward (Dr 2099 / Cr 2098 for a
* profit, reversed for a loss). Each period is handled independently: this is
* NOT a single lump-sum across years. A period whose 2099 is already flat (or
* already has a result_appropriation entry) is skipped. No closed/locked years
* are touched: entries land in open periods and respect every BFL trigger. This
* corrects the balance sheet going forward; it does not reconstruct per-year
* history (which would require reopening closed years).
* Historical data cannot be repaired from the frozen opening balance alone.
* A user or SIE import may already have disposed of 2099, and replaying the
* opening amount would then move equity twice. The script therefore requires:
*
* The actual posting and all no-op gating (AB-only, idempotency, zero balance)
* are delegated to the SAME helper the year-end flow uses, so the catch-up and
* the steady-state behaviour can never diverge.
* - an open and unlocked aktiebolag period,
* - an explicit, posted opening_balance entry,
* - active 2099 and 2098 accounts,
* - no existing posted result_appropriation entry,
* - current posted 2099 equal to the explicit opening 2099, and
* - no other entry touching 2099 in the period.
*
* Attribution (BFL 5 kap 6§): the omföring verifikat is attributed to a user.
* Pass --user-id to set it explicitly. Otherwise it defaults to the company
* owner; only if no owner row exists does it fall back to an arbitrary member,
* and that fallback prints a loud WARNING so a misattributed rättelse can't slip
* through unnoticed.
* Everything else is skipped or printed for manual review. Periods without an
* explicit opening-balance entry are never reconstructed from cumulative
* history. All writes use the bookkeeping engine and commit mode re-assesses
* the period immediately before posting. The --user-id the entry is
* attributed to must be a member of the company.
*
* Usage:
* # Preview every affected company (read-only)
* # Preview every company. Read-only.
* npx tsx scripts/repair-result-appropriation.ts
*
* # Preview a single company
* # Preview one company or one exact period.
* npx tsx scripts/repair-result-appropriation.ts --company-id <uuid>
* npx tsx scripts/repair-result-appropriation.ts --company-id <uuid> --period-id <uuid>
*
* # Apply (post the omföring entries), attributing to a specific user
* npx tsx scripts/repair-result-appropriation.ts --commit --user-id <uuid>
* # Apply one reviewed period only.
* npx tsx scripts/repair-result-appropriation.ts --commit \
* --company-id <uuid> --period-id <uuid> --user-id <uuid>
*
* Run against staging first; only run against prod after reviewing the dry-run.
* Run a reviewed dry-run against staging first. Production writes require
* explicit approval for the exact company, period, amount, and attribution.
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '../lib/supabase/fetch-all'
import {
planResultAppropriation,
generateResultAppropriation,
} from '../lib/core/bookkeeping/result-appropriation-service'
// ──────────────────────────────────────────────────────────────────
// Args + client
// ──────────────────────────────────────────────────────────────────
assessHistoricalResultRepair,
getHistoricalResultRepairScopeError,
postHistoricalResultRepair,
type HistoricalResultRepairAssessment,
type HistoricalResultRepairReason,
} from '../lib/core/bookkeeping/result-appropriation-repair'
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`)
return i >= 0 ? process.argv[i + 1] : undefined
const index = process.argv.indexOf(`--${name}`)
if (index < 0) return undefined
const value = process.argv[index + 1]
if (value === undefined || value.startsWith('--')) {
console.error(`--${name} requires a value`)
process.exit(1)
}
return value
}
const ONLY_COMPANY_ID = arg('company-id')
const USER_ID_OVERRIDE = arg('user-id')
const ONLY_PERIOD_ID = arg('period-id')
const USER_ID = arg('user-id')
const COMMIT = process.argv.includes('--commit')
const scopeError = getHistoricalResultRepairScopeError({
commit: COMMIT,
companyId: ONLY_COMPANY_ID,
periodId: ONLY_PERIOD_ID,
userId: USER_ID,
})
if (scopeError) {
console.error(scopeError)
process.exit(1)
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
@@ -71,166 +89,188 @@ if (!supabaseUrl || !serviceRoleKey) {
const supabase = createClient(supabaseUrl, serviceRoleKey) as SupabaseClient
console.log('─────────────────────────────────────────────────────────')
console.log('Result Appropriation Catch-up (2099 → 2098)')
console.log('─────────────────────────────────────────────────────────')
console.log('Supabase URL :', supabaseUrl)
console.log('Scope :', ONLY_COMPANY_ID ? `company ${ONLY_COMPANY_ID}` : 'ALL companies')
console.log('Attribution :', USER_ID_OVERRIDE ? `user ${USER_ID_OVERRIDE} (--user-id)` : 'company owner (fallback: any member)')
console.log('Mode :', COMMIT ? 'COMMIT (writes)' : 'DRY RUN (no writes)')
console.log('─────────────────────────────────────────────────────────\n')
const REASON_LABELS: Record<HistoricalResultRepairReason, string> = {
ready: 'safe to repair',
non_aktiebolag: 'not an aktiebolag',
period_closed: 'period is closed',
period_locked: 'period is locked',
already_corrected: 'posted result appropriation already exists',
missing_explicit_opening_balance: 'no explicit opening-balance entry',
invalid_opening_balance_entry: 'opening-balance pointer is not a posted opening_balance entry',
missing_required_accounts: 'active 2099 and 2098 accounts are required',
no_result_to_move: 'opening and current 2099 are zero',
already_disposed: 'current 2099 is zero, so the result has already been disposed',
current_balance_differs: 'current 2099 differs from the explicit opening amount',
intervening_2099_activity: 'another entry touched 2099 in the period',
}
// ──────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────
console.log('---------------------------------------------------------')
console.log('Historical Result Appropriation Repair (2099 -> 2098)')
console.log('---------------------------------------------------------')
console.log('Supabase URL :', supabaseUrl)
console.log('Company scope:', ONLY_COMPANY_ID ?? 'ALL companies')
console.log('Period scope :', ONLY_PERIOD_ID ?? 'all open periods')
console.log('Attribution :', USER_ID ?? 'not needed for dry-run')
console.log('Mode :', COMMIT ? 'COMMIT (writes one reviewed period)' : 'DRY RUN (no writes)')
console.log('---------------------------------------------------------\n')
async function listCompanyIds(): Promise<string[]> {
if (ONLY_COMPANY_ID) return [ONLY_COMPANY_ID]
const { data, error } = await supabase
.from('companies')
.select('id')
.order('created_at', { ascending: true })
if (error) throw new Error(`Failed to list companies: ${error.message}`)
return (data as { id: string }[]).map((c) => c.id)
const rows = await fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
.from('companies')
.select('id')
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
)
return rows.map((company) => company.id)
}
/** Open periods (not locked, not closed), earliest first. */
async function listOpenPeriods(companyId: string): Promise<{ id: string; name: string }[]> {
const { data, error } = await supabase
.from('fiscal_periods')
.select('id, name')
.eq('company_id', companyId)
.eq('is_closed', false)
.is('locked_at', null)
.order('period_start', { ascending: true })
if (error) throw new Error(`Failed to list open periods for ${companyId}: ${error.message}`)
return (data as { id: string; name: string }[]) ?? []
}
/**
* Resolve the user_id to attribute the verifikat to (BFL 5 kap 6§). Precedence:
* 1. --user-id override (caller takes responsibility for correctness),
* 2. the company owner,
* 3. any member, but this is an arbitrary attribution, so it prints a loud
* WARNING; a rättelse landing on the wrong person must never be silent.
* Returns null only when the company has no members at all.
*/
async function resolveAttributionUserId(
async function listOpenPeriods(
companyId: string,
companyLabel: string,
): Promise<string | null> {
if (USER_ID_OVERRIDE) return USER_ID_OVERRIDE
): Promise<Array<{ id: string; name: string }>> {
const rows = await fetchAllRows<{ id: string; name: string }>(({ from, to }) => {
let query = supabase
.from('fiscal_periods')
.select('id, name')
.eq('company_id', companyId)
.eq('is_closed', false)
.is('locked_at', null)
const { data: owner } = await supabase
.from('company_members')
.select('user_id')
.eq('company_id', companyId)
.eq('role', 'owner')
.order('created_at', { ascending: true })
.limit(1)
.maybeSingle()
if (owner?.user_id) return owner.user_id as string
if (ONLY_PERIOD_ID) query = query.eq('id', ONLY_PERIOD_ID)
// Fallback: any member (e.g. legacy data with no explicit owner row).
const { data: anyMember } = await supabase
.from('company_members')
.select('user_id')
.eq('company_id', companyId)
.limit(1)
.maybeSingle()
const fallbackId = (anyMember?.user_id as string) ?? null
if (fallbackId) {
console.warn(
` ⚠ ${companyLabel}: no owner row: attributing the omföring to an ARBITRARY ` +
`member (${fallbackId}). Pass --user-id <uuid> to attribute it deliberately.`,
)
}
return fallbackId
return query.order('period_start', { ascending: true }).range(from, to)
})
return rows
}
// ──────────────────────────────────────────────────────────────────
// Main
// ──────────────────────────────────────────────────────────────────
function describeAssessment(assessment: HistoricalResultRepairAssessment): string {
const amounts =
`opening 2099=${assessment.openingNet}, current 2099=${assessment.currentNet}, ` +
`other 2099 entries=${assessment.nonOpeningActivityEntries}`
return `${REASON_LABELS[assessment.reason]} (${amounts})`
}
async function main() {
let scanned = 0
let planned = 0
let companiesScanned = 0
let periodsScanned = 0
let safe = 0
let manualReview = 0
let skipped = 0
let posted = 0
let skippedNoOwner = 0
let revalidationBlocked = 0
let failed = 0
const companyIds = await listCompanyIds()
console.log(`Scanning ${companyIds.length} company(ies)…\n`)
console.log(`Scanning ${companyIds.length} company(ies)...\n`)
for (const companyId of companyIds) {
scanned++
let openPeriods: { id: string; name: string }[]
companiesScanned++
let periods: Array<{ id: string; name: string }>
try {
openPeriods = await listOpenPeriods(companyId)
} catch (err) {
console.error(` · ${companyId}: FAILED to list periods:`, err instanceof Error ? err.message : err)
periods = await listOpenPeriods(companyId)
} catch (error) {
console.error(
` ${companyId}: FAILED to list periods:`,
error instanceof Error ? error.message : error,
)
failed++
continue
}
for (const period of openPeriods) {
let plan
for (const period of periods) {
periodsScanned++
let assessment: HistoricalResultRepairAssessment
try {
plan = await planResultAppropriation(supabase, companyId, period.id)
} catch (err) {
assessment = await assessHistoricalResultRepair(supabase, companyId, period.id)
} catch (error) {
console.error(
` · ${companyId} / ${period.name}: FAILED to plan:`,
err instanceof Error ? err.message : err,
` ${companyId} / ${period.name}: FAILED to assess:`,
error instanceof Error ? error.message : error,
)
failed++
continue
}
if (!plan) continue // non-AB, already done, or 2099 flat
planned++
if (assessment.status !== 'safe') {
if (assessment.status === 'skipped') {
skipped++
continue
}
manualReview++
console.warn(
` REVIEW ${companyId} / ${period.name} (${period.id}): ${describeAssessment(assessment)}`,
)
continue
}
safe++
console.log(
` · ${companyId} / ${period.name}: ${plan.direction} ${plan.amount} kr ` +
`: ${plan.lines.map((l) => `${l.account_number} ${l.debit_amount ? `D ${l.debit_amount}` : `K ${l.credit_amount}`}`).join(' / ')}`,
` SAFE ${companyId} / ${period.name} (${period.id}): ` +
`${assessment.plan.direction} ${assessment.plan.amount} kr, ` +
assessment.plan.lines
.map((line) =>
`${line.account_number} ${line.debit_amount ? `D ${line.debit_amount}` : `K ${line.credit_amount}`}`,
)
.join(' / '),
)
if (!COMMIT) continue
const userId = await resolveAttributionUserId(companyId, `${companyId} / ${period.name}`)
if (!userId) {
console.error(` · ${companyId}: SKIPPED: no member to attribute the entry to`)
skippedNoOwner++
continue
}
try {
const entry = await generateResultAppropriation(supabase, companyId, userId, period.id)
if (entry) {
console.log(` → posted ${entry.voucher_series}${entry.voucher_number} (${entry.id})`)
posted++
const result = await postHistoricalResultRepair(
supabase,
companyId,
USER_ID!,
period.id,
)
if (!result.entry) {
revalidationBlocked++
console.warn(
` NOT POSTED after revalidation: ${describeAssessment(result.assessment)}`,
)
continue
}
} catch (err) {
posted++
console.log(
` posted ${result.entry.voucher_series}${result.entry.voucher_number} (${result.entry.id})`,
)
} catch (error) {
console.error(
` · ${companyId} / ${period.name}: FAILED to post:`,
err instanceof Error ? err.message : err,
` ${companyId} / ${period.name}: FAILED to post:`,
error instanceof Error ? error.message : error,
)
failed++
}
}
}
console.log('\n─────────────────────────────────────────────────────────')
console.log('\n---------------------------------------------------------')
console.log('Summary')
console.log('─────────────────────────────────────────────────────────')
console.log(`Companies scanned : ${scanned}`)
console.log(`Omföringar planned: ${planned}`)
console.log(`Omföringar posted : ${posted}`)
console.log(`Skipped (no owner): ${skippedNoOwner}`)
console.log(`Failed : ${failed}`)
console.log(`Mode : ${COMMIT ? 'COMMIT' : 'DRY RUN'}`)
if (!COMMIT) console.log('\nRe-run with --commit to apply.')
console.log('---------------------------------------------------------')
console.log(`Companies scanned : ${companiesScanned}`)
console.log(`Periods scanned : ${periodsScanned}`)
console.log(`Safe candidates : ${safe}`)
console.log(`Manual review : ${manualReview}`)
console.log(`Skipped : ${skipped}`)
console.log(`Posted : ${posted}`)
console.log(`Blocked on recheck : ${revalidationBlocked}`)
console.log(`Failed : ${failed}`)
console.log(`Mode : ${COMMIT ? 'COMMIT' : 'DRY RUN'}`)
if (!COMMIT) {
console.log(
'\nCommit mode requires one reviewed --company-id, --period-id, and --user-id.',
)
}
if (failed > 0) {
process.exitCode = 1
}
}
main().catch((err) => {
console.error('\nFATAL:', err instanceof Error ? err.message : err)
main().catch((error) => {
console.error('\nFATAL:', error instanceof Error ? error.message : error)
process.exit(1)
})