diff --git a/lib/core/bookkeeping/__tests__/result-appropriation-repair.test.ts b/lib/core/bookkeeping/__tests__/result-appropriation-repair.test.ts new file mode 100644 index 00000000..65b5fe61 --- /dev/null +++ b/lib/core/bookkeeping/__tests__/result-appropriation-repair.test.ts @@ -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 { + 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), + ) + + 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() + }) +}) diff --git a/lib/core/bookkeeping/result-appropriation-repair.ts b/lib/core/bookkeeping/result-appropriation-repair.ts new file mode 100644 index 00000000..2c092ab3 --- /dev/null +++ b/lib/core/bookkeeping/result-appropriation-repair.ts @@ -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 + 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, + ): 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 { + 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({ + 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 { + 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 } +} diff --git a/scripts/repair-result-appropriation.ts b/scripts/repair-result-appropriation.ts index bd7a4df0..53277464 100644 --- a/scripts/repair-result-appropriation.ts +++ b/scripts/repair-result-appropriation.ts @@ -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 + * npx tsx scripts/repair-result-appropriation.ts --company-id --period-id * - * # Apply (post the omföring entries), attributing to a specific user - * npx tsx scripts/repair-result-appropriation.ts --commit --user-id + * # Apply one reviewed period only. + * npx tsx scripts/repair-result-appropriation.ts --commit \ + * --company-id --period-id --user-id * - * 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 = { + 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 { 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 { - if (USER_ID_OVERRIDE) return USER_ID_OVERRIDE +): Promise> { + 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 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) })