{/* 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
+
+ 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",