import { createJournalEntry, findFiscalPeriod } from './engine' import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils' import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries' import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules' import { createLogger } from '@/lib/logger' import type { SupabaseClient } from '@supabase/supabase-js' import type { CreateJournalEntryInput, CreateJournalEntryLineInput, EntityType, Invoice, InvoiceItem, JournalEntry, VatTreatment, } from '@/types' const log = createLogger('invoice-entries') /** * 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, currency?: string | null, exchangeRate?: number | null ): CreateJournalEntryLineInput[] { const lines: CreateJournalEntryLineInput[] = [] const isForeign = currency != null && currency !== 'SEK' // Helper: convert item amount to SEK when dealing with foreign currency const toSek = (amount: number): number => { if (!isForeign) return amount if (exchangeRate != null && exchangeRate > 0) { return Math.round(amount * exchangeRate * 100) / 100 } return amount // fallback for legacy data } // 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) const subtotalSek = toSek(subtotal) lines.push({ account_number: revenueAccount, debit_amount: 0, credit_amount: subtotalSek, line_description: `Försäljning faktura ${invoiceNumber}`, }) const totalVat = items.reduce((sum, item) => sum + (item.vat_amount || 0), 0) if (totalVat > 0) { if (isForeign) { // For foreign currency, compute VAT in SEK directly const vatSek = toSek(totalVat) const vatAccount = getOutputVatAccount(invoiceVatTreatment) lines.push({ account_number: vatAccount, debit_amount: 0, credit_amount: vatSek, line_description: `Utgående moms`, }) } else { const vatLines = generateSalesVatLines({ vatTreatment: invoiceVatTreatment, baseAmount: subtotal, direction: 'sales', }) lines.push(...vatLines) } } return lines } // Group items by vat_rate const rateGroups = new Map() for (const item of items) { const rate = item.vat_rate ?? 0 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(toSek(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(toSek(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( supabase: SupabaseClient, userId: string, invoice: Invoice, entityType: EntityType = 'enskild_firma' ): Promise { 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 isForeign = invoice.currency !== 'SEK' // Credit lines: revenue + VAT per rate group (compute first to guarantee balance) const creditLines: CreateJournalEntryLineInput[] = [] if (invoice.items && invoice.items.length > 0) { creditLines.push(...generatePerRateLines( invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number, invoice.currency, invoice.exchange_rate )) } else { // Fallback: no items available, use invoice-level amounts const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType) const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate) creditLines.push({ account_number: revenueAccount, debit_amount: 0, credit_amount: subtotalSek, line_description: `Försäljning faktura ${invoice.invoice_number}`, }) if (invoice.vat_amount > 0) { if (isForeign) { const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate) const vatAccount = getOutputVatAccount(invoice.vat_treatment) creditLines.push({ account_number: vatAccount, debit_amount: 0, credit_amount: vatSek, line_description: `Utgående moms faktura ${invoice.invoice_number}`, }) } else { const vatLines = generateSalesVatLines({ vatTreatment: invoice.vat_treatment, baseAmount: invoice.subtotal, direction: 'sales', }) creditLines.push(...vatLines) } } } // Debit: Kundfordringar — balance guarantee: debit = sum of all credit lines const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount, 0) const debitAmount = isForeign ? Math.round(totalCredits * 100) / 100 : resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate) lines.push({ account_number: '1510', debit_amount: debitAmount, credit_amount: 0, line_description: `Faktura ${invoice.invoice_number}`, ...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate), }) lines.push(...creditLines) 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(supabase, 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( supabase: SupabaseClient, userId: string, invoice: Invoice, paymentDate: string, exchangeRateDifference?: number ): Promise { 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 faktura ${invoice.invoice_number}` const bookedSekAmount = resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate) const lines: CreateJournalEntryLineInput[] = [] if (exchangeRateDifference && exchangeRateDifference !== 0) { // Foreign currency with exchange rate difference // For receivables: positive diff = gain (received more), negative = loss (received less) const actualSekReceived = bookedSekAmount + exchangeRateDifference // Debit: Bank at actual SEK received lines.push({ account_number: '1930', debit_amount: Math.round(actualSekReceived * 100) / 100, credit_amount: 0, line_description: desc, }) // Credit: Clear kundfordringar at original booked SEK amount lines.push({ account_number: '1510', debit_amount: 0, credit_amount: Math.round(bookedSekAmount * 100) / 100, line_description: desc, }) // Exchange rate difference if (exchangeRateDifference > 0) { // Gain: Credit 3960 (received more than booked) lines.push({ account_number: '3960', debit_amount: 0, credit_amount: Math.round(exchangeRateDifference * 100) / 100, line_description: 'Valutakursvinst', }) } else { // Loss: Debit 7960 (received less than booked) 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 or no exchange rate difference lines.push( { account_number: '1930', debit_amount: Math.round(bookedSekAmount * 100) / 100, credit_amount: 0, line_description: desc, }, { account_number: '1510', debit_amount: 0, credit_amount: Math.round(bookedSekAmount * 100) / 100, line_description: desc, } ) } const input: CreateJournalEntryInput = { fiscal_period_id: fiscalPeriodId, entry_date: paymentDate, description: desc, source_type: 'invoice_paid', source_id: invoice.id, lines, } return createJournalEntry(supabase, 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( supabase: SupabaseClient, userId: string, creditNote: Invoice, entityType: EntityType = 'enskild_firma' ): Promise { 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 lines: CreateJournalEntryLineInput[] = [] // Generate reversed revenue + VAT lines per rate group (debit side for credit notes) const debitLines: CreateJournalEntryLineInput[] = [] if (creditNote.items && creditNote.items.length > 0) { // Use absolute items for generatePerRateLines, then swap debit/credit const creditLines = generatePerRateLines( creditNote.items, creditNote.vat_treatment, entityType, creditNote.invoice_number, creditNote.currency, creditNote.exchange_rate ) for (const line of creditLines) { debitLines.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(resolveSekAmount(creditNote.subtotal, creditNote.subtotal_sek, creditNote.currency, creditNote.exchange_rate)) const absVat = Math.abs(resolveSekAmount(creditNote.vat_amount, creditNote.vat_amount_sek, creditNote.currency, creditNote.exchange_rate)) debitLines.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) debitLines.push({ account_number: vatAccount, debit_amount: absVat, credit_amount: 0, line_description: `Moms kreditfaktura ${creditNote.invoice_number}`, }) } } lines.push(...debitLines) // Credit: Kundfordringar — balance guarantee: credit = sum of all debit lines const totalDebits = debitLines.reduce((sum, l) => sum + l.debit_amount, 0) lines.push({ account_number: '1510', debit_amount: 0, credit_amount: Math.round(totalDebits * 100) / 100, 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(supabase, 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( supabase: SupabaseClient, userId: string, invoice: Invoice, paymentDate: string, entityType: EntityType = 'enskild_firma' ): Promise { const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate) if (!fiscalPeriodId) { log.warn('No open fiscal period found for payment date:', paymentDate) return null } const lines: CreateJournalEntryLineInput[] = [] const isForeign = invoice.currency !== 'SEK' // Credit lines: revenue + VAT per rate group (compute first to guarantee balance) const creditLines: CreateJournalEntryLineInput[] = [] if (invoice.items && invoice.items.length > 0) { creditLines.push(...generatePerRateLines( invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number, invoice.currency, invoice.exchange_rate )) } else { // Fallback: invoice-level amounts const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType) const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate) creditLines.push({ account_number: revenueAccount, debit_amount: 0, credit_amount: subtotalSek, line_description: `Försäljning faktura ${invoice.invoice_number}`, }) if (invoice.vat_amount > 0) { const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate) const vatAccount = getOutputVatAccount(invoice.vat_treatment) creditLines.push({ account_number: vatAccount, debit_amount: 0, credit_amount: vatSek, line_description: `Utgående moms faktura ${invoice.invoice_number}`, }) } } // Debit: Företagskonto — balance guarantee: debit = sum of credit lines const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount, 0) lines.push({ account_number: '1930', debit_amount: isForeign ? Math.round(totalCredits * 100) / 100 : resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate), credit_amount: 0, line_description: `Betalning faktura ${invoice.invoice_number}`, }) lines.push(...creditLines) 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(supabase, 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' } }