Files
accounted/lib/bookkeeping/supplier-invoice-entries.ts
T
Jakob WennbergandClaude Opus 4.6 a9bf24ce0b feat: enhance supplier invoices, entity-aware categorization, and new tests
Expand supplier invoice module with overdue cron job, credit note journal
entries, and event emissions on approve/mark-paid/create flows. Add entity
type (EF/AB) awareness to transaction categorization UI and category
mapping logic. Add comprehensive tests for supplier-invoice-entries,
transaction-entries, and expanded API route coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:08:26 +01:00

436 lines
15 KiB
TypeScript

import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { generateReverseChargeLines } from './vat-entries'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
SupplierInvoice,
SupplierInvoiceItem,
} from '@/types'
const log = createLogger('supplier-invoice-entries')
/**
* Create journal entry when a supplier invoice is registered (accrual method)
*
* Swedish domestic (25% VAT):
* Debit 5xxx/6xxx (per item's account_number) [item line_total]
* Debit 2641 Ingående moms (per rate) [VAT per rate group]
* Credit 2440 Leverantörsskulder [total incl VAT]
*
* EU/non-EU reverse charge (services):
* Debit 5xxx/6xxx (per item) [total]
* Debit 2645 Beräknad ingående moms (per rate) [fiktiv VAT per rate]
* Credit 26x4 Utgående moms omvänd (per rate) [fiktiv VAT per rate]
* Credit 2440 Leverantörsskulder [total]
*
* Note: Goods imports via Tullverket (customs) use a different accounting path
* (2615/2645) and are not handled here — only services use reverse charge.
*/
export async function createSupplierInvoiceRegistrationEntry(
supabase: SupabaseClient,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
supplierType: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, invoice.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
return null
}
const lines: CreateJournalEntryLineInput[] = []
const desc = `Lev.faktura ${invoice.supplier_invoice_number} (ankomst ${invoice.arrival_number})`
const isForeign = invoice.currency !== 'SEK'
// Aggregate expense amounts by account number and convert to SEK
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
expenseByAccount.set(item.account_number, current + itemSek)
}
// Debit: Expense accounts (in SEK)
const debitLines: CreateJournalEntryLineInput[] = []
for (const [accountNumber, amount] of expenseByAccount) {
debitLines.push({
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
}
lines.push(...debitLines)
const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business') && invoice.reverse_charge
if (isReverseCharge) {
// EU/non-EU reverse charge: fiktiv moms entries per rate group
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
for (const [rate, amount] of vatByRate) {
if (rate > 0 && amount > 0) {
const rcLines = generateReverseChargeLines(amount / rate, rate)
lines.push(...rcLines)
}
}
} else if (invoice.vat_amount > 0) {
// Domestic: Debit ingående moms per rate group
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
lines.push({
account_number: '2641',
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
})
}
}
}
// Credit: Leverantörsskulder — balance guarantee: ensures sum(debits) === sum(credits)
// For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: '2440',
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: desc,
source_type: 'supplier_invoice_registered',
source_id: invoice.id,
lines,
}
return createJournalEntry(supabase, userId, input)
}
/**
* Create journal entry when a supplier invoice is paid (accrual method)
*
* Debit 2440 Leverantörsskulder [payment amount]
* Credit 1930 Företagskonto [payment amount]
*
* With exchange rate difference:
* Debit 2440 Leverantörsskulder [original SEK amount]
* Credit 1930 Företagskonto [actual SEK paid]
* Credit/Debit 3960/7960 [difference]
*/
export async function createSupplierInvoicePaymentEntry(
supabase: SupabaseClient,
userId: string,
invoice: SupplierInvoice,
paymentAmount: number,
paymentDate: string,
exchangeRateDifference?: number
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
return null
}
const desc = `Betalning lev.faktura ${invoice.supplier_invoice_number} (ankomst ${invoice.arrival_number})`
const lines: CreateJournalEntryLineInput[] = []
if (exchangeRateDifference && exchangeRateDifference !== 0) {
// Foreign currency with exchange rate difference
const originalSekAmount = paymentAmount
const actualSekPaid = paymentAmount - exchangeRateDifference
// Debit: Clear leverantörsskulder at original booked SEK amount
lines.push({
account_number: '2440',
debit_amount: Math.round(originalSekAmount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
// Credit: Bank at actual SEK paid
lines.push({
account_number: '1930',
debit_amount: 0,
credit_amount: Math.round(actualSekPaid * 100) / 100,
line_description: desc,
})
// Exchange rate difference
if (exchangeRateDifference > 0) {
// Gain: Credit 3960
lines.push({
account_number: '3960',
debit_amount: 0,
credit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
line_description: 'Valutakursvinst',
})
} else {
// Loss: Debit 7960
lines.push({
account_number: '7960',
debit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
credit_amount: 0,
line_description: 'Valutakursförlust',
})
}
} else {
// Standard SEK payment
lines.push({
account_number: '2440',
debit_amount: Math.round(paymentAmount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
lines.push({
account_number: '1930',
debit_amount: 0,
credit_amount: Math.round(paymentAmount * 100) / 100,
line_description: desc,
})
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: desc,
source_type: 'supplier_invoice_paid',
source_id: invoice.id,
lines,
}
return createJournalEntry(supabase, userId, input)
}
/**
* Create journal entry for cash method (kontantmetoden)
* Combined entry at payment time:
*
* Debit 5xxx/6xxx (per item) [line_total]
* Debit 2641 Ingående moms [total VAT]
* Credit 1930 Företagskonto [total incl VAT]
*/
export async function createSupplierInvoiceCashEntry(
supabase: SupabaseClient,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
paymentDate: string,
supplierType: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
return null
}
const desc = `Betalning lev.faktura ${invoice.supplier_invoice_number} (kontantmetoden)`
const lines: CreateJournalEntryLineInput[] = []
// Aggregate expense amounts by account number and convert to SEK
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
expenseByAccount.set(item.account_number, current + itemSek)
}
// Debit: Expense accounts (in SEK)
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
}
const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business') && invoice.reverse_charge
if (isReverseCharge) {
// EU/non-EU reverse charge: fiktiv moms entries per rate group
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
for (const [rate, amount] of vatByRate) {
if (rate > 0 && amount > 0) {
const rcLines = generateReverseChargeLines(amount / rate, rate)
lines.push(...rcLines)
}
}
} else if (invoice.vat_amount > 0) {
// Domestic: Debit ingående moms per rate group
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
lines.push({
account_number: '2641',
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
})
}
}
}
// Credit: Företagskonto — balance guarantee: ensures sum(debits) === sum(credits)
// For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: '1930',
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: desc,
source_type: 'supplier_invoice_cash_payment',
source_id: invoice.id,
lines,
}
return createJournalEntry(supabase, userId, input)
}
/**
* Create journal entry for a supplier credit note (reversal of registration)
*
* Debit 2440 Leverantörsskulder [total]
* Credit 5xxx/6xxx (per item) [line_total]
* Credit 2641 Ingående moms [total VAT]
*/
export async function createSupplierCreditNoteEntry(
supabase: SupabaseClient,
userId: string,
creditNote: SupplierInvoice,
items: SupplierInvoiceItem[],
supplierType: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, creditNote.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for credit note date:', creditNote.invoice_date)
return null
}
const desc = `Kreditfaktura lev. ${creditNote.supplier_invoice_number} (ankomst ${creditNote.arrival_number})`
const lines: CreateJournalEntryLineInput[] = []
// Credit: Expense accounts (reverse, in SEK)
const creditLines: CreateJournalEntryLineInput[] = []
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const itemSek = Math.abs(resolveSekAmount(item.line_total, null, creditNote.currency, creditNote.exchange_rate))
expenseByAccount.set(item.account_number, current + itemSek)
}
for (const [accountNumber, amount] of expenseByAccount) {
creditLines.push({
account_number: accountNumber,
debit_amount: 0,
credit_amount: Math.round(amount * 100) / 100,
line_description: desc,
})
}
const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business') && creditNote.reverse_charge
if (isReverseCharge) {
// Reverse the fiktiv moms per rate group (swap debit/credit from registration)
const vatByRate = groupVatByRate(items, creditNote.currency, creditNote.exchange_rate, true)
for (const [rate, amount] of vatByRate) {
if (rate > 0 && amount > 0) {
// Determine the output account for this rate
let outputAccount: string
switch (rate) {
case 0.12: outputAccount = '2624'; break
case 0.06: outputAccount = '2634'; break
default: outputAccount = '2614'; break
}
creditLines.push({
account_number: '2645',
debit_amount: 0,
credit_amount: amount,
line_description: `Omvänd fiktiv ingående moms ${Math.round(rate * 100)}% ${desc}`,
})
lines.push({
account_number: outputAccount,
debit_amount: amount,
credit_amount: 0,
line_description: `Omvänd fiktiv utgående moms ${Math.round(rate * 100)}% ${desc}`,
})
}
}
} else {
// Domestic: Credit ingående moms per rate group (reverse)
const vatByRate = groupVatByRate(items, creditNote.currency, creditNote.exchange_rate, true)
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
creditLines.push({
account_number: '2641',
debit_amount: 0,
credit_amount: amount,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
})
}
}
}
lines.push(...creditLines)
// Debit: Leverantörsskulder — balance guarantee: debit = sum of credits minus other debits
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
lines.unshift({
account_number: '2440',
debit_amount: Math.round((totalCredits - totalDebits) * 100) / 100,
credit_amount: 0,
line_description: desc,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: creditNote.invoice_date,
description: desc,
source_type: 'supplier_credit_note',
source_id: creditNote.id,
lines,
}
return createJournalEntry(supabase, userId, input)
}
/**
* Group items by VAT rate and sum the VAT amount per rate.
* Returns a Map<rate, totalVatAmount> for generating per-rate journal lines.
*/
function groupVatByRate(
items: SupplierInvoiceItem[],
currency: string,
exchangeRate: number | null,
useAbsoluteValues = false
): Map<number, number> {
const vatByRate = new Map<number, number>()
for (const item of items) {
const rate = item.vat_rate ?? 0.25
let itemSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
if (useAbsoluteValues) itemSek = Math.abs(itemSek)
const itemVat = Math.round(itemSek * rate * 100) / 100
vatByRate.set(rate, (vatByRate.get(rate) || 0) + itemVat)
}
return vatByRate
}