From 88f49c0ccc901bb9ccf4feabe0d4d2e1ee30c5ae Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:17:44 +0200 Subject: [PATCH] fix(bookkeeping): harden correction flow and align VAT/cashflow reports (#726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles a set of bookkeeping-correctness fixes developed together. Correction / storno flow - correctEntry resolves (and seeds standard BAS) accounts for the corrected lines BEFORE writing the storno. The old order created and posted the storno first, then hit AccountsNotInChartError on the corrected lines and had to cancel it again — leaving a voided 0 kr storno in the chain and permanently burning a voucher number (an unexplained BFNAR 2013:2 gap). It now fails fast with nothing written. - correctEntry re-points the bank transaction and underlag from the reversed original to the live corrected entry, so the transaction keeps reading as booked (and stays correctable) and the underlag travels with it. recordateEntry delegates both relinks to correctEntry. - reverseEntry (engine) clears transactions.journal_entry_id for rows booked by the reversed entry, so a plain storno returns the bank row to "Att bokföra" with a re-booking affordance. The agent paths did this manually; the dashboard reverse route did not. - findUnresolvableAccounts replaces findMissingActiveAccounts in the categorize routes: a standard BAS account merely absent from the chart is seeded on demand by the engine, so pre-validation must not 400 on it — only unknown numbers or deactivated accounts block. - CorrectionChain dims cancelled (0 kr) entries and labels them so they no longer render like a live storno. Report accuracy - calculateVatLiability() (lib/reports/kpi.ts) is shared by the KPI route, the KPI xlsx export and the MCP period-summary tool, and uses the same 26xx accounts as the momsdeklaration (ruta 49). Reverse-charge and import pairs (e.g. 2614 credit + 2645 debit) net to zero instead of inflating the receivable (#715). VAT_OUTPUT_ACCOUNTS / VAT_INPUT_ACCOUNTS are derived from ACCOUNT_RUTA so the widget can never drift from the declaration. - Kassaflödesanalys records erhållna aktieägartillskott (2093) as a financing inflow and counts överkursfond (2086/2097) toward nyemission. 2093 was previously unmapped, so any contribution broke the 19xx reconciliation by exactly the contributed amount (#716). Wired through the report type, both PDF templates, the K3 PDF, the dashboard client and the årsredovisning summary type. Agent guidance - shared-rules: describe the real Accounted correction flow (Rätta rader / Rätta datum / Radera verifikat, on-demand BAS backfill) so the assistant stops inventing flows that don't exist. - verifikation-draft: clearer locked-period guidance. Tests cover all of the above (storno fail-fast + seeding + relink, reverseEntry unlink, findUnresolvableAccounts, VAT netting and the cashflow reconciliation cases). Co-authored-by: Claude Opus 4.8 (1M context) --- .../KassaflodesanalysClient.tsx | 4 + app/api/reports/kpi/route.ts | 26 +--- app/api/reports/kpi/xlsx/route.ts | 11 +- .../[id]/categorize/__tests__/route.test.ts | 2 +- app/api/transactions/[id]/categorize/route.ts | 10 +- .../transactions/[id]/__tests__/route.test.ts | 2 +- .../transactions/[id]/categorize/route.ts | 9 +- .../batch-categorize/__tests__/route.test.ts | 6 +- .../transactions/batch-categorize/route.ts | 7 +- components/bookkeeping/CorrectionChain.tsx | 11 +- extensions/general/mcp-server/server.ts | 13 +- lib/agent/intents/shared-rules.ts | 14 ++ lib/agent/intents/verifikation-draft.ts | 2 +- .../__tests__/arsredovisning-k3-pdf.test.ts | 2 +- .../__tests__/arsredovisning-k3.test.ts | 1 + .../arsredovisning/arsredovisning-k3-pdf.tsx | 6 + lib/bokslut/arsredovisning/types.ts | 1 + .../__tests__/account-validation.test.ts | 96 +++++++++++++ lib/bookkeeping/__tests__/engine.test.ts | 90 ++++++++++++ lib/bookkeeping/account-validation.ts | 39 +++++ lib/bookkeeping/engine.ts | 15 ++ .../__tests__/recordate-entry.test.ts | 21 ++- .../__tests__/storno-service.test.ts | 135 +++++++++++++----- lib/core/bookkeeping/storno-service.ts | 101 +++++++++---- .../__tests__/kassaflodesanalys.test.ts | 81 +++++++++++ lib/reports/__tests__/kpi.test.ts | 108 ++++++++++++++ .../kassaflodesanalys-pdf-template.tsx | 6 + lib/reports/kassaflodesanalys.ts | 25 +++- lib/reports/kpi-definitions.ts | 4 +- lib/reports/kpi.ts | 40 ++++++ lib/reports/vat-declaration.ts | 14 ++ messages/en.json | 4 +- messages/sv.json | 4 +- 33 files changed, 774 insertions(+), 136 deletions(-) create mode 100644 lib/bookkeeping/__tests__/account-validation.test.ts 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",