fix(bookkeeping): harden correction flow and align VAT/cashflow reports (#726)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -251,6 +251,10 @@ export function KassaflodesanalysClient() {
|
||||
/>
|
||||
<CashRow label="Utdelningar" amount={report.finansierings.utdelningar} />
|
||||
<CashRow label="Nyemission" amount={report.finansierings.nyemission} />
|
||||
<CashRow
|
||||
label="Erhållna aktieägartillskott"
|
||||
amount={report.finansierings.erhallna_aktieagartillskott}
|
||||
/>
|
||||
<SubtotalRow
|
||||
label="Summa kassaflöde finansieringsverksamhet"
|
||||
amount={report.finansierings.total}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
calculateGrossMargin,
|
||||
calculateExpenseRatio,
|
||||
calculateAvgPaymentDays,
|
||||
calculateVatLiability,
|
||||
} from '@/lib/reports/kpi'
|
||||
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
@@ -93,27 +94,10 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
// VAT liability — use account overrides if set
|
||||
const vatOverrides = preferences.accountOverrides['vatLiability']
|
||||
let vatLiability: number
|
||||
if (vatOverrides && vatOverrides.length > 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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -67,7 +67,7 @@ vi.mock('@/lib/bookkeeping/account-validation', async () => {
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
findMissingActiveAccounts: (...args: unknown[]) => mockFindMissingActiveAccounts(...args),
|
||||
findUnresolvableAccounts: (...args: unknown[]) => mockFindMissingActiveAccounts(...args),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 (
|
||||
<Link
|
||||
@@ -67,9 +72,9 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
|
||||
href={`/bookkeeping/${entry.id}`}
|
||||
className="block"
|
||||
>
|
||||
<div className={`relative pl-7 py-2 rounded-md transition-colors hover:bg-muted/50 ${isCurrent ? 'bg-muted/30' : ''}`}>
|
||||
<div className={`relative pl-7 py-2 rounded-md transition-colors hover:bg-muted/50 ${isCurrent ? 'bg-muted/30' : ''} ${isCancelled ? 'opacity-60' : ''}`}>
|
||||
{/* Timeline dot */}
|
||||
<div className={`absolute left-0.5 top-[18px] h-3 w-3 rounded-full border-2 border-background ${role.color}`} />
|
||||
<div className={`absolute left-0.5 top-[18px] h-3 w-3 rounded-full border-2 border-background ${isCancelled ? 'bg-muted-foreground' : role.color}`} />
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-muted-foreground">{role.label}</span>
|
||||
@@ -77,7 +82,7 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
|
||||
{formatVoucher(entry)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">{formatDate(entry.entry_date)}</span>
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={false} />
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={isCancelled} />
|
||||
{isCurrent && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{t('current')}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -256,6 +256,7 @@ function plantStandardReports() {
|
||||
delta_lan: 0,
|
||||
utdelningar: 0,
|
||||
nyemission: 0,
|
||||
erhallna_aktieagartillskott: 0,
|
||||
total: 0,
|
||||
},
|
||||
total_cash_flow: 300_000,
|
||||
|
||||
@@ -380,6 +380,12 @@ export function ArsredovisningK3PDF({ data }: { data: ArsredovisningData }) {
|
||||
{fmt(data.kassaflodesanalys.finansierings.nyemission)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.tableRow}>
|
||||
<Text style={styles.colLabel}>Erhållna aktieägartillskott</Text>
|
||||
<Text style={styles.colAmount}>
|
||||
{fmt(data.kassaflodesanalys.finansierings.erhallna_aktieagartillskott)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.tableRowSubtotal}>
|
||||
<Text style={styles.colLabel}>Kassaflöde från finansieringsverksamheten</Text>
|
||||
<Text style={styles.colAmount}>
|
||||
|
||||
@@ -154,6 +154,7 @@ export interface KassaflodesAnalysisSummary {
|
||||
delta_lan: number
|
||||
utdelningar: number
|
||||
nyemission: number
|
||||
erhallna_aktieagartillskott: number
|
||||
total: number
|
||||
}
|
||||
total_cash_flow: number
|
||||
|
||||
@@ -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<string, unknown> = {}
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> = {}
|
||||
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<string, unknown> = {}
|
||||
|
||||
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<string, unknown> = {}
|
||||
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<string, unknown> = {}
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<string>((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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Map<string, string>> => {
|
||||
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<string, string>()
|
||||
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<string, string>()
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%
|
||||
|
||||
@@ -314,6 +314,12 @@ export function KassaflodesanalysPDF({
|
||||
<Text style={styles.label}>Nyemission</Text>
|
||||
<Text style={styles.amount}>{formatAmount(report.finansierings.nyemission)}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Erhållna aktieägartillskott</Text>
|
||||
<Text style={styles.amount}>
|
||||
{formatAmount(report.finansierings.erhallna_aktieagartillskott)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.subtotalRow}>
|
||||
<Text style={styles.subtotalLabel}>
|
||||
Kassaflöde från finansieringsverksamheten
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -127,6 +127,20 @@ export const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side
|
||||
|
||||
const VAT_ACCOUNTS = Object.keys(ACCOUNT_RUTA)
|
||||
|
||||
/**
|
||||
* 26xx output VAT accounts feeding rutor 10/11/12, 30/31/32 and 60/61/62.
|
||||
* Derived from ACCOUNT_RUTA so the KPI vatLiability widget can never drift
|
||||
* from the momsdeklaration (ruta 49) calculation.
|
||||
*/
|
||||
export const VAT_OUTPUT_ACCOUNTS = Object.entries(ACCOUNT_RUTA)
|
||||
.filter(([account, mapping]) => 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
|
||||
*/
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user