/** * Kontantmetoden year-end cut-off (BFL 5 kap 2 §). * * Under kontantmetoden (bokslutsmetoden) affärshändelser are booked when cash * moves, so open customer and supplier invoices never reach 1510 / 2440 during * the year. BFL still requires that fordringar och skulder ARE booked at * räkenskapsårets utgång, so the year-end needs a cut-off entry that puts every * still-outstanding invoice onto the balance sheet. * * Moms is the part that is easy to get wrong. Under bokslutsmetoden, unpaid * invoice moms must be included in the final VAT period of the year. BAS * provides 2618/2628/2638 for year-end output VAT and 2648 for year-end input * VAT. The VAT report maps those accounts for the cut-off date, while excluding * the mechanical day-one reversal so it cannot undo the final declaration. * * Shape: two aggregate verifikat (one for fordringar, one for skulder), each * reversed on the first day of the following period. Deliberately NOT * per-invoice, and deliberately not linked through invoices.journal_entry_id: * * - the payment flows route on whether a live journal-entry link exists, so * linking here would make every new-year payment take the accrual clearing * path against a receivable the reversal has already removed, booking the * settlement twice; * - leaving the link unset means a new-year payment still books the normal * kontantmetoden cash entry (revenue/expense + real moms at the payment * date), which is what bokslutsmetoden requires. * * The reversal is what makes that safe: cut-off on the last day of the year, * vändning on the first day of the next, and the ledger is back to a pure cash * basis before any new-year payment is booked. */ import type { SupabaseClient } from '@supabase/supabase-js' import { createHash } from 'node:crypto' import type { CreateJournalEntryLineInput, EntityType, JournalEntry, VatTreatment, } from '@/types' import { getRevenueAccount } from '@/lib/bookkeeping/invoice-entries' import { generateReverseChargeBasisLines, generateReverseChargeLines, isReverseChargeBasisAccount, resolveReverseChargeRate, } from '@/lib/bookkeeping/vat-entries' import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine' import { createLogger } from '@/lib/logger' import { ORE_TOLERANCE, roundOre } from '@/lib/money' import { fetchAllRows } from '@/lib/supabase/fetch-all' const log = createLogger('kontantmetod-cutoff') /** * Vilande utgående moms per VAT treatment. Rates outside 25/12/6 (export, * reverse charge, exempt) carry no Swedish output moms at all, so they never * reach this map: their whole outstanding amount is revenue. */ export const VILANDE_OUTPUT_VAT_ACCOUNTS: Partial> = { standard_25: '2618', reduced_12: '2628', reduced_6: '2638', } /** Vilande ingående moms. One account for every rate, mirroring 2641. */ export const VILANDE_INPUT_VAT_ACCOUNT = '2648' export const RECEIVABLES_ACCOUNT = '1510' export const PAYABLES_ACCOUNT = '2440' export const KONTANTMETOD_CUTOFF_DESCRIPTIONS = { receivable: 'Kundfordringar vid bokslut (kontantmetoden)', receivableReversal: 'Vändning kundfordringar bokslut (kontantmetoden)', payable: 'Leverantörsskulder vid bokslut (kontantmetoden)', payableReversal: 'Vändning leverantörsskulder bokslut (kontantmetoden)', } as const /** A customer invoice still outstanding at period end. Amounts are SEK. */ export interface CutoffReceivable { id: string /** Human reference for the line description. */ reference: string vatTreatment: VatTreatment /** Outstanding INCLUDING moms at period end. */ outstanding: number /** The moms share of `outstanding`. */ vat: number } /** A supplier invoice still outstanding at period end. Amounts are SEK. */ export interface CutoffPayable { id: string reference: string /** Outstanding INCLUDING moms at period end. */ outstanding: number /** The ingående moms share of `outstanding`. */ vat: number /** * Omvänd betalningsskyldighet. The supplier charges no moms, so the buyer * self-assesses output AND input moms on 2614/2624/2634 + 2645/2647, which * is a symmetric pair that must never be split. `vat` is 0 on every such row * by construction, and this flag forces it to 0 anyway: routing a stray * amount into the single 2648 bucket would post a one-sided reverse charge. * The complete output/input pair and its declaration basis are included in * the final VAT period through `reverseChargeGroups` below. */ reverseCharge?: boolean reverseChargeGroups?: Array<{ rate: number base: number nonBasisBase: number supplierType: 'eu_business' | 'non_eu_business' | 'swedish_business' }> /** * Net expense split across BAS accounts, as weights. Only the ratios matter: * the net total is always derived as `outstanding - vat` so the verifikat * balances no matter how the source rows round. */ netByAccount: Array<{ account: string; amount: number }> } export interface CutoffLines { receivableLines: CreateJournalEntryLineInput[] payableLines: CreateJournalEntryLineInput[] receivableTotal: number payableTotal: number } interface PostedCutoffEntry { id: string fiscal_period_id: string entry_date: string description: string lines: Array<{ account_number: string debit_amount: number | string | null credit_amount: number | string | null }> } export interface KontantmetodCutoffPostingStatus { complete: boolean hasAny: boolean receivableEntryId: string | null receivableReversalId: string | null payableEntryId: string | null payableReversalId: string | null missing: Array<'receivable' | 'receivable_reversal' | 'payable' | 'payable_reversal'> duplicates: string[] } export function hasIncompleteKontantmetodCutoffPair( status: KontantmetodCutoffPostingStatus, lines: CutoffLines, ): boolean { const receivablePartial = lines.receivableLines.length > 0 && Boolean(status.receivableEntryId) !== Boolean(status.receivableReversalId) const payablePartial = lines.payableLines.length > 0 && Boolean(status.payableEntryId) !== Boolean(status.payableReversalId) return status.duplicates.length > 0 || receivablePartial || payablePartial } // Go through roundOre first: Math.round(x * 100) alone mis-rounds exact-half // values that arrive with float drift (lib/money.ts). const toOre = (amount: number): number => Math.round(roundOre(amount) * 100) const toKronor = (ore: number): number => ore / 100 /** * Split `totalOre` across `weights` so the parts sum to exactly `totalOre`. * * Proportional shares with largest-remainder allocation. Doing this in whole * öre (rather than rounding each share independently) is what keeps the * verifikat balanced: independent rounding drifts by an öre per bucket and the * DB balance trigger would reject the entry. */ export function distributeOre(totalOre: number, weights: number[]): number[] { if (weights.length === 0) return [] if (weights.length === 1) return [totalOre] const sign = totalOre < 0 ? -1 : 1 const absoluteTotal = Math.abs(totalOre) const weightSum = weights.reduce((sum, w) => sum + Math.abs(w), 0) // Degenerate input (all-zero weights): put everything on the first bucket // rather than emitting NaN. if (weightSum === 0) return weights.map((_, i) => (i === 0 ? totalOre : 0)) const exact = weights.map((w) => (Math.abs(w) / weightSum) * absoluteTotal) const floors = exact.map((value) => Math.floor(value)) let remainder = absoluteTotal - floors.reduce((sum, value) => sum + value, 0) // Hand the leftover öre to the largest fractional parts first. const order = exact .map((value, index) => ({ index, frac: value - Math.floor(value) })) .sort((a, b) => b.frac - a.frac) const result = [...floors] for (const { index } of order) { if (remainder <= 0) break result[index] += 1 remainder -= 1 } return result.map((value) => value * sign) } function signedLine( accountNumber: string, normalSide: 'debit' | 'credit', signedOre: number, lineDescription: string, ): CreateJournalEntryLineInput { const normal = signedOre >= 0 const amount = toKronor(Math.abs(signedOre)) const debit = (normalSide === 'debit') === normal return { account_number: accountNumber, debit_amount: debit ? amount : 0, credit_amount: debit ? 0 : amount, line_description: lineDescription, } } /** * Build the cut-off verifikat lines. Pure: no IO, so the money math is * directly testable. * * Receivables: Debit 1510 / Credit 30xx + Credit 2618|2628|2638 * Payables: Debit 4-6xxx + Debit 2648 / Credit 2440 */ export function buildCutoffLines( receivables: CutoffReceivable[], payables: CutoffPayable[], entityType: EntityType = 'aktiebolag', ): CutoffLines { const receivableLines: CreateJournalEntryLineInput[] = [] const payableLines: CreateJournalEntryLineInput[] = [] // ---- Fordringar ------------------------------------------------------- // Group by VAT treatment: the revenue account and the vilande moms account // both follow from it. const revenueByTreatment = new Map() const outputVatByTreatment = new Map() let receivableOre = 0 for (const row of receivables) { const outstandingOre = toOre(row.outstanding) if (outstandingOre === 0) continue // Derive net from outstanding minus moms so the two legs always add back // to the receivable, whatever rounding the source row carries. const vatOre = toOre(row.vat) const netOre = outstandingOre - vatOre receivableOre += outstandingOre revenueByTreatment.set(row.vatTreatment, (revenueByTreatment.get(row.vatTreatment) ?? 0) + netOre) if (vatOre !== 0) { outputVatByTreatment.set( row.vatTreatment, (outputVatByTreatment.get(row.vatTreatment) ?? 0) + vatOre, ) } } if (receivableOre !== 0) { receivableLines.push(signedLine( RECEIVABLES_ACCOUNT, 'debit', receivableOre, 'Kundfordringar vid räkenskapsårets utgång (kontantmetoden)', )) for (const [treatment, netOre] of revenueByTreatment) { if (netOre === 0) continue receivableLines.push(signedLine( getRevenueAccount(treatment, entityType), 'credit', netOre, 'Obetalda kundfakturor vid bokslut', )) } for (const [treatment, vatOre] of outputVatByTreatment) { const account = VILANDE_OUTPUT_VAT_ACCOUNTS[treatment] // No vilande account means the treatment carries no Swedish output moms // (export, omvänd betalningsskyldighet, undantagen). A non-zero moms // amount there is a data error: fold it into revenue rather than invent // a moms account, so the verifikat still balances and the anomaly shows // up as revenue rather than as a phantom momsskuld. if (!account) { log.warn('outstanding moms on a treatment with no vilande account; booked as revenue', { treatment, ore: vatOre, }) receivableLines.push(signedLine( getRevenueAccount(treatment, entityType), 'credit', vatOre, 'Obetalda kundfakturor vid bokslut', )) continue } receivableLines.push(signedLine( account, 'credit', vatOre, 'Utgående moms på obetald faktura vid bokslut', )) } } // ---- Skulder ---------------------------------------------------------- const expenseByAccount = new Map() const reverseChargeByGroup = new Map() let payableOre = 0 let inputVatOre = 0 for (const row of payables) { const outstandingOre = toOre(row.outstanding) if (outstandingOre === 0) continue // Reverse charge carries no charged moms on the invoice itself. The // self-assessed pair is built from its basis groups below, never split into // the single vilande bucket. Force the invoice moms field to zero. const vatOre = row.reverseCharge ? 0 : toOre(row.vat) const netOre = outstandingOre - vatOre payableOre += outstandingOre inputVatOre += vatOre const buckets = row.netByAccount.length > 0 ? row.netByAccount // No item detail: park the net on the generic övriga kostnader account // rather than dropping it. The entry is reversed the next day, so the // account choice never survives into the new year. : [{ account: '6990', amount: 1 }] const shares = distributeOre(netOre, buckets.map((b) => b.amount)) buckets.forEach((bucket, index) => { const share = shares[index] if (share === 0) return expenseByAccount.set(bucket.account, (expenseByAccount.get(bucket.account) ?? 0) + share) }) for (const group of row.reverseChargeGroups ?? []) { const key = `${group.supplierType}:${group.rate}` const current = reverseChargeByGroup.get(key) ?? { rate: group.rate, baseOre: 0, nonBasisBaseOre: 0, supplierType: group.supplierType, } current.baseOre += toOre(group.base) current.nonBasisBaseOre += toOre(group.nonBasisBase) reverseChargeByGroup.set(key, current) } } if (payableOre !== 0) { for (const [account, netOre] of expenseByAccount) { if (netOre === 0) continue payableLines.push(signedLine( account, 'debit', netOre, 'Obetalda leverantörsfakturor vid bokslut', )) } if (inputVatOre !== 0) { payableLines.push(signedLine( VILANDE_INPUT_VAT_ACCOUNT, 'debit', inputVatOre, 'Ingående moms på obetald faktura vid bokslut', )) } const appendReverseChargeLines = ( generated: CreateJournalEntryLineInput[], sign: number, ) => { for (const line of generated) { const normalSide = line.debit_amount > 0 ? 'debit' : 'credit' const amount = line.debit_amount || line.credit_amount payableLines.push(signedLine( line.account_number, normalSide, toOre(amount) * sign, line.line_description ?? 'Omvänd skattskyldighet vid bokslut', )) } } for (const group of reverseChargeByGroup.values()) { if (group.baseOre === 0) continue const sign = group.baseOre < 0 ? -1 : 1 const base = toKronor(Math.abs(group.baseOre)) const nonBasisBase = toKronor(Math.abs(group.nonBasisBaseOre)) appendReverseChargeLines( generateReverseChargeLines( base, group.rate, group.supplierType === 'swedish_business', ), sign, ) appendReverseChargeLines( generateReverseChargeBasisLines(nonBasisBase, group.rate, group.supplierType), sign, ) } payableLines.push(signedLine( PAYABLES_ACCOUNT, 'credit', payableOre, 'Leverantörsskulder vid räkenskapsårets utgång (kontantmetoden)', )) } return { receivableLines, payableLines, receivableTotal: toKronor(Math.abs(receivableOre)), payableTotal: toKronor(Math.abs(payableOre)), } } function comparableLines(lines: Array<{ account_number: string debit_amount: number | string | null credit_amount: number | string | null }>): string[] { return lines .map((line) => [ line.account_number, roundOre(Number(line.debit_amount ?? 0)).toString(), roundOre(Number(line.credit_amount ?? 0)).toString(), ].join(':'), ) .sort() } export function cutoffLinesEqual( left: CreateJournalEntryLineInput[], right: Array<{ account_number: string debit_amount: number | string | null credit_amount: number | string | null }>, ): boolean { const normalizedLeft = comparableLines(left) const normalizedRight = comparableLines(right) return normalizedLeft.length === normalizedRight.length && normalizedLeft.every((line, index) => line === normalizedRight[index]) } /** * Inspect the immutable journal for a complete cut-off and its day-one * reversals. Matching exact account totals, rather than only a description, * makes a late invoice or payment reopen the blocker until a fresh cut-off is * posted. `source_id` anchors all four entries to the year being closed. */ export async function inspectKontantmetodCutoffPostings( supabase: SupabaseClient, companyId: string, fiscalPeriodId: string, nextFiscalPeriodId: string, periodEnd: string, expected: CutoffLines, ): Promise { const { data, error } = await supabase .from('journal_entries') .select( 'id, fiscal_period_id, entry_date, description, lines:journal_entry_lines(account_number, debit_amount, credit_amount)', ) .eq('company_id', companyId) .eq('source_type', 'year_end') .eq('source_id', fiscalPeriodId) .eq('status', 'posted') .in('fiscal_period_id', [fiscalPeriodId, nextFiscalPeriodId]) .in('description', Object.values(KONTANTMETOD_CUTOFF_DESCRIPTIONS)) if (error) { throw new Error(`Kontantmetodens bokslutsavgränsning kunde inte kontrolleras: ${error.message}`) } const rows = (data ?? []) as PostedCutoffEntry[] const missing: KontantmetodCutoffPostingStatus['missing'] = [] const duplicates: string[] = [] const matchOne = ( description: string, periodId: string, lines: CreateJournalEntryLineInput[], missingKind: KontantmetodCutoffPostingStatus['missing'][number], expectedDate: string, ): string | null => { const candidates = rows.filter( (row) => row.description === description && row.fiscal_period_id === periodId, ) if (lines.length === 0) { if (candidates.length > 0) duplicates.push(description) return null } const exact = candidates.filter( (row) => row.entry_date === expectedDate && cutoffLinesEqual(lines, row.lines ?? []), ) if (candidates.length !== 1 || exact.length !== 1) { // Any marker with non-matching lines is a conflict, even when only one // exists. Treating it as merely missing could stage a second cut-off on // top of an immutable entry after the source reskontra changed. if (candidates.length > 0) duplicates.push(description) missing.push(missingKind) return null } return exact[0]!.id } const receivableEntryId = matchOne( KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable, fiscalPeriodId, expected.receivableLines, 'receivable', periodEnd, ) const receivableReversalId = matchOne( KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal, nextFiscalPeriodId, reverseLines(expected.receivableLines), 'receivable_reversal', nextDay(periodEnd), ) const payableEntryId = matchOne( KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable, fiscalPeriodId, expected.payableLines, 'payable', periodEnd, ) const payableReversalId = matchOne( KONTANTMETOD_CUTOFF_DESCRIPTIONS.payableReversal, nextFiscalPeriodId, reverseLines(expected.payableLines), 'payable_reversal', nextDay(periodEnd), ) return { complete: missing.length === 0 && duplicates.length === 0, hasAny: rows.length > 0, receivableEntryId, receivableReversalId, payableEntryId, payableReversalId, missing, duplicates, } } /** Swap every debit and credit: the vändning posted on day 1 of the new year. */ export function reverseLines( lines: CreateJournalEntryLineInput[], ): CreateJournalEntryLineInput[] { return lines.map((line) => ({ ...line, debit_amount: line.credit_amount, credit_amount: line.debit_amount, line_description: `Vändning: ${line.line_description ?? ''}`.trim(), })) } /** The day after `date`, ISO. Used to date the vändning. */ export function nextDay(date: string): string { const d = new Date(`${date}T00:00:00Z`) d.setUTCDate(d.getUTCDate() + 1) return d.toISOString().slice(0, 10) } export interface CutoffCollection { receivables: CutoffReceivable[] payables: CutoffPayable[] /** * Invoices whose vat_treatment is missing. Never guessed at: a reduced-rate * or exempt invoice silently defaulted to 25 % would land on the wrong * vilande account and the wrong revenue account. Posting refuses while this * is non-empty so the user fixes the source rows instead. */ unknownVatTreatment: string[] /** * Invoices carrying moms on a treatment that cannot have Swedish output moms * (export, omvänd betalningsskyldighet, undantagen). Absorbing that into the * revenue line would balance the verifikat while silently swallowing a real * invoicing error, which is the netting the swedish-vat reference prohibits. * Excluded and refused on the same footing as a missing treatment. */ strayVatOnZeroRate: string[] } export interface KontantmetodCutoffAssessment { collection: CutoffCollection lines: CutoffLines postings: KontantmetodCutoffPostingStatus } export function sortedCutoffCollection(collection: CutoffCollection): CutoffCollection { return { receivables: [...collection.receivables].sort((a, b) => a.id.localeCompare(b.id)), payables: [...collection.payables] .map((row) => ({ ...row, netByAccount: [...row.netByAccount].sort((a, b) => a.account.localeCompare(b.account)), })) .sort((a, b) => a.id.localeCompare(b.id)), unknownVatTreatment: [...collection.unknownVatTreatment].sort(), strayVatOnZeroRate: [...collection.strayVatOnZeroRate].sort(), } } export function cutoffCollectionsEqual( left: CutoffCollection, right: CutoffCollection, ): boolean { return JSON.stringify(sortedCutoffCollection(left)) === JSON.stringify(sortedCutoffCollection(right)) } function canonicalLines(lines: CreateJournalEntryLineInput[]): string[] { return lines .map((line) => JSON.stringify({ account_number: line.account_number, debit_amount: roundOre(line.debit_amount), credit_amount: roundOre(line.credit_amount), line_description: line.line_description ?? null, })) .sort() } export function cutoffPreviewFingerprint(args: { collection: CutoffCollection lines: CutoffLines entityType: EntityType periodEnd: string }): string { const payload = JSON.stringify({ collection: sortedCutoffCollection(args.collection), entity_type: args.entityType, period_end: args.periodEnd, reversal_date: nextDay(args.periodEnd), receivable_lines: canonicalLines(args.lines.receivableLines), payable_lines: canonicalLines(args.lines.payableLines), }) return createHash('sha256').update(payload).digest('hex') } /** * An aggregate verifikat still has to say which affärshändelser it covers * (BFL 5 kap 6-7 §: motpart and underlag must be traceable). The lines are * grouped by account, so the invoice references go into the entry `notes` * where an examiner can follow them back to the sub-ledger. * * Truncated past a sane length: the note is a pointer to the reskontra, not a * replacement for it, and an unbounded note on a company with thousands of * open invoices helps nobody. */ export function buildCutoffNote(label: string, references: string[]): string { const named = references.filter((ref) => ref && ref.trim().length > 0) if (named.length === 0) return `${label}: inga fakturanummer registrerade` const MAX = 50 const shown = named.slice(0, MAX).join(', ') const rest = named.length - Math.min(named.length, MAX) return rest > 0 ? `${label} (${named.length} st): ${shown} och ${rest} till. ` + 'Fullständig specifikation finns i reskontran per bokslutsdagen.' : `${label} (${named.length} st): ${shown}` } function resolveHeaderSek( row: Record, amountKey: string, sekKey: string, ): number { const amount = Number(row[amountKey] ?? 0) const sekValue = row[sekKey] const sek = sekValue == null ? null : Number(sekValue) if (sek != null && Number.isFinite(sek) && (amount === 0 || sek !== 0)) return sek const currency = String(row.currency ?? 'SEK').toUpperCase() if (currency === 'SEK') return amount const rate = Number(row.exchange_rate ?? 0) if (Number.isFinite(rate) && rate > 0) return roundOre(amount * rate) throw new Error( `Faktura ${String(row.id ?? '')} i ${currency} saknar användbart SEK-belopp eller valutakurs`, ) } /** * Fetch every invoice still outstanding at `periodEnd`. * * "Outstanding at period end" is deliberately payment-DATE based, not the * current remaining_amount: an invoice settled in January was still a * fordran on 31 December and must be part of the cut-off. Reading * remaining_amount would silently shrink the cut-off every day the user * delays running the bokslut. */ export async function collectKontantmetodCutoff( supabase: SupabaseClient, companyId: string, periodStart: string, periodEnd: string, ): Promise { let invoices: Array> let supplierInvoices: Array> try { [invoices, supplierInvoices] = await Promise.all([ fetchAllRows>( ({ from, to }) => supabase .from('invoices') .select('id, invoice_number, invoice_date, status, total, total_sek, vat_amount, vat_amount_sek, vat_treatment, credited_invoice_id, document_type, currency, exchange_rate') .eq('company_id', companyId) .lte('invoice_date', periodEnd) .in('status', ['sent', 'overdue', 'partially_paid', 'paid', 'credited']) .order('id', { ascending: true }) .range(from, to), { dedupeBy: (row) => row.id as string }, ), fetchAllRows>( ({ from, to }) => supabase .from('supplier_invoices') .select('id, supplier_invoice_number, invoice_date, status, total, total_sek, vat_amount, vat_amount_sek, reverse_charge, is_credit_note, credited_invoice_id, currency, exchange_rate, supplier:suppliers(supplier_type), items:supplier_invoice_items(account_number, line_total, vat_rate, reverse_charge_rate)') .eq('company_id', companyId) .lte('invoice_date', periodEnd) .in('status', ['registered', 'approved', 'partially_paid', 'paid', 'credited']) .order('id', { ascending: true }) .range(from, to), { dedupeBy: (row) => row.id as string }, ), ]) } catch (err) { throw new Error( 'Kontantmetodens bokslutsavgränsning kunde inte läsa reskontran: ' + (err instanceof Error ? err.message : 'okänt fel'), ) } // Payments ON OR BEFORE period end reduce the outstanding balance; later // ones must not. Amount is stored in the invoice's own currency. let invoicePayments: Array> let supplierPayments: Array> try { [invoicePayments, supplierPayments] = await Promise.all([ fetchAllRows>( ({ from, to }) => supabase .from('invoice_payments') .select('id, invoice_id, amount, payment_date') .eq('company_id', companyId) .lte('payment_date', periodEnd) .order('id', { ascending: true }) .range(from, to), { dedupeBy: (row) => row.id as string }, ), fetchAllRows>( ({ from, to }) => supabase .from('supplier_invoice_payments') .select('id, supplier_invoice_id, amount, payment_date') .eq('company_id', companyId) .lte('payment_date', periodEnd) .order('id', { ascending: true }) .range(from, to), { dedupeBy: (row) => row.id as string }, ), ]) } catch (err) { throw new Error( 'Kontantmetodens bokslutsavgränsning kunde inte läsa betalningar: ' + (err instanceof Error ? err.message : 'okänt fel'), ) } const paidByInvoice = new Map() for (const row of invoicePayments) { const id = row.invoice_id as string paidByInvoice.set(id, (paidByInvoice.get(id) ?? 0) + Number(row.amount ?? 0)) } const paidBySupplierInvoice = new Map() for (const row of supplierPayments) { const id = row.supplier_invoice_id as string paidBySupplierInvoice.set(id, (paidBySupplierInvoice.get(id) ?? 0) + Number(row.amount ?? 0)) } const receivables: CutoffReceivable[] = [] const unknownVatTreatment: string[] = [] const strayVatOnZeroRate: string[] = [] for (const row of invoices) { // Credit notes reduce the receivable through their own negative totals; // they are already part of the invoice set, so no special casing beyond // skipping non-invoice document types (offers, delivery notes). const documentType = row.document_type as string | null if (documentType && documentType !== 'invoice') continue const totalOwn = Number(row.total ?? 0) const total = resolveHeaderSek(row, 'total', 'total_sek') const vat = resolveHeaderSek(row, 'vat_amount', 'vat_amount_sek') const paid = paidByInvoice.get(row.id as string) ?? 0 const outstandingOwn = roundOre(totalOwn - paid) const outstanding = totalOwn === 0 ? 0 : roundOre(total * (outstandingOwn / totalOwn)) if (Math.abs(outstanding) < ORE_TOLERANCE) continue // Never guess the treatment. Defaulting a 12 %/6 %/undantagen invoice to // 25 % would route it to the wrong vilande account AND the wrong revenue // account; the moms impact is deferred but the year-end fordran // composition would be wrong on the balance sheet. Collect and refuse. const treatment = row.vat_treatment as VatTreatment | null const reference = (row.invoice_number as string) ?? '' if (!treatment) { unknownVatTreatment.push(reference || (row.id as string)) continue } // Scale the moms share to the part still outstanding: a half-paid invoice // carries half its moms into the cut-off. const ratio = totalOwn === 0 ? 0 : outstandingOwn / totalOwn const scaledVat = roundOre(vat * ratio) // Moms on a treatment that cannot carry Swedish output moms is a real // invoicing error. Surface it instead of quietly folding it into revenue: // the verifikat would balance and the mistake would disappear. if (!VILANDE_OUTPUT_VAT_ACCOUNTS[treatment] && Math.abs(scaledVat) >= ORE_TOLERANCE) { strayVatOnZeroRate.push(reference || (row.id as string)) continue } receivables.push({ id: row.id as string, reference, vatTreatment: treatment, outstanding, vat: scaledVat, }) } const payables: CutoffPayable[] = [] for (const row of supplierInvoices) { const sign = row.is_credit_note ? -1 : 1 const totalOwn = Math.abs(Number(row.total ?? 0)) * sign const total = Math.abs(resolveHeaderSek(row, 'total', 'total_sek')) * sign const vat = Math.abs(resolveHeaderSek(row, 'vat_amount', 'vat_amount_sek')) * sign const paid = paidBySupplierInvoice.get(row.id as string) ?? 0 const outstandingOwn = roundOre(totalOwn - (paid * sign)) const outstanding = totalOwn === 0 ? 0 : roundOre(total * (outstandingOwn / totalOwn)) if (Math.abs(outstanding) < ORE_TOLERANCE) continue const ratio = totalOwn === 0 ? 0 : outstandingOwn / totalOwn const items = (row.items ?? []) as Array> const supplierValue = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier const supplierType = (supplierValue as Record | null)?.supplier_type let reverseChargeGroups: CutoffPayable['reverseChargeGroups'] if (row.reverse_charge) { if (!['eu_business', 'non_eu_business', 'swedish_business'].includes(String(supplierType))) { throw new Error( `Leverantörsfaktura ${String(row.supplier_invoice_number ?? row.id)} med omvänd skattskyldighet saknar giltig leverantörstyp`, ) } const groups = new Map() for (const item of items) { const rate = resolveReverseChargeRate({ vat_rate: item.vat_rate == null ? null : Number(item.vat_rate), reverse_charge_rate: item.reverse_charge_rate == null ? null : Number(item.reverse_charge_rate), }) const itemBase = totalOwn === 0 ? 0 : roundOre(Math.abs(Number(item.line_total ?? 0)) * Math.abs(total / totalOwn) * ratio) const current = groups.get(rate) ?? { base: 0, nonBasisBase: 0 } current.base = roundOre(current.base + itemBase) if (!isReverseChargeBasisAccount(String(item.account_number ?? ''))) { current.nonBasisBase = roundOre(current.nonBasisBase + itemBase) } groups.set(rate, current) } reverseChargeGroups = [...groups.entries()].map(([rate, group]) => ({ rate, base: group.base * sign, nonBasisBase: group.nonBasisBase * sign, supplierType: supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business', })) } payables.push({ id: row.id as string, reference: (row.supplier_invoice_number as string) ?? '', outstanding, vat: roundOre(vat * ratio), reverseCharge: Boolean(row.reverse_charge), reverseChargeGroups, netByAccount: items .filter((item) => item.account_number) .map((item) => ({ account: item.account_number as string, amount: Math.abs(Number(item.line_total ?? 0)), })), }) } log.info('collected kontantmetoden cut-off', { companyId, periodStart, periodEnd, receivables: receivables.length, payables: payables.length, }) if (unknownVatTreatment.length > 0) { log.warn('invoices without vat_treatment excluded from the cut-off', { companyId, count: unknownVatTreatment.length, }) } if (strayVatOnZeroRate.length > 0) { log.warn('invoices with moms on a zero-rate treatment excluded from the cut-off', { companyId, count: strayVatOnZeroRate.length, }) } return { receivables, payables, unknownVatTreatment, strayVatOnZeroRate } } export async function assessKontantmetodCutoff( supabase: SupabaseClient, companyId: string, period: { id: string; period_start: string; period_end: string }, nextFiscalPeriodId: string, entityType: EntityType = 'aktiebolag', ): Promise { const collection = sortedCutoffCollection(await collectKontantmetodCutoff( supabase, companyId, period.period_start, period.period_end, )) const lines = buildCutoffLines(collection.receivables, collection.payables, entityType) const postings = await inspectKontantmetodCutoffPostings( supabase, companyId, period.id, nextFiscalPeriodId, period.period_end, lines, ) return { collection, lines, postings } } export interface PostCutoffResult { receivableEntry: JournalEntry | null receivableReversal: JournalEntry | null payableEntry: JournalEntry | null payableReversal: JournalEntry | null } export class KontantmetodCutoffPartialError extends Error { readonly postedIds: Record readonly cause: unknown constructor(message: string, postedIds: Record, cause: unknown) { super(message) this.name = 'KontantmetodCutoffPartialError' this.postedIds = postedIds this.cause = cause } } /** * Assert the vändning can actually be posted BEFORE any cut-off entry exists. * * The cut-off and its reversal are two verifikat, and the engine gives no * cross-entry transaction: if the reversal fails after the cut-off is * committed, 1510/2440 stay permanently inflated and every new-year payment * double-books. Checking the target period up front turns the common failure * (next period missing, closed, or locked) into a refusal that posts nothing, * which leaves the compensating storno below as a genuine last resort rather * than the expected path. */ async function assertReversalPeriodPostable( supabase: SupabaseClient, companyId: string, nextFiscalPeriodId: string, reversalDate: string, ): Promise { if (!nextFiscalPeriodId) { throw new Error( 'Kontantmetodens bokslutsavgränsning kräver att nästa räkenskapsår är upplagt: vändningen bokas första dagen på det nya året.', ) } const { data, error } = await supabase .from('fiscal_periods') .select('id, period_start, period_end, is_closed, locked_at') .eq('id', nextFiscalPeriodId) .eq('company_id', companyId) .maybeSingle() if (error || !data) { throw new Error( 'Kontantmetodens bokslutsavgränsning kräver att nästa räkenskapsår är upplagt: vändningen bokas första dagen på det nya året.', ) } if (data.is_closed || data.locked_at) { throw new Error( 'Nästa räkenskapsår är stängt eller låst: vändningen av bokslutsavgränsningen kan inte bokföras. Lås upp perioden och försök igen.', ) } if (reversalDate < (data.period_start as string) || reversalDate > (data.period_end as string)) { throw new Error( `Vändningsdatumet ${reversalDate} ligger utanför nästa räkenskapsår: kontrollera periodernas datum.`, ) } } /** * Post the cut-off verifikat and their vändningar. * * Refuses outright unless the vändning can be posted (see * assertReversalPeriodPostable) and unless every invoice has a known * vat_treatment: a cut-off without its vändning leaves 1510/2440 permanently * inflated and makes every new-year payment double-book. * * If a reversal still fails after its cut-off committed, the cut-off is * stornoed through the sanctioned reverseEntry() path (BFL 5 kap 5 §: posted * entries are never edited or deleted) so the ledger is left consistent, and * the original error is rethrown. */ export async function postKontantmetodCutoff( supabase: SupabaseClient, companyId: string, userId: string, opts: { fiscalPeriodId: string nextFiscalPeriodId: string periodEnd: string receivables: CutoffReceivable[] payables: CutoffPayable[] entityType?: EntityType /** Refuse if any invoice lacked a vat_treatment (see CutoffCollection). */ unknownVatTreatment?: string[] /** Refuse if any invoice carried moms on a zero-rate treatment. */ strayVatOnZeroRate?: string[] }, ): Promise { if (opts.unknownVatTreatment && opts.unknownVatTreatment.length > 0) { throw new Error( `${opts.unknownVatTreatment.length} fakturor saknar momsinställning och kan inte tas med i bokslutsavgränsningen: ` + `${opts.unknownVatTreatment.slice(0, 10).join(', ')}. Komplettera fakturorna och kör om.`, ) } if (opts.strayVatOnZeroRate && opts.strayVatOnZeroRate.length > 0) { throw new Error( `${opts.strayVatOnZeroRate.length} fakturor har moms trots en momsfri momsinställning (export, omvänd betalningsskyldighet eller undantagen) och kan inte tas med i bokslutsavgränsningen: ` + `${opts.strayVatOnZeroRate.slice(0, 10).join(', ')}. Rätta fakturorna och kör om.`, ) } const { receivableLines, payableLines } = buildCutoffLines( opts.receivables, opts.payables, opts.entityType, ) const reversalDate = nextDay(opts.periodEnd) const result: PostCutoffResult = { receivableEntry: null, receivableReversal: null, payableEntry: null, payableReversal: null, } if (receivableLines.length === 0 && payableLines.length === 0) return result await assertReversalPeriodPostable(supabase, companyId, opts.nextFiscalPeriodId, reversalDate) const existing = await inspectKontantmetodCutoffPostings( supabase, companyId, opts.fiscalPeriodId, opts.nextFiscalPeriodId, opts.periodEnd, { receivableLines, payableLines, receivableTotal: receivableLines.reduce((sum, line) => sum + line.debit_amount, 0), payableTotal: payableLines.reduce((sum, line) => sum + line.credit_amount, 0), }, ) if (hasIncompleteKontantmetodCutoffPair(existing, { receivableLines, payableLines, receivableTotal: receivableLines.reduce((sum, line) => sum + line.debit_amount, 0), payableTotal: payableLines.reduce((sum, line) => sum + line.credit_amount, 0), })) { throw new Error( 'Kontantmetodens bokslutsavgränsning är delvis eller dubbelt bokförd för perioden. Kontrollera och rätta verifikaten innan du försöker igen.', ) } if (existing.receivableEntryId && existing.receivableReversalId) { result.receivableEntry = { id: existing.receivableEntryId } as JournalEntry result.receivableReversal = { id: existing.receivableReversalId } as JournalEntry } if (existing.payableEntryId && existing.payableReversalId) { result.payableEntry = { id: existing.payableEntryId } as JournalEntry result.payableReversal = { id: existing.payableReversalId } as JournalEntry } /** * Post a cut-off/vändning pair. On reversal failure the cut-off is stornoed * so the pair is all-or-nothing from the ledger's point of view. */ const postPair = async ( lines: CreateJournalEntryLineInput[], label: string, references: string[], ): Promise<[JournalEntry, JournalEntry]> => { const description = label === 'Kundfordringar' ? KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable : KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable const reversalDescription = label === 'Kundfordringar' ? KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal : KONTANTMETOD_CUTOFF_DESCRIPTIONS.payableReversal const entry = await createJournalEntry(supabase, companyId, userId, { fiscal_period_id: opts.fiscalPeriodId, entry_date: opts.periodEnd, description, source_type: 'year_end', source_id: opts.fiscalPeriodId, notes: buildCutoffNote(label, references), lines, }) try { const reversal = await createJournalEntry(supabase, companyId, userId, { fiscal_period_id: opts.nextFiscalPeriodId, entry_date: reversalDate, description: reversalDescription, source_type: 'year_end', source_id: opts.fiscalPeriodId, notes: buildCutoffNote(`Vändning ${label.toLowerCase()}`, references), lines: reverseLines(lines), }) return [entry, reversal] } catch (reversalError) { // Compensate: an un-reversed cut-off is worse than no cut-off at all. let stornoId: string | null = null try { // Storno in the same period as the cut-off so the pair nets to zero // inside the year being closed. const storno = await reverseEntry(supabase, companyId, userId, entry.id, opts.periodEnd) stornoId = storno.id } catch (stornoError) { log.error( 'cut-off reversal failed AND the compensating storno failed: 1510/2440 left inflated, manual correction required', stornoError as Error, { companyId, entryId: entry.id }, ) } const key = label === 'Kundfordringar' ? 'receivable' : 'payable' throw new KontantmetodCutoffPartialError( `Vändningen för ${label.toLowerCase()} kunde inte bokföras`, { [`${key}_entry_id`]: entry.id, ...(stornoId ? { [`${key}_storno_entry_id`]: stornoId } : {}), }, reversalError, ) } } if (receivableLines.length > 0 && !result.receivableEntry) { const [entry, reversal] = await postPair( receivableLines, 'Kundfordringar', opts.receivables.map((r) => r.reference), ) result.receivableEntry = entry result.receivableReversal = reversal } if (payableLines.length > 0 && !result.payableEntry) { try { const [entry, reversal] = await postPair( payableLines, 'Leverantörsskulder', opts.payables.map((p) => p.reference), ) result.payableEntry = entry result.payableReversal = reversal } catch (err) { const completedIds = { ...(result.receivableEntry ? { receivable_entry_id: result.receivableEntry.id } : {}), ...(result.receivableReversal ? { receivable_reversal_entry_id: result.receivableReversal.id } : {}), } if (Object.keys(completedIds).length === 0) throw err if (err instanceof KontantmetodCutoffPartialError) { throw new KontantmetodCutoffPartialError( err.message, { ...completedIds, ...err.postedIds }, err.cause, ) } throw new KontantmetodCutoffPartialError( 'Leverantörsskuldernas bokslutsavgränsning kunde inte slutföras', completedIds, err, ) } } return result }