diff --git a/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx b/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx index 9d1cb405..3df702a4 100644 --- a/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx +++ b/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx @@ -251,6 +251,10 @@ export function KassaflodesanalysClient() { /> + 0) { - const outputVat = trialBalanceResult.rows - .filter((r) => vatOverrides.includes(r.account_number) && r.account_number.startsWith('26') && !r.account_number.startsWith('264')) - .reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0) - const inputVat = trialBalanceResult.rows - .filter((r) => vatOverrides.includes(r.account_number) && r.account_number.startsWith('264')) - .reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0) - vatLiability = Math.round((outputVat - inputVat) * 100) / 100 - } else { - const vatOutputAccounts = ['2611', '2621', '2631'] - const vatInputAccounts = ['2641', '2645'] - const outputVat = trialBalanceResult.rows - .filter((r) => vatOutputAccounts.includes(r.account_number)) - .reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0) - const inputVat = trialBalanceResult.rows - .filter((r) => vatInputAccounts.includes(r.account_number)) - .reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0) - vatLiability = Math.round((outputVat - inputVat) * 100) / 100 - } + const vatLiability = calculateVatLiability( + trialBalanceResult.rows, + preferences.accountOverrides['vatLiability'] + ) // Avg payment days from paid invoices const paidInvoices = (paidInvoicesResult.data ?? []).map((inv) => ({ diff --git a/app/api/reports/kpi/xlsx/route.ts b/app/api/reports/kpi/xlsx/route.ts index efae6718..aba4dfc5 100644 --- a/app/api/reports/kpi/xlsx/route.ts +++ b/app/api/reports/kpi/xlsx/route.ts @@ -9,6 +9,7 @@ import { calculateGrossMargin, calculateExpenseRatio, calculateAvgPaymentDays, + calculateVatLiability, } from '@/lib/reports/kpi' import { requireCompanyId } from '@/lib/company/context' import { @@ -102,15 +103,7 @@ export async function GET(request: Request) { ]) const cashPosition = calculateCashPosition(trialBalanceResult.rows) - const vatOutputAccounts = ['2611', '2621', '2631'] - const vatInputAccounts = ['2641', '2645'] - const outputVat = trialBalanceResult.rows - .filter((r) => vatOutputAccounts.includes(r.account_number)) - .reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0) - const inputVat = trialBalanceResult.rows - .filter((r) => vatInputAccounts.includes(r.account_number)) - .reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0) - const vatLiability = Math.round((outputVat - inputVat) * 100) / 100 + const vatLiability = calculateVatLiability(trialBalanceResult.rows) const paidInvoices = (paidInvoicesResult.data ?? []).map((inv) => ({ invoice_date: inv.invoice_date as string, diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 305c06dc..40c800d2 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -67,7 +67,7 @@ vi.mock('@/lib/bookkeeping/account-validation', async () => { ) return { ...actual, - findMissingActiveAccounts: (...args: unknown[]) => mockFindMissingActiveAccounts(...args), + findUnresolvableAccounts: (...args: unknown[]) => mockFindMissingActiveAccounts(...args), } }) diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 8fcadd19..a5d29c06 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -16,7 +16,7 @@ import { normalizeOcrReference, } from '@/lib/invoices/duplicate-payment-guard' import { AccountsNotInChartError, accountsNotInChartResponse, isBookkeepingError } from '@/lib/bookkeeping/errors' -import { collectMappingResultAccounts, findMissingActiveAccounts } from '@/lib/bookkeeping/account-validation' +import { collectMappingResultAccounts, findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation' import { getErrorMessage } from '@/lib/errors/get-error-message' import type { Logger } from '@/lib/logger' import type { CategorizationTemplate } from '@/types' @@ -305,13 +305,17 @@ export const POST = withRouteContext( // catch below silently marks the transaction as bokförd with no // verifikation. Catching it here means the row stays in "Att bokföra" // and the user gets a clear actionable message. - const missingAccounts = await findMissingActiveAccounts( + // + // Only truly unresolvable accounts block: a standard BAS account that is + // merely absent from the chart is seeded on demand by the engine, so the + // user can always book the row without registering accounts first. + const missingAccounts = await findUnresolvableAccounts( supabase, companyId, collectMappingResultAccounts(mappingResult), ) if (missingAccounts.length > 0) { - txLog.warn('mapping references inactive/missing accounts', { missingAccounts }) + txLog.warn('mapping references inactive/unknown accounts', { missingAccounts }) return accountsNotInChartResponse(new AccountsNotInChartError(missingAccounts)) } diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts index b0bbf5ae..da7558c6 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts @@ -70,7 +70,7 @@ vi.mock('@/lib/bookkeeping/account-validation', async () => { ) return { ...actual, - findMissingActiveAccounts: findMissingAccountsMock, + findUnresolvableAccounts: findMissingAccountsMock, } }) // category mapping is real — provides the debit/credit account guarantees. diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts index a7a9a08f..b29f6e0c 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts @@ -35,7 +35,7 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent import { reverseEntry } from '@/lib/bookkeeping/engine' import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine' import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors' -import { collectMappingResultAccounts, findMissingActiveAccounts } from '@/lib/bookkeeping/account-validation' +import { collectMappingResultAccounts, findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation' import { getErrorMessage } from '@/lib/errors/get-error-message' import { eventBus } from '@/lib/events' import type { @@ -293,14 +293,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // would reach the engine and throw AccountsNotInChartError mid-flight, // leaving the legacy partial-success branch to silently mark the row as // bokförd with no verifikation. We validate in both live AND dry-run - // paths so previews surface the same actionable error. - const missingAccounts = await findMissingActiveAccounts( + // paths so previews surface the same actionable error. Standard BAS + // accounts merely absent from the chart pass — the engine seeds them. + const missingAccounts = await findUnresolvableAccounts( ctx.supabase, ctx.companyId!, collectMappingResultAccounts(mappingResult), ) if (missingAccounts.length > 0) { - txLog.warn('mapping references inactive/missing accounts', { missingAccounts }) + txLog.warn('mapping references inactive/unknown accounts', { missingAccounts }) return v1ErrorResponse(new AccountsNotInChartError(missingAccounts), txLog, { requestId: ctx.requestId, }) diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts index 575e35b9..7db088d3 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts @@ -26,8 +26,8 @@ vi.mock('@supabase/supabase-js', async () => { const { createTxJE, findMissingAccountsMock } = vi.hoisted(() => ({ createTxJE: vi.fn().mockResolvedValue({ id: 'je-fresh' }), - // Default: every mapped account exists and is active. Per-test overrides - // simulate the bug surface. + // Default: every mapped account resolves (active, or seedable standard + // BAS). Per-test overrides simulate the bug surface (inactive/unknown). findMissingAccountsMock: vi.fn().mockResolvedValue([]), })) @@ -43,7 +43,7 @@ vi.mock('@/lib/bookkeeping/account-validation', async () => { ) return { ...actual, - findMissingActiveAccounts: findMissingAccountsMock, + findUnresolvableAccounts: findMissingAccountsMock, } }) // category mapping is real — gives the route real BAS accounts to validate. diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts index e71dd677..e199d18f 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts @@ -27,7 +27,7 @@ import { import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { reverseEntry } from '@/lib/bookkeeping/engine' import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors' -import { collectMappingResultAccounts, findMissingActiveAccounts } from '@/lib/bookkeeping/account-validation' +import { collectMappingResultAccounts, findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation' import { getErrorMessage } from '@/lib/errors/get-error-message' import { eventBus } from '@/lib/events' import type { Logger } from '@/lib/logger' @@ -204,8 +204,9 @@ async function categorizeOne( // engine throws AccountsNotInChartError mid-flight and the legacy // partial-success branch silently marks the row bokförd with no // verifikation. Validate in both dry-run and live paths so previews - // surface the same actionable error. - const missingAccounts = await findMissingActiveAccounts( + // surface the same actionable error. Standard BAS accounts merely absent + // from the chart pass — the engine seeds them on demand. + const missingAccounts = await findUnresolvableAccounts( supabase, companyId, collectMappingResultAccounts(mappingResult), diff --git a/components/bookkeeping/CorrectionChain.tsx b/components/bookkeeping/CorrectionChain.tsx index 1f924072..2be6db95 100644 --- a/components/bookkeeping/CorrectionChain.tsx +++ b/components/bookkeeping/CorrectionChain.tsx @@ -60,6 +60,11 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) { const role = getRole(entry) const total = getTotal(entry) const isCurrent = entry.id === currentEntryId + // A cancelled entry is residue from an aborted correction attempt: + // it was voided before taking effect and its lines were removed, so + // it always sums to 0,00. Without the status badge it renders + // exactly like a live storno — dim it and say what it is. + const isCancelled = entry.status === 'cancelled' return ( -
+
{/* Timeline dot */} -
+
{role.label} @@ -77,7 +82,7 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) { {formatVoucher(entry)} {formatDate(entry.entry_date)} - + {isCurrent && ( {t('current')} diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 8ca9db40..c6c6e57e 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -23,6 +23,7 @@ import { calculateCashPosition, calculateExpenseRatio, calculateAvgPaymentDays, + calculateVatLiability, } from '@/lib/reports/kpi' import { generateTrialBalance } from '@/lib/reports/trial-balance' import { generateARLedger } from '@/lib/reports/ar-ledger' @@ -3504,16 +3505,8 @@ export const tools: McpTool[] = [ const outstandingReceivables = arLedger.total_outstanding const overdueReceivables = arLedger.total_overdue - // VAT liability from trial balance - const getClosing = (accNum: string) => { - const row = trialBalance.rows.find((r) => r.account_number === accNum) - if (!row) return 0 - return row.closing_credit - row.closing_debit - } - const vatLiability = Math.round( - (getClosing('2611') + getClosing('2621') + getClosing('2631') - - getClosing('2641') - getClosing('2645')) * 100 - ) / 100 + // VAT liability from trial balance (same accounts as momsdeklaration ruta 49) + const vatLiability = calculateVatLiability(trialBalance.rows) return { period_name: period.name, diff --git a/lib/agent/intents/shared-rules.ts b/lib/agent/intents/shared-rules.ts index aecd4d24..270ee3a9 100644 --- a/lib/agent/intents/shared-rules.ts +++ b/lib/agent/intents/shared-rules.ts @@ -35,6 +35,20 @@ export const AGENT_GROUND_RULES: string[] = [ // -- Anchor in user's own history -- '- KOLLA HISTORIK FÖRST: innan du föreslår "så här gör du" på en återkommande motpart, anropa gnubok_query_journal med motpartens namn. Om de bokfört Vercel/Spotify/SJ förut — följ samma mönster. "Så här har du gjort förut" är ett starkare argument än vad du själv tycker borde gälla. Bryt bara mönstret om underlaget tydligt säger något annat.', '', + // -- Storno / rättelse: how the product actually works -- + // Production feedback: the assistant described correction flows that don't + // exist in Accounted (or implied the user must register accounts before + // correcting), so the user got stuck. Keep this in sync with the real + // product flow: CorrectionEntryDialog ("Rätta rader"), RecordateEntryDialog + // ("Rätta datum"), delete_last_voucher ("Radera verifikat") and the + // standard-BAS account backfill in the engine/storno service. + '- RÄTTA FEL I BOKFÖRDA VERIFIKATIONER — så fungerar det i Accounted (beskriv aldrig andra vägar än dessa):', + ' • En bokförd verifikation kan aldrig redigeras direkt (Bokföringslagen). Rättelse görs från verifikationens egen sida: Bokföring → öppna verifikationen → knappen "Rätta". "Rätta rader" skapar automatiskt en storno som nollställer originalet plus en ny rättelseverifikation med de rätta raderna, båda i originalets period. "Rätta datum" flyttar verifikationen till rätt datum/år (storno + ombokning under huven). Hela kedjan original → storno → rättelse länkas och visas på verifikationssidan.', + ' • Är verifikationen den SENASTE i sin serie kan den även raderas helt ("Radera verifikat") — då återanvänds löpnumret och ingen lucka uppstår.', + ' • Konton som finns i BAS-kontoplanen men saknas i företagets kontoplan läggs till AUTOMATISKT vid bokföring och rättelse. Be aldrig användaren registrera standardkonton manuellt innan de bokför — bara okända kontonummer eller avaktiverade konton stoppar.', + ' • När en bokning makuleras (storno utan rättelse) släpps den kopplade banktransaktionen och blir bokföringsbar igen i transaktionsvyn — användaren kan alltid klicka på transaktionen och bokföra om. Vid en rättelse följer transaktionen och underlaget med till rättelseverifikationen.', + ' • En storno på 0 kr med status "Makulerad" i kedjan är resterna av ett avbrutet rättelseförsök — den påverkar inga saldon. Oförklarade luckor i löpnummerserien dokumenteras via verifikationsluckor (gnubok_list_voucher_gaps / gnubok_explain_voucher_gap).', + '', // -- Representation: headcount + per-person VAT cap -- '- REPRESENTATION (måltid/restaurang): innan du bokför, fånga ANTAL deltagare, vilka de var (namn + företag), och syftet. Antalet är inte valfritt: momsavdraget beräknas per person. Fråga "Hur många var ni, och vilka?" om det inte redan framgår.', ' • Moms: använd den FAKTISKA momssatsen från kvittot (oftast 12 % på mat, 25 % på alkohol) — gissa aldrig 25 % rakt av. Avdraget gäller på ett underlag om max 300 kr exkl. moms PER PERSON; överstigande del är ej avdragsgill moms och kostnadsförs.', diff --git a/lib/agent/intents/verifikation-draft.ts b/lib/agent/intents/verifikation-draft.ts index 2ca5b7e3..7aaa1068 100644 --- a/lib/agent/intents/verifikation-draft.ts +++ b/lib/agent/intents/verifikation-draft.ts @@ -172,7 +172,7 @@ export const verifikationDraft = defineAgentIntent< })`, ) if (captured.period_status.status === 'locked' || captured.period_status.status === 'closed') { - lines.push('PERIODEN ÄR LÅST — vägled mot storno + ny verifikation i öppen period istället.') + lines.push('PERIODEN ÄR LÅST — ett utkast kan inte bokföras här. Vägled användaren att ändra verifikationsdatumet till en öppen period (utkast redigeras fritt), eller att låsa upp perioden under Bokföring → Räkenskapsår om datumet måste stå kvar.') } } lines.push('') diff --git a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts index b837e41c..f1d0788c 100644 --- a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts +++ b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts @@ -96,7 +96,7 @@ function makeMinimalK3Data(): ArsredovisningData { total: 300_000, }, investerings: { forvarv_anlaggningar: 0, avyttring_anlaggningar: 0, total: 0 }, - finansierings: { delta_lan: 0, utdelningar: 0, nyemission: 0, total: 0 }, + finansierings: { delta_lan: 0, utdelningar: 0, nyemission: 0, erhallna_aktieagartillskott: 0, total: 0 }, total_cash_flow: 300_000, reconciliation: { opening_cash_1xxx: 300_000, diff --git a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts index bcdea8fb..4baca759 100644 --- a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts +++ b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts @@ -256,6 +256,7 @@ function plantStandardReports() { delta_lan: 0, utdelningar: 0, nyemission: 0, + erhallna_aktieagartillskott: 0, total: 0, }, total_cash_flow: 300_000, diff --git a/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx b/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx index e7f0e8cd..ee9f629b 100644 --- a/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx +++ b/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx @@ -380,6 +380,12 @@ export function ArsredovisningK3PDF({ data }: { data: ArsredovisningData }) { {fmt(data.kassaflodesanalys.finansierings.nyemission)} + + Erhållna aktieägartillskott + + {fmt(data.kassaflodesanalys.finansierings.erhallna_aktieagartillskott)} + + Kassaflöde från finansieringsverksamheten diff --git a/lib/bokslut/arsredovisning/types.ts b/lib/bokslut/arsredovisning/types.ts index db693aa1..6c1a7e82 100644 --- a/lib/bokslut/arsredovisning/types.ts +++ b/lib/bokslut/arsredovisning/types.ts @@ -154,6 +154,7 @@ export interface KassaflodesAnalysisSummary { delta_lan: number utdelningar: number nyemission: number + erhallna_aktieagartillskott: number total: number } total_cash_flow: number diff --git a/lib/bookkeeping/__tests__/account-validation.test.ts b/lib/bookkeeping/__tests__/account-validation.test.ts new file mode 100644 index 00000000..6215d273 --- /dev/null +++ b/lib/bookkeeping/__tests__/account-validation.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + findMissingActiveAccounts, + findUnresolvableAccounts, +} from '../account-validation' + +// Sequential thenable builder — each awaited query pops the next result. +let resultIdx: number +let results: Array<{ data?: unknown; error?: unknown }> + +function makeBuilder() { + const b: Record = {} + for (const m of ['select', 'eq', 'in']) { + b[m] = vi.fn().mockReturnValue(b) + } + b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null }) + return b +} + +function makeClient() { + return { from: vi.fn().mockImplementation(() => makeBuilder()) } +} + +beforeEach(() => { + resultIdx = 0 + results = [] +}) + +describe('findMissingActiveAccounts', () => { + it('returns accounts not present-and-active, first-seen order, deduped', async () => { + results = [{ data: [{ account_number: '1930' }], error: null }] + const missing = await findMissingActiveAccounts(makeClient() as never, 'co-1', [ + '5410', + '1930', + '5410', + '3740', + ]) + expect(missing).toEqual(['5410', '3740']) + }) + + it('returns empty for empty input without querying', async () => { + const supabase = makeClient() + const missing = await findMissingActiveAccounts(supabase as never, 'co-1', []) + expect(missing).toEqual([]) + expect(supabase.from).not.toHaveBeenCalled() + }) +}) + +describe('findUnresolvableAccounts', () => { + it('lets a standard BAS account that is merely absent pass (engine seeds it)', async () => { + results = [ + // active-accounts read: only 1930 active, 3740 missing + { data: [{ account_number: '1930' }], error: null }, + // existence read for BAS-seedable: no row at all → engine will seed + { data: [], error: null }, + ] + const unresolvable = await findUnresolvableAccounts(makeClient() as never, 'co-1', [ + '1930', + '3740', + ]) + expect(unresolvable).toEqual([]) + }) + + it('blocks a BAS account that exists but is deactivated (backfill never resurrects)', async () => { + results = [ + { data: [{ account_number: '1930' }], error: null }, + // 3740 has a row (it surfaced as missing-active, so it must be inactive) + { data: [{ account_number: '3740' }], error: null }, + ] + const unresolvable = await findUnresolvableAccounts(makeClient() as never, 'co-1', [ + '1930', + '3740', + ]) + expect(unresolvable).toEqual(['3740']) + }) + + it('blocks numbers with no BAS reference without an extra existence read', async () => { + results = [ + // only the active-accounts read — '9999' has no BAS reference + { data: [], error: null }, + ] + const supabase = makeClient() + const unresolvable = await findUnresolvableAccounts(supabase as never, 'co-1', ['9999']) + expect(unresolvable).toEqual(['9999']) + expect(supabase.from).toHaveBeenCalledTimes(1) + }) + + it('returns empty when everything is already active', async () => { + results = [{ data: [{ account_number: '1930' }, { account_number: '5410' }], error: null }] + const unresolvable = await findUnresolvableAccounts(makeClient() as never, 'co-1', [ + '1930', + '5410', + ]) + expect(unresolvable).toEqual([]) + }) +}) diff --git a/lib/bookkeeping/__tests__/engine.test.ts b/lib/bookkeeping/__tests__/engine.test.ts index e9af113d..184bd4ee 100644 --- a/lib/bookkeeping/__tests__/engine.test.ts +++ b/lib/bookkeeping/__tests__/engine.test.ts @@ -483,3 +483,93 @@ describe('createDraftEntry — on-demand BAS account backfill', () => { expect(mockBackfill).toHaveBeenCalledTimes(1) }) }) + +describe('reverseEntry — bank transaction unlink', () => { + // After a reversal the booked bank transaction must return to "Att bokföra" + // (journal_entry_id cleared) so the user can book it again. The agent paths + // in lib/pending-operations/commit.ts did this manually; the engine now owns + // it so the dashboard reverse route behaves the same. + it('clears transactions.journal_entry_id for rows booked by the reversed entry', async () => { + const original = { + id: 'entry-1', + company_id: 'company-1', + status: 'posted', + fiscal_period_id: 'period-1', + voucher_series: 'A', + voucher_number: 7, + entry_date: '2026-02-02', + description: 'ALMI AB - Innovationslån', + source_type: 'manual', + source_id: null, + lines: [ + { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, + { account_number: '2350', debit_amount: 0, credit_amount: 1000 }, + ], + } + const reversal = { id: 'reversal-1', reverses_id: 'entry-1', source_type: 'storno' } + + let jeCall = 0 + const jeResults = [ + { data: original, error: null }, // fetch original (.single) + { data: reversal, error: null }, // insert reversal (.single) + { data: null, error: null }, // post reversal (await) + { data: [{ id: 'entry-1' }], error: null }, // CAS original → reversed (await) + { data: { ...reversal, lines: [] }, error: null }, // fetch complete (.single) + ] + function jeBuilder() { + const b: Record = {} + for (const m of ['select', 'eq', 'in', 'update', 'insert']) { + b[m] = vi.fn().mockReturnValue(b) + } + b.single = vi.fn().mockImplementation(async () => jeResults[jeCall++]) + b.then = (resolve: (v: unknown) => void) => resolve(jeResults[jeCall++]) + return b + } + + const txUpdatePayloads: unknown[] = [] + const txFilters: Record = {} + + const supabase = { + rpc: vi.fn().mockResolvedValue({ data: 8, error: null }), + from: vi.fn().mockImplementation((table: string) => { + if (table === 'journal_entries') return jeBuilder() + if (table === 'chart_of_accounts') { + const b: Record = {} + for (const m of ['select', 'eq', 'in']) b[m] = vi.fn().mockReturnValue(b) + b.then = (resolve: (v: unknown) => void) => + resolve({ + data: [ + { id: 'acc-1930', account_number: '1930' }, + { id: 'acc-2350', account_number: '2350' }, + ], + error: null, + }) + return b + } + if (table === 'journal_entry_lines') { + return { insert: vi.fn().mockResolvedValue({ error: null }) } + } + if (table === 'transactions') { + const b: Record = {} + b.update = vi.fn().mockImplementation((payload: unknown) => { + txUpdatePayloads.push(payload) + return b + }) + b.eq = vi.fn().mockImplementation((col: string, val: unknown) => { + txFilters[col] = val + return b + }) + b.then = (resolve: (v: unknown) => void) => resolve({ error: null }) + return b + } + return createMockChain() + }), + } + + const result = await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1') + + expect(result.id).toBe('reversal-1') + expect(txUpdatePayloads).toEqual([{ journal_entry_id: null }]) + expect(txFilters).toMatchObject({ company_id: 'company-1', journal_entry_id: 'entry-1' }) + }) +}) diff --git a/lib/bookkeeping/account-validation.ts b/lib/bookkeeping/account-validation.ts index 23bc1c8c..fb6f69a7 100644 --- a/lib/bookkeeping/account-validation.ts +++ b/lib/bookkeeping/account-validation.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { MappingResult } from '@/types' +import { getBASReference } from '@/lib/bookkeeping/bas-reference' /** * Return the subset of `accountNumbers` that are NOT present-and-active in the @@ -40,6 +41,44 @@ export async function findMissingActiveAccounts( return unique.filter((n) => !present.has(n)) } +/** + * Return the subset of `accountNumbers` that the engine cannot resolve even + * after its on-demand backfill (lib/bookkeeping/account-backfill.ts): + * + * - numbers with no BAS 2026 reference (typos, non-standard accounts), and + * - accounts that exist in the chart but are deactivated (the backfill never + * resurrects a deliberate deactivation). + * + * An account that is simply absent from the chart but exists in BAS is NOT + * returned — createDraftEntry seeds it automatically, so pre-validation in a + * route must not 400 on it. Read-only on purpose: dry-run/preview paths use + * the same check without side effects. Preserves first-seen order. + */ +export async function findUnresolvableAccounts( + supabase: SupabaseClient, + companyId: string, + accountNumbers: readonly string[], +): Promise { + const missing = await findMissingActiveAccounts(supabase, companyId, accountNumbers) + if (missing.length === 0) return [] + + const basSeedable = missing.filter((num) => Boolean(getBASReference(num))) + if (basSeedable.length === 0) return missing + + // A row that exists (but is inactive) blocks the backfill; a BAS number + // with no row at all will be seeded by the engine. + const { data, error } = await supabase + .from('chart_of_accounts') + .select('account_number') + .eq('company_id', companyId) + .in('account_number', basSeedable) + + if (error) throw error + + const existsInactive = new Set((data ?? []).map((r) => r.account_number as string)) + return missing.filter((num) => !getBASReference(num) || existsInactive.has(num)) +} + /** * Extract every chart account number a MappingResult will post to: the headline * debit/credit plus every account_number in vat_lines. Returns the raw list diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 750509f3..9741eda0 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -614,6 +614,21 @@ export async function reverseEntry( throw new EntryAlreadyReversedError() } + // Unlink any bank transactions booked by the reversed entry so they return + // to "Att bokföra" and can be booked again from the transactions view. + // Without this the row keeps pointing at a status='reversed' entry, reads + // as bokförd forever, and has no re-booking affordance — the agent paths + // (lib/pending-operations/commit.ts) already did this manually after every + // reverseEntry call; the dashboard reverse route did not. + const { error: unlinkError } = await supabase + .from('transactions') + .update({ journal_entry_id: null }) + .eq('company_id', companyId) + .eq('journal_entry_id', entryId) + if (unlinkError) { + log.error('failed to unlink transactions from reversed entry', unlinkError, { entryId }) + } + // If this was a payment entry, sync the linked invoice/supplier-invoice status. // Helper is shared with the DELETE journal entry route so both code paths leave // the invoice in a consistent state (BFL 5 kap 5§ requires GL reversal; this diff --git a/lib/core/bookkeeping/__tests__/recordate-entry.test.ts b/lib/core/bookkeeping/__tests__/recordate-entry.test.ts index 82dfabe9..75ed1b0e 100644 --- a/lib/core/bookkeeping/__tests__/recordate-entry.test.ts +++ b/lib/core/bookkeeping/__tests__/recordate-entry.test.ts @@ -42,6 +42,12 @@ vi.mock('@/lib/bookkeeping/engine', () => ({ getNextVoucherNumber: vi.fn(async () => 1), })) +// On-demand BAS backfill — never triggered here (accounts resolve on the +// first read in every scenario below). +vi.mock('@/lib/bookkeeping/account-backfill', () => ({ + backfillStandardBASAccounts: vi.fn(async () => []), +})) + // resolvePeriodStatusForDate is the classification gate — mock it directly so // each test controls whether the target date is open/locked/closed/uncovered. const mockResolve = vi.fn() @@ -131,17 +137,18 @@ describe('recordateEntry', () => { results = [ { data: original, error: null }, // 0 recordate fetch original { data: { name: '2025', period_start: '2025-01-01', period_end: '2025-12-31' }, error: null }, // 1 target period - { data: reversalEntry, error: null }, // 2 insert reversal - { data: null, error: null }, // 3 reversal lines - { data: null, error: null }, // 4 post reversal - { data: [{ id: 'a1', account_number: '6230' }, { id: 'a2', account_number: '1930' }], error: null }, // 5 accounts + { data: [{ id: 'a1', account_number: '6230' }, { id: 'a2', account_number: '1930' }], error: null }, // 2 accounts (Step 0) + { data: reversalEntry, error: null }, // 3 insert reversal + { data: null, error: null }, // 4 reversal lines + { data: null, error: null }, // 5 post reversal { data: correctedEntry, error: null }, // 6 insert corrected { data: null, error: null }, // 7 corrected lines { data: null, error: null }, // 8 post corrected { data: [{ id: 'orig-1' }], error: null }, // 9 CAS - { data: { ...reversalEntry, lines: [] }, error: null }, // 10 final reversal - { data: { ...correctedEntry, lines: [] }, error: null }, // 11 final corrected - { data: null, error: null }, // 12 relink documents + { data: null, error: null }, // 10 relink transactions + { data: null, error: null }, // 11 relink documents + { data: { ...reversalEntry, lines: [] }, error: null }, // 12 final reversal + { data: { ...correctedEntry, lines: [] }, error: null }, // 13 final corrected ] const supabase = makeClient() const result = await recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2025-07-03') diff --git a/lib/core/bookkeeping/__tests__/storno-service.test.ts b/lib/core/bookkeeping/__tests__/storno-service.test.ts index 7d15231c..a1006ed8 100644 --- a/lib/core/bookkeeping/__tests__/storno-service.test.ts +++ b/lib/core/bookkeeping/__tests__/storno-service.test.ts @@ -37,6 +37,12 @@ vi.mock('@/lib/bookkeeping/engine', () => ({ getNextVoucherNumber: vi.fn(async () => ++resultIdx), // just increment })) +// On-demand BAS backfill — default: nothing seedable. Tests override. +const mockBackfill = vi.fn() +vi.mock('@/lib/bookkeeping/account-backfill', () => ({ + backfillStandardBASAccounts: (...args: unknown[]) => mockBackfill(...args), +})) + import { correctEntry } from '../storno-service' import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine' @@ -51,6 +57,7 @@ beforeEach(() => { vi.mocked(validateBalance).mockReturnValue({ valid: true, totalDebit: 1000, totalCredit: 1000 }) let voucherNum = 0 vi.mocked(getNextVoucherNumber).mockImplementation(async () => ++voucherNum) + mockBackfill.mockResolvedValue([]) }) describe('correctEntry', () => { @@ -78,15 +85,14 @@ describe('correctEntry', () => { results = [ // 0: fetch original (.single()) { data: originalEntry, error: null }, - // 1: insert reversal entry (.single()) - { data: reversalEntry, error: null }, - // 2: insert reversal lines (thenable) - { data: null, error: null }, - // 3: update reversal to posted (thenable) - { data: null, error: null }, - // -- getNextVoucherNumber increments resultIdx -- - // 4: fetch accounts for corrected lines (thenable) + // 1: fetch accounts for corrected lines — Step 0 pre-validation (thenable) { data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, + // 2: insert reversal entry (.single()) + { data: reversalEntry, error: null }, + // 3: insert reversal lines (thenable) + { data: null, error: null }, + // 4: update reversal to posted (thenable) + { data: null, error: null }, // 5: insert corrected entry (.single()) { data: correctedEntry, error: null }, // 6: insert corrected lines (thenable) @@ -95,9 +101,13 @@ describe('correctEntry', () => { { data: null, error: null }, // 8: CAS update original to reversed (thenable, needs array for .length check) { data: [{ id: 'orig-1' }], error: null }, - // 9: fetch final reversal (.single()) + // 9: relink transactions original → corrected (thenable) + { data: null, error: null }, + // 10: relink documents original → corrected (thenable) + { data: null, error: null }, + // 11: fetch final reversal (.single()) { data: { ...reversalEntry, lines: [] }, error: null }, - // 10: fetch final corrected (.single()) + // 12: fetch final corrected (.single()) { data: { ...correctedEntry, lines: correctedLines }, error: null }, ] } @@ -141,10 +151,10 @@ describe('correctEntry', () => { results = [ { data: originalEntry, error: null }, // 0: fetch original - { data: reversalEntry, error: null }, // 1: insert reversal - { data: null, error: null }, // 2: insert reversal lines - { data: null, error: null }, // 3: post reversal - { data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 4: accounts + { data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts (Step 0) + { data: reversalEntry, error: null }, // 2: insert reversal + { data: null, error: null }, // 3: insert reversal lines + { data: null, error: null }, // 4: post reversal { data: correctedEntry, error: null }, // 5: insert corrected { data: null, error: null }, // 6: insert corrected lines { data: null, error: null }, // 7: post corrected @@ -166,10 +176,10 @@ describe('correctEntry', () => { results = [ { data: originalEntry, error: null }, // 0: fetch original - { data: reversalEntry, error: null }, // 1: insert reversal - { data: null, error: null }, // 2: insert reversal lines - { data: null, error: null }, // 3: post reversal - { data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 4: accounts + { data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts (Step 0) + { data: reversalEntry, error: null }, // 2: insert reversal + { data: null, error: null }, // 3: insert reversal lines + { data: null, error: null }, // 4: post reversal { data: null, error: { message: 'DB error' } }, // 5: insert corrected FAILS { data: null, error: null }, // 6: cancelEntry reversal update { data: null, error: null }, // 7: cancelEntry reversal lines delete @@ -186,10 +196,11 @@ describe('correctEntry', () => { results = [ { data: originalEntry, error: null }, // 0: fetch original - { data: reversalEntry, error: null }, // 1: insert reversal - { data: null, error: { message: 'line error' } }, // 2: insert reversal lines FAILS - { data: null, error: null }, // 3: cancelEntry update - { data: null, error: null }, // 4: cancelEntry lines delete + { data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts (Step 0) + { data: reversalEntry, error: null }, // 2: insert reversal + { data: null, error: { message: 'line error' } }, // 3: insert reversal lines FAILS + { data: null, error: null }, // 4: cancelEntry update + { data: null, error: null }, // 5: cancelEntry lines delete ] const supabase = makeClient() @@ -302,16 +313,18 @@ describe('correctEntry', () => { results = [ { data: correctionAsOriginal, error: null }, // 0: fetch original (the prior correction) - { data: secondReversal, error: null }, // 1: insert reversal - { data: null, error: null }, // 2: insert reversal lines - { data: null, error: null }, // 3: post reversal - { data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 4: accounts + { data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts (Step 0) + { data: secondReversal, error: null }, // 2: insert reversal + { data: null, error: null }, // 3: insert reversal lines + { data: null, error: null }, // 4: post reversal { data: secondCorrection, error: null }, // 5: insert corrected { data: null, error: null }, // 6: insert corrected lines { data: null, error: null }, // 7: post corrected { data: [{ id: 'correction-1' }], error: null }, // 8: CAS update - { data: { ...secondReversal, lines: [] }, error: null }, // 9: fetch final reversal - { data: { ...secondCorrection, lines: [] }, error: null }, // 10: fetch final corrected + { data: null, error: null }, // 9: relink transactions + { data: null, error: null }, // 10: relink documents + { data: { ...secondReversal, lines: [] }, error: null }, // 11: fetch final reversal + { data: { ...secondCorrection, lines: [] }, error: null }, // 12: fetch final corrected ] const supabase = makeClient() @@ -325,6 +338,58 @@ describe('correctEntry', () => { expect(result.corrected.source_type).toBe('correction') }) + it('fails fast on unknown accounts — BEFORE the storno exists or a voucher number is consumed', async () => { + // Regression: the old flow created+posted the storno first, then hit + // AccountsNotInChartError on the corrected lines and had to cancel the + // storno again — leaving a voided 0 kr storno in the chain (the user's + // "A98") and burning voucher numbers (the missing "A99"). + results = [ + { data: originalEntry, error: null }, // 0: fetch original + { data: [{ id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts — 5420 missing + ] + mockBackfill.mockResolvedValue([]) // not seedable (e.g. deactivated / unknown) + + const supabase = makeClient() + await expect( + correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines) + ).rejects.toMatchObject({ code: 'ACCOUNTS_NOT_IN_CHART' }) + + // Nothing was written to the journal and no voucher number was fetched. + expect(inserts.filter((i) => i.table === 'journal_entries')).toHaveLength(0) + expect(inserts.filter((i) => i.table === 'journal_entry_lines')).toHaveLength(0) + expect(getNextVoucherNumber).not.toHaveBeenCalled() + }) + + it('seeds a standard BAS account missing from the chart and proceeds', async () => { + const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' }) + const correctedEntry = makeJournalEntry({ id: 'corrected-1', correction_of_id: 'orig-1' }) + results = [ + { data: originalEntry, error: null }, // 0: fetch original + { data: [{ id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts — 5420 missing + // -- backfill seeds 5420 -- + { data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 2: re-resolve + { data: reversalEntry, error: null }, // 3: insert reversal + { data: null, error: null }, // 4: reversal lines + { data: null, error: null }, // 5: post reversal + { data: correctedEntry, error: null }, // 6: insert corrected + { data: null, error: null }, // 7: corrected lines + { data: null, error: null }, // 8: post corrected + { data: [{ id: 'orig-1' }], error: null }, // 9: CAS + { data: null, error: null }, // 10: relink transactions + { data: null, error: null }, // 11: relink documents + { data: { ...reversalEntry, lines: [] }, error: null }, // 12: final reversal + { data: { ...correctedEntry, lines: [] }, error: null }, // 13: final corrected + ] + mockBackfill.mockResolvedValue(['5420']) + + const supabase = makeClient() + const result = await correctEntry( + supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines + ) + expect(result.corrected.id).toBe('corrected-1') + expect(mockBackfill).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', ['5420']) + }) + it('emits journal_entry.corrected event', async () => { setupResults() @@ -368,16 +433,18 @@ describe('correctEntry — date/period override (recordate engine)', () => { results = [ { data: originalEntry, error: null }, // 0 fetch original { data: { name: '2025', period_start: '2025-01-01', period_end: '2025-12-31' }, error: null }, // 1 target period - { data: reversalEntry, error: null }, // 2 insert reversal - { data: null, error: null }, // 3 reversal lines - { data: null, error: null }, // 4 post reversal - { data: [{ id: 'acc-5410', account_number: '5410' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 5 accounts + { data: [{ id: 'acc-5410', account_number: '5410' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 2 accounts (Step 0) + { data: reversalEntry, error: null }, // 3 insert reversal + { data: null, error: null }, // 4 reversal lines + { data: null, error: null }, // 5 post reversal { data: correctedEntry, error: null }, // 6 insert corrected { data: null, error: null }, // 7 corrected lines { data: null, error: null }, // 8 post corrected { data: [{ id: 'orig-1' }], error: null }, // 9 CAS - { data: { ...reversalEntry, lines: [] }, error: null }, // 10 final reversal - { data: { ...correctedEntry, lines: [] }, error: null }, // 11 final corrected + { data: null, error: null }, // 10 relink transactions + { data: null, error: null }, // 11 relink documents + { data: { ...reversalEntry, lines: [] }, error: null }, // 12 final reversal + { data: { ...correctedEntry, lines: [] }, error: null }, // 13 final corrected ] const supabase = makeClient() const result = await correctEntry( diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts index fc23cd4d..ee145cef 100644 --- a/lib/core/bookkeeping/storno-service.ts +++ b/lib/core/bookkeeping/storno-service.ts @@ -6,6 +6,7 @@ import type { JournalEntryLine, } from '@/types' import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine' +import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill' import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' import { AccountsNotInChartError, @@ -204,6 +205,43 @@ export async function correctEntry( } } + // ===== Step 0: Resolve corrected-line accounts BEFORE any journal write ===== + // The old flow created and posted the storno first and only then discovered + // that a corrected line referenced an account outside the chart. The storno + // then had to be cancelled again, which left a voided 0 kr storno in the + // correction chain and permanently burned voucher numbers (next_voucher_number + // is a consuming counter → an unexplained BFNAR 2013:2 gap). Validate up + // front instead: standard BAS accounts missing from the chart are seeded on + // demand (same as createDraftEntry); unknown numbers or deliberately + // deactivated accounts fail fast with nothing written. + const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))] + const resolveActiveAccountIds = async (): Promise> => { + const { data: accounts } = await supabase + .from('chart_of_accounts') + .select('id, account_number') + .eq('company_id', companyId) + .eq('is_active', true) + .in('account_number', accountNumbers) + const map = new Map() + for (const account of accounts || []) { + map.set(account.account_number, account.id) + } + return map + } + + let accountIdMap = await resolveActiveAccountIds() + let missingAccounts = accountNumbers.filter((num) => !accountIdMap.has(num)) + if (missingAccounts.length > 0) { + const seeded = await backfillStandardBASAccounts(supabase, companyId, userId, missingAccounts) + if (seeded.length > 0) { + accountIdMap = await resolveActiveAccountIds() + missingAccounts = accountNumbers.filter((num) => !accountIdMap.has(num)) + } + if (missingAccounts.length > 0) { + throw new AccountsNotInChartError(missingAccounts) + } + } + // ===== Step 1: Create storno (reversal) entry ===== const reversalVoucherNumber = await getNextVoucherNumber( supabase, @@ -288,26 +326,8 @@ export async function correctEntry( original.voucher_series || 'A' ) - // Resolve account IDs for corrected lines — only active rows count - const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))] - const { data: accounts } = await supabase - .from('chart_of_accounts') - .select('id, account_number') - .eq('company_id', companyId) - .eq('is_active', true) - .in('account_number', accountNumbers) - - const accountIdMap = new Map() - for (const account of accounts || []) { - accountIdMap.set(account.account_number, account.id) - } - - // Validate all account numbers resolved to IDs - const missingAccounts = accountNumbers.filter(num => !accountIdMap.has(num)) - if (missingAccounts.length > 0) { - throw new AccountsNotInChartError(missingAccounts) - } - + // Account IDs were resolved (and standard BAS accounts seeded) in Step 0, + // before the storno existed — nothing to clean up if we got this far. const { data: newEntry, error: correctedError } = await supabase .from('journal_entries') .insert({ @@ -394,6 +414,15 @@ export async function correctEntry( throw new EntryAlreadyReversedError() } + // Re-point bank transactions and underlag from the original to the corrected + // entry. The original is now status 'reversed'; the corrected entry is the + // live representation of the affärshändelse, so the transaction row should + // keep reading as booked against it (and stay correctable/uncategorizable), + // and the underlag should travel with it. Best-effort — the correction_of_id + // chain preserves traceability even if either relink fails. + await relinkTransactionsToEntry(supabase, companyId, originalEntryId, correctedEntry!.id) + await relinkDocumentsToEntry(supabase, companyId, originalEntryId, correctedEntry!.id) + // ===== Step 3: Fetch complete entries ===== const { data: finalReversal } = await supabase .from('journal_entries') @@ -516,14 +545,36 @@ export async function recordateEntry( } ) - // Move the underlag to the corrected entry so it doesn't surface as a - // "verifikat utan underlag" in the target year. Best-effort — the - // correction_of_id chain preserves traceability even if this fails. - await relinkDocumentsToEntry(supabase, companyId, originalEntryId, result.corrected.id) - + // Underlag and bank-transaction links follow the corrected entry — + // correctEntry handles both relinks for every correction flavour. return result } +/** + * Re-point every bank transaction from one entry to another. Used when a + * verifikation is corrected so the transaction row keeps reading as booked + * against the live (corrected) entry instead of the reversed original. + * Failures are logged, not thrown — the correction chain stays traceable. + */ +async function relinkTransactionsToEntry( + supabase: SupabaseClient, + companyId: string, + fromEntryId: string, + toEntryId: string +): Promise { + const { error } = await supabase + .from('transactions') + .update({ journal_entry_id: toEntryId }) + .eq('company_id', companyId) + .eq('journal_entry_id', fromEntryId) + if (error) { + console.error( + `[storno] relinkTransactionsToEntry: failed to move transactions ${fromEntryId} → ${toEntryId}:`, + error.message + ) + } +} + /** * Re-point every document_attachment from one entry to another. Used when a * verifikation is moved to a different period so its underlag travels with the diff --git a/lib/reports/__tests__/kassaflodesanalys.test.ts b/lib/reports/__tests__/kassaflodesanalys.test.ts index 774e3a43..f2d6544a 100644 --- a/lib/reports/__tests__/kassaflodesanalys.test.ts +++ b/lib/reports/__tests__/kassaflodesanalys.test.ts @@ -296,6 +296,87 @@ describe('generateKassaflodesanalys', () => { expect(report.reconciliation.is_reconciled).toBe(true) }) + it('records erhållna aktieägartillskott (2093) as financing inflow and reconciles (#716)', async () => { + // Repro from issue #716: debit 1930, credit 2093 (10 000 kr shareholder + // contribution). Before the fix, 2093 was unmapped — financing showed 0 + // and the reconciliation failed by exactly the contributed amount. + mockTrialBalance.mockResolvedValue({ + rows: [ + makeRow({ + account_number: '1930', + account_class: 1, + period_debit: 10000, + closing_debit: 10000, + }), + makeRow({ + account_number: '2093', + account_class: 2, + period_credit: 10000, + closing_credit: 10000, + }), + ], + totalDebit: 10000, + totalCredit: 10000, + isBalanced: true, + }) + mockIncomeStatement.mockResolvedValue(makeIs()) + + const report = await generateKassaflodesanalys( + makeSupabase(PERIOD), + 'company-1', + 'period-1' + ) + + expect(report.finansierings.erhallna_aktieagartillskott).toBe(10000) + expect(report.finansierings.nyemission).toBe(0) + expect(report.finansierings.total).toBe(10000) + expect(report.reconciliation.delta_actual).toBe(10000) + expect(report.reconciliation.delta_calculated).toBe(10000) + expect(report.reconciliation.is_reconciled).toBe(true) + }) + + it('records nyemission premium on överkursfond (2097) as financing inflow and reconciles', async () => { + // A 100 000 kr emission: 25 000 to 2081 (aktiekapital), 75 000 premium to + // 2097 (fri överkursfond). 2097 was previously outside the nyemission + // prefixes — same reconciliation-failure class as #716. + mockTrialBalance.mockResolvedValue({ + rows: [ + makeRow({ + account_number: '1930', + account_class: 1, + period_debit: 100000, + closing_debit: 100000, + }), + makeRow({ + account_number: '2081', + account_class: 2, + period_credit: 25000, + closing_credit: 25000, + }), + makeRow({ + account_number: '2097', + account_class: 2, + period_credit: 75000, + closing_credit: 75000, + }), + ], + totalDebit: 100000, + totalCredit: 100000, + isBalanced: true, + }) + mockIncomeStatement.mockResolvedValue(makeIs()) + + const report = await generateKassaflodesanalys( + makeSupabase(PERIOD), + 'company-1', + 'period-1' + ) + + expect(report.finansierings.nyemission).toBe(100000) + expect(report.finansierings.total).toBe(100000) + expect(report.reconciliation.is_reconciled).toBe(true) + }) + it('detects mismatch when a cash movement has no balancing classification', async () => { // Plant an invariant violation: 1930 went up by 10 000 but no offsetting // entry on any tracked account class. This is the kind of bug a real diff --git a/lib/reports/__tests__/kpi.test.ts b/lib/reports/__tests__/kpi.test.ts index 14717b16..c1c69c6e 100644 --- a/lib/reports/__tests__/kpi.test.ts +++ b/lib/reports/__tests__/kpi.test.ts @@ -5,7 +5,9 @@ import { calculateRevenueGrowth, calculateExpenseRatio, calculateAvgPaymentDays, + calculateVatLiability, } from '../kpi' +import { VAT_INPUT_ACCOUNTS, VAT_OUTPUT_ACCOUNTS } from '../vat-declaration' import type { IncomeStatementReport, TrialBalanceRow } from '@/types' function makeIncomeStatement( @@ -105,6 +107,112 @@ describe('calculateCashPosition', () => { }) }) +describe('calculateVatLiability', () => { + it('returns positive liability for standard output VAT', () => { + const rows = [ + makeTrialBalanceRow({ account_number: '2611', closing_credit: 25000 }), + makeTrialBalanceRow({ account_number: '2641', closing_debit: 10000 }), + ] + expect(calculateVatLiability(rows)).toBe(15000) + }) + + it('nets EU reverse charge (2614 + 2645) to zero — issue #715', () => { + const rows = [ + makeTrialBalanceRow({ account_number: '2614', closing_credit: 2500 }), + makeTrialBalanceRow({ account_number: '2645', closing_debit: 2500 }), + ] + expect(calculateVatLiability(rows)).toBe(0) + }) + + it('nets domestic reverse charge (2614 + 2647) to zero', () => { + const rows = [ + makeTrialBalanceRow({ account_number: '2614', closing_credit: 1200 }), + makeTrialBalanceRow({ account_number: '2647', closing_debit: 1200 }), + ] + expect(calculateVatLiability(rows)).toBe(0) + }) + + it('nets import VAT (2615 + 2645) to zero', () => { + const rows = [ + makeTrialBalanceRow({ account_number: '2615', closing_credit: 800 }), + makeTrialBalanceRow({ account_number: '2645', closing_debit: 800 }), + ] + expect(calculateVatLiability(rows)).toBe(0) + }) + + it('reverse charge does not distort the net position alongside regular sales', () => { + const rows = [ + makeTrialBalanceRow({ account_number: '2611', closing_credit: 5000 }), + makeTrialBalanceRow({ account_number: '2641', closing_debit: 2000 }), + makeTrialBalanceRow({ account_number: '2614', closing_credit: 1000 }), + makeTrialBalanceRow({ account_number: '2645', closing_debit: 1000 }), + ] + // Old formula gave 5000 − (2000 + 1000) = 2000; correct is 3000 + expect(calculateVatLiability(rows)).toBe(3000) + }) + + it('returns negative for net VAT receivable', () => { + const rows = [ + makeTrialBalanceRow({ account_number: '2611', closing_credit: 1000 }), + makeTrialBalanceRow({ account_number: '2641', closing_debit: 4000 }), + ] + expect(calculateVatLiability(rows)).toBe(-3000) + }) + + it('ignores accounts outside the VAT declaration set', () => { + const rows = [ + makeTrialBalanceRow({ account_number: '2650', closing_credit: 9000 }), // redovisningskonto för moms + makeTrialBalanceRow({ account_number: '1930', closing_debit: 9000 }), + ] + expect(calculateVatLiability(rows)).toBe(0) + }) + + it('respects account overrides, splitting input/output on the 264x prefix', () => { + const rows = [ + makeTrialBalanceRow({ account_number: '2611', closing_credit: 5000 }), + makeTrialBalanceRow({ account_number: '2614', closing_credit: 1000 }), + makeTrialBalanceRow({ account_number: '2641', closing_debit: 2000 }), + ] + // Override excludes 2614 + expect(calculateVatLiability(rows, ['2611', '2641'])).toBe(3000) + }) + + it('handles debit balances on output accounts (corrections)', () => { + const rows = [ + makeTrialBalanceRow({ account_number: '2611', closing_credit: 5000, closing_debit: 500 }), + ] + expect(calculateVatLiability(rows)).toBe(4500) + }) +}) + +describe('VAT widget account lists (derived from ACCOUNT_RUTA)', () => { + // Drift guard: an ACCOUNT_RUTA change that alters these lists changes the + // dashboard widget's semantics — update this snapshot deliberately. + it('output accounts cover rutor 10–12, 30–32 and 60–62', () => { + expect([...VAT_OUTPUT_ACCOUNTS].sort()).toEqual([ + '2610', '2611', '2612', '2613', '2614', '2615', '2616', '2618', + '2620', '2621', '2622', '2623', '2624', '2625', '2626', '2628', + '2630', '2631', '2632', '2633', '2634', '2635', '2636', '2638', + ]) + }) + + it('input accounts cover ruta 48', () => { + expect([...VAT_INPUT_ACCOUNTS].sort()).toEqual([ + '2640', '2641', '2642', '2645', '2646', '2647', '2649', + ]) + }) + + it('the prefix split used by calculateVatLiability is exact for the defaults', () => { + for (const account of VAT_OUTPUT_ACCOUNTS) { + expect(account.startsWith('26')).toBe(true) + expect(account.startsWith('264')).toBe(false) + } + for (const account of VAT_INPUT_ACCOUNTS) { + expect(account.startsWith('264')).toBe(true) + } + }) +}) + describe('calculateRevenueGrowth', () => { it('returns positive growth', () => { // (120000 - 100000) / 100000 * 100 = 20% diff --git a/lib/reports/kassaflodesanalys-pdf-template.tsx b/lib/reports/kassaflodesanalys-pdf-template.tsx index 5fdc5a89..fc4d149e 100644 --- a/lib/reports/kassaflodesanalys-pdf-template.tsx +++ b/lib/reports/kassaflodesanalys-pdf-template.tsx @@ -314,6 +314,12 @@ export function KassaflodesanalysPDF({ Nyemission {formatAmount(report.finansierings.nyemission)} + + Erhållna aktieägartillskott + + {formatAmount(report.finansierings.erhallna_aktieagartillskott)} + + Kassaflöde från finansieringsverksamheten diff --git a/lib/reports/kassaflodesanalys.ts b/lib/reports/kassaflodesanalys.ts index 062aa0a5..95b938a9 100644 --- a/lib/reports/kassaflodesanalys.ts +++ b/lib/reports/kassaflodesanalys.ts @@ -26,7 +26,8 @@ import type { TrialBalanceRow } from '@/types' * * 10xx-13xx Anläggningstillgångar (capital goods) → investing * - * 20xx Eget kapital (nyemission, utdelning) → financing + * 20xx Eget kapital (nyemission, utdelning, + * erhållna aktieägartillskott 2093) → financing * 23xx Långfristiga skulder (lån) → financing * * 19xx Kassa och bank → reconciliation (target) @@ -61,6 +62,7 @@ export type KassaflodesanalysReport = { delta_lan: number utdelningar: number nyemission: number + erhallna_aktieagartillskott: number total: number } total_cash_flow: number @@ -281,12 +283,24 @@ export async function generateKassaflodesanalys( // Nyemission: increase in 20xx equity (excluding result-of-the-year and // dividends). Credit-normal: positive credit-side delta = cash inflow. - // We sum 2081 (share capital) + 2082 (premium) deltas specifically to - // avoid double-counting 2099 (årets resultat is non-cash). - const nyemissionDebit = sumDeltaByPrefix(rows, ['2081', '2082', '2083', '2087']) + // We sum 2081 (share capital) + 2082 (ej registrerat aktiekapital) + 2083 + // (medlemsinsatser) + 2086/2097 (bunden/fri överkursfond — the premium on + // an emission lands there under K2/K3) + 2087 (pågående nyemission), + // specifically avoiding 2099 (årets resultat is non-cash). + const nyemissionDebit = sumDeltaByPrefix(rows, ['2081', '2082', '2083', '2086', '2087', '2097']) const nyemission = r2(-nyemissionDebit) - const totalFinansierings = r2(deltaLan + utdelningar + nyemission) + // Erhållna aktieägartillskott (2093, villkorade + ovillkorade): a cash + // contribution from shareholders booked straight to equity. Credit-normal: + // increase = cash inflow → negate the debit-side delta. Issue #716: this + // account was previously unmapped, so any tillskott during the period + // showed 0 under finansiering and broke the 19xx reconciliation by exactly + // the contributed amount. + const erhallnaAktieagartillskott = r2(-sumDeltaByPrefix(rows, ['2093'])) + + const totalFinansierings = r2( + deltaLan + utdelningar + nyemission + erhallnaAktieagartillskott + ) // ─── Total cash flow ─────────────────────────────────────────────────── const totalCashFlow = r2(totalLopande + totalInvesterings + totalFinansierings) @@ -332,6 +346,7 @@ export async function generateKassaflodesanalys( delta_lan: deltaLan, utdelningar, nyemission, + erhallna_aktieagartillskott: erhallnaAktieagartillskott, total: totalFinansierings, }, total_cash_flow: totalCashFlow, diff --git a/lib/reports/kpi-definitions.ts b/lib/reports/kpi-definitions.ts index 980d44e6..8d97eb93 100644 --- a/lib/reports/kpi-definitions.ts +++ b/lib/reports/kpi-definitions.ts @@ -1,4 +1,5 @@ import type { KPIPreferences } from '@/types' +import { VAT_INPUT_ACCOUNTS, VAT_OUTPUT_ACCOUNTS } from '@/lib/reports/vat-declaration' export interface KPIDefinition { id: string @@ -37,7 +38,8 @@ export const KPI_DEFINITIONS: KPIDefinition[] = [ }, { id: 'vatLiability', - defaultAccounts: ['2611', '2621', '2631', '2641', '2645'], + // Same 26xx accounts as the momsdeklaration (ruta 49) — see vat-declaration.ts + defaultAccounts: [...VAT_OUTPUT_ACCOUNTS, ...VAT_INPUT_ACCOUNTS], customizableAccounts: true, defaultVisible: true, format: 'currency', diff --git a/lib/reports/kpi.ts b/lib/reports/kpi.ts index 53c04835..a078f492 100644 --- a/lib/reports/kpi.ts +++ b/lib/reports/kpi.ts @@ -1,4 +1,5 @@ import type { IncomeStatementReport, TrialBalanceRow } from '@/types' +import { VAT_INPUT_ACCOUNTS, VAT_OUTPUT_ACCOUNTS } from '@/lib/reports/vat-declaration' /** * Calculate gross margin from income statement. @@ -30,6 +31,45 @@ export function calculateCashPosition(rows: TrialBalanceRow[]): number { return Math.round(total * 100) / 100 } +/** + * Calculate net VAT liability (positive = att betala) or receivable + * (negative = att återfå) from trial balance rows. + * + * Uses the same 26xx accounts as the momsdeklaration so the result mirrors + * ruta 49: output VAT (rutor 10–12, 30–32, 60–62) − input VAT (ruta 48). + * Reverse-charge and import pairs (e.g. 2614 credit + 2645 debit) therefore + * net to zero instead of inflating the receivable (#715). + * + * `accounts` overrides the default list (user KPI preferences); accounts in + * the 264x range count as input VAT, all other 26xx accounts as output VAT. + */ +export function calculateVatLiability( + rows: TrialBalanceRow[], + accounts?: string[] +): number { + const vatAccounts = + accounts && accounts.length > 0 + ? accounts + : [...VAT_OUTPUT_ACCOUNTS, ...VAT_INPUT_ACCOUNTS] + + const outputVat = rows + .filter( + (r) => + vatAccounts.includes(r.account_number) && + r.account_number.startsWith('26') && + !r.account_number.startsWith('264') + ) + .reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0) + const inputVat = rows + .filter( + (r) => + vatAccounts.includes(r.account_number) && r.account_number.startsWith('264') + ) + .reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0) + + return Math.round((outputVat - inputVat) * 100) / 100 +} + /** * Calculate revenue growth between two periods. * Returns percentage or null if no previous period data. diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index 19d76654..2c10f67d 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -127,6 +127,20 @@ export const ACCOUNT_RUTA: Record account.startsWith('26') && mapping.side === 'credit') + .map(([account]) => account) + +/** Input VAT accounts feeding ruta 48 (2640–2649 series). */ +export const VAT_INPUT_ACCOUNTS = Object.entries(ACCOUNT_RUTA) + .filter(([, mapping]) => mapping.box === 'ruta48') + .map(([account]) => account) + /** * Calculate period start and end dates */ diff --git a/messages/en.json b/messages/en.json index 4726acbb..beec902a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3462,8 +3462,8 @@ "def_outstandingReceivables_accounts": "Accounts receivable (1510)", "def_vatLiability_label": "VAT", "def_vatLiability_description": "VAT liability or receivable for the period", - "def_vatLiability_formula": "Output VAT (2611 + 2621 + 2631) − Input VAT (2641 + 2645)", - "def_vatLiability_accounts": "Output VAT (2611, 2621, 2631), Input VAT (2641, 2645)", + "def_vatLiability_formula": "Output VAT (rutor 10–12, 30–32, 60–62) − Input VAT (ruta 48) — same accounts as the VAT declaration", + "def_vatLiability_accounts": "Output VAT incl. reverse charge and import (2610–2638), Input VAT (2640–2649)", "def_grossMargin_label": "Gross margin", "def_grossMargin_description": "Share of revenue remaining after cost of goods", "def_grossMargin_formula": "(Revenue − Cost of goods class 4) ÷ Revenue × 100", diff --git a/messages/sv.json b/messages/sv.json index 95c77787..948809e7 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3462,8 +3462,8 @@ "def_outstandingReceivables_accounts": "Kundfordringar (1510)", "def_vatLiability_label": "Moms", "def_vatLiability_description": "Momsskuld eller momsfordran för perioden", - "def_vatLiability_formula": "Utgående moms (2611 + 2621 + 2631) − Ingående moms (2641 + 2645)", - "def_vatLiability_accounts": "Utgående moms (2611, 2621, 2631), Ingående moms (2641, 2645)", + "def_vatLiability_formula": "Utgående moms (rutor 10–12, 30–32, 60–62) − Ingående moms (ruta 48) — samma konton som momsdeklarationen", + "def_vatLiability_accounts": "Utgående moms inkl. omvänd skattskyldighet och import (2610–2638), Ingående moms (2640–2649)", "def_grossMargin_label": "Bruttomarginal", "def_grossMargin_description": "Andel av intäkterna som blir kvar efter varuinköp", "def_grossMargin_formula": "(Intäkter − Varukostnad klass 4) ÷ Intäkter × 100",