Files
accounted/lib/bookkeeping/invoice-entries.ts
T
Jakob WennbergandClaude Opus 4.6 ef5a84a5d5 feat: make extension system packageable via enriched ExtensionContext
Enrich ExtensionContext with supabase, emit(), settings, storage, log,
and services so extensions can receive everything through dependency
injection instead of importing core modules directly.

- Add context factory and inject context into event handlers via registry
- Move supplier invoice journal entry creation to core event handler
- Add services.ingestTransactions to ExtensionContext for enable-banking
- Create catch-all API route for extension-declared apiRoutes
- Migrate 5 extensions to accept context with dynamic import fallbacks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:24:21 +01:00

398 lines
12 KiB
TypeScript

import { createJournalEntry, findFiscalPeriod } from './engine'
import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
EntityType,
Invoice,
InvoiceItem,
JournalEntry,
VatTreatment,
} from '@/types'
/**
* Group invoice items by VAT rate and generate per-rate revenue + VAT lines.
* Returns credit lines only (revenue + VAT). The caller adds the debit side.
*/
function generatePerRateLines(
items: InvoiceItem[],
invoiceVatTreatment: VatTreatment,
entityType: EntityType,
invoiceNumber: string
): CreateJournalEntryLineInput[] {
const lines: CreateJournalEntryLineInput[] = []
// Check if items have per-line vat_rate set (new invoices)
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
if (!hasPerLineVat) {
// Legacy fallback: single rate from invoice level
const revenueAccount = getRevenueAccount(invoiceVatTreatment, entityType)
const subtotal = items.reduce((sum, item) => sum + item.line_total, 0)
lines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: subtotal,
line_description: `Försäljning faktura ${invoiceNumber}`,
})
const totalVat = items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
if (totalVat > 0) {
const vatLines = generateSalesVatLines({
vatTreatment: invoiceVatTreatment,
baseAmount: subtotal,
direction: 'sales',
})
lines.push(...vatLines)
}
return lines
}
// Group items by vat_rate
const rateGroups = new Map<number, { subtotal: number; vatAmount: number }>()
for (const item of items) {
const rate = item.vat_rate ?? 25
const group = rateGroups.get(rate) || { subtotal: 0, vatAmount: 0 }
group.subtotal += item.line_total
group.vatAmount += item.vat_amount || 0
rateGroups.set(rate, group)
}
// Generate revenue + VAT lines per rate group
for (const [rate, group] of rateGroups) {
const treatment = rate === 0 && (invoiceVatTreatment === 'reverse_charge' || invoiceVatTreatment === 'export')
? invoiceVatTreatment
: getVatTreatmentForRate(rate)
const revenueAccount = getRevenueAccount(treatment, entityType)
const roundedSubtotal = Math.round(group.subtotal * 100) / 100
lines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: roundedSubtotal,
line_description: `Försäljning faktura ${invoiceNumber}`,
})
const roundedVat = Math.round(group.vatAmount * 100) / 100
if (roundedVat !== 0) {
const vatAccount = getOutputVatAccount(treatment)
lines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: roundedVat,
line_description: `Utgående moms ${rate}%`,
})
}
}
return lines
}
/**
* Create journal entry when an invoice is created (status != draft)
*
* Supports mixed VAT rates per line item. Groups items by vat_rate
* and creates separate revenue + VAT lines per rate.
*
* Standard domestic invoice (25% VAT):
* Debit 1510 Kundfordringar [total incl VAT]
* Credit 30xx Försäljning [subtotal per rate]
* Credit 26xx Utgående moms [vat per rate]
*
* EU reverse charge:
* Debit 1510 Kundfordringar [subtotal]
* Credit 3308 Försäljning tjänst EU [subtotal]
*
* Export (non-EU):
* Debit 1510 Kundfordringar [subtotal]
* Credit 3305 Försäljning tjänst Export [subtotal]
*/
export async function createInvoiceJournalEntry(
userId: string,
invoice: Invoice,
entityType: EntityType = 'enskild_firma'
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, invoice.invoice_date)
if (!fiscalPeriodId) {
console.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
return null
}
const lines: CreateJournalEntryLineInput[] = []
// Debit: Kundfordringar (total including VAT)
lines.push({
account_number: '1510',
debit_amount: invoice.total,
credit_amount: 0,
line_description: `Faktura ${invoice.invoice_number}`,
currency: invoice.currency,
amount_in_currency: invoice.currency !== 'SEK' ? invoice.total : undefined,
exchange_rate: invoice.exchange_rate || undefined,
})
// Credit lines: revenue + VAT per rate group
if (invoice.items && invoice.items.length > 0) {
lines.push(...generatePerRateLines(invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number))
} else {
// Fallback: no items available, use invoice-level amounts
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
lines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: invoice.subtotal,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
})
if (invoice.vat_amount > 0) {
const vatLines = generateSalesVatLines({
vatTreatment: invoice.vat_treatment,
baseAmount: invoice.subtotal,
direction: 'sales',
})
lines.push(...vatLines)
}
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: `Faktura ${invoice.invoice_number}`,
source_type: 'invoice_created',
source_id: invoice.id,
lines,
}
return createJournalEntry(userId, input)
}
/**
* Create journal entry when an invoice is marked as paid
*
* Debit 1930 Företagskonto [total]
* Credit 1510 Kundfordringar [total]
*/
export async function createInvoicePaymentJournalEntry(
userId: string,
invoice: Invoice,
paymentDate: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
if (!fiscalPeriodId) {
console.warn('No open fiscal period found for payment date:', paymentDate)
return null
}
const lines: CreateJournalEntryLineInput[] = [
{
account_number: '1930', // Företagskonto
debit_amount: invoice.total,
credit_amount: 0,
line_description: `Betalning faktura ${invoice.invoice_number}`,
},
{
account_number: '1510', // Kundfordringar
debit_amount: 0,
credit_amount: invoice.total,
line_description: `Betalning faktura ${invoice.invoice_number}`,
},
]
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: `Betalning faktura ${invoice.invoice_number}`,
source_type: 'invoice_paid',
source_id: invoice.id,
lines,
}
return createJournalEntry(userId, input)
}
/**
* Create journal entry for a credit note (reversed version of original invoice entry)
* Supports per-item VAT rates with reversed debit/credit sides.
*
* Debit 30xx Försäljning [subtotal per rate]
* Debit 26xx Utgående moms [vat per rate]
* Credit 1510 Kundfordringar [total]
*/
export async function createCreditNoteJournalEntry(
userId: string,
creditNote: Invoice,
entityType: EntityType = 'enskild_firma'
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, creditNote.invoice_date)
if (!fiscalPeriodId) {
console.warn('No open fiscal period found for credit note date:', creditNote.invoice_date)
return null
}
const absTotal = Math.abs(creditNote.total)
const lines: CreateJournalEntryLineInput[] = []
// Generate reversed revenue + VAT lines per rate group
if (creditNote.items && creditNote.items.length > 0) {
const creditLines = generatePerRateLines(creditNote.items, creditNote.vat_treatment, entityType, creditNote.invoice_number)
// Swap debit/credit for credit note reversal (make amounts absolute first)
for (const line of creditLines) {
lines.push({
...line,
debit_amount: Math.abs(line.credit_amount),
credit_amount: Math.abs(line.debit_amount),
line_description: `Kreditfaktura ${creditNote.invoice_number}`,
})
}
} else {
// Fallback: invoice-level amounts
const revenueAccount = getRevenueAccount(creditNote.vat_treatment, entityType)
const absSubtotal = Math.abs(creditNote.subtotal)
const absVat = Math.abs(creditNote.vat_amount)
lines.push({
account_number: revenueAccount,
debit_amount: absSubtotal,
credit_amount: 0,
line_description: `Kreditfaktura ${creditNote.invoice_number}`,
})
if (absVat > 0) {
const vatAccount = getOutputVatAccount(creditNote.vat_treatment)
lines.push({
account_number: vatAccount,
debit_amount: absVat,
credit_amount: 0,
line_description: `Moms kreditfaktura ${creditNote.invoice_number}`,
})
}
}
// Credit: Kundfordringar (reverse the debit)
lines.push({
account_number: '1510',
debit_amount: 0,
credit_amount: absTotal,
line_description: `Kreditfaktura ${creditNote.invoice_number}`,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: creditNote.invoice_date,
description: `Kreditfaktura ${creditNote.invoice_number}`,
source_type: 'credit_note',
source_id: creditNote.id,
lines,
}
return createJournalEntry(userId, input)
}
/**
* Create journal entry for kontantmetoden (cash method) when payment is received.
* Supports per-item VAT rates. Revenue + VAT recognised at payment.
*
* Debit 1930 Företagskonto [total]
* Credit 30xx Försäljning [subtotal per rate]
* Credit 26xx Utgående moms [vat per rate] (if applicable)
*/
export async function createInvoiceCashEntry(
userId: string,
invoice: Invoice,
paymentDate: string,
entityType: EntityType = 'enskild_firma'
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
if (!fiscalPeriodId) {
console.warn('No open fiscal period found for payment date:', paymentDate)
return null
}
const lines: CreateJournalEntryLineInput[] = []
// Debit: Företagskonto (total received)
lines.push({
account_number: '1930',
debit_amount: invoice.total,
credit_amount: 0,
line_description: `Betalning faktura ${invoice.invoice_number}`,
})
// Credit lines: revenue + VAT per rate group
if (invoice.items && invoice.items.length > 0) {
lines.push(...generatePerRateLines(invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number))
} else {
// Fallback: invoice-level amounts
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
lines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: invoice.subtotal,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
})
if (invoice.vat_amount > 0) {
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
lines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: invoice.vat_amount,
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
})
}
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: `Betalning faktura ${invoice.invoice_number} (kontantmetoden)`,
source_type: 'invoice_cash_payment',
source_id: invoice.id,
lines,
}
return createJournalEntry(userId, input)
}
/**
* Get the appropriate revenue account based on VAT treatment
*
* For 'exempt': AB uses 3004 (Försäljning inom Sverige, momsfri),
* EF uses 3100 (Momsfria intäkter, mapped to R2 in NE engine).
*/
export function getRevenueAccount(vatTreatment: VatTreatment, entityType: EntityType = 'enskild_firma'): string {
switch (vatTreatment) {
case 'standard_25':
return '3001' // Försäljning 25%
case 'reduced_12':
return '3002' // Försäljning 12%
case 'reduced_6':
return '3003' // Försäljning 6%
case 'reverse_charge':
return '3308' // Försäljning tjänst EU
case 'export':
return '3305' // Försäljning tjänst Export
case 'exempt':
return entityType === 'aktiebolag' ? '3004' : '3100'
default:
return '3001'
}
}
/**
* Get the output VAT account based on VAT treatment
*/
export function getOutputVatAccount(vatTreatment: VatTreatment): string {
switch (vatTreatment) {
case 'standard_25':
return '2611'
case 'reduced_12':
return '2621'
case 'reduced_6':
return '2631'
default:
return '2611'
}
}