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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f6a9889f0a
commit
a9bf24ce0b
@@ -7,7 +7,7 @@ import {
|
||||
buildMappingResultFromCategory,
|
||||
} from '../category-mapping'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
import type { TransactionCategory } from '@/types'
|
||||
import type { TransactionCategory, VatTreatment } from '@/types'
|
||||
|
||||
describe('getCategoryAccountMapping', () => {
|
||||
describe('income_products uses correct account', () => {
|
||||
@@ -182,6 +182,7 @@ describe('getDefaultVatTreatmentForCategory', () => {
|
||||
expect(getDefaultVatTreatmentForCategory('expense_bank_fees')).toBeNull()
|
||||
expect(getDefaultVatTreatmentForCategory('expense_card_fees')).toBeNull()
|
||||
expect(getDefaultVatTreatmentForCategory('expense_currency_exchange')).toBeNull()
|
||||
expect(getDefaultVatTreatmentForCategory('expense_representation')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for private transactions', () => {
|
||||
@@ -192,3 +193,80 @@ describe('getDefaultVatTreatmentForCategory', () => {
|
||||
expect(getDefaultVatTreatmentForCategory('uncategorized')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('representation VAT (ML 8:9 — illegal since 2017)', () => {
|
||||
it('getDefaultVatTreatmentForCategory returns null for representation', () => {
|
||||
expect(getDefaultVatTreatmentForCategory('expense_representation')).toBeNull()
|
||||
})
|
||||
|
||||
it('getCategoryAccountMapping has vatTreatment: null for representation', () => {
|
||||
const result = getCategoryAccountMapping('expense_representation', -500, true)
|
||||
expect(result.vatTreatment).toBeNull()
|
||||
expect(result.vatDebitAccount).toBeNull()
|
||||
})
|
||||
|
||||
it('buildMappingResultFromCategory generates no VAT lines for representation', () => {
|
||||
const tx = makeTransaction({ amount: -500 })
|
||||
const result = buildMappingResultFromCategory('expense_representation', tx, true)
|
||||
expect(result.vat_lines).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('income account resolves by VAT treatment', () => {
|
||||
const cases: [VatTreatment, string][] = [
|
||||
['standard_25', '3001'],
|
||||
['reduced_12', '3002'],
|
||||
['reduced_6', '3003'],
|
||||
['export', '3305'],
|
||||
['reverse_charge', '3308'],
|
||||
['exempt', '3004'],
|
||||
]
|
||||
|
||||
it.each(cases)('income_services with %s maps to %s', (vat, expectedAccount) => {
|
||||
const result = getCategoryAccountMapping('income_services', 1000, true, 'enskild_firma', vat)
|
||||
expect(result.creditAccount).toBe(expectedAccount)
|
||||
})
|
||||
|
||||
it.each(cases)('income_products with %s maps to %s', (vat, expectedAccount) => {
|
||||
const result = getCategoryAccountMapping('income_products', 1000, true, 'enskild_firma', vat)
|
||||
expect(result.creditAccount).toBe(expectedAccount)
|
||||
})
|
||||
|
||||
it('income_other always returns 3900 regardless of VAT treatment', () => {
|
||||
for (const vat of ['standard_25', 'reduced_12', 'reduced_6', 'export', 'reverse_charge', 'exempt'] as VatTreatment[]) {
|
||||
const result = getCategoryAccountMapping('income_other', 1000, true, 'enskild_firma', vat)
|
||||
expect(result.creditAccount).toBe('3900')
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults to 3001 when no vatTreatment provided', () => {
|
||||
const result = getCategoryAccountMapping('income_services', 1000, true)
|
||||
expect(result.creditAccount).toBe('3001')
|
||||
})
|
||||
})
|
||||
|
||||
describe('private transaction accounts by entity type and direction', () => {
|
||||
it('EF withdrawal (amount < 0) uses 2013', () => {
|
||||
const result = getCategoryAccountMapping('private', -500, false, 'enskild_firma')
|
||||
expect(result.debitAccount).toBe('2013')
|
||||
expect(result.creditAccount).toBe('1930')
|
||||
})
|
||||
|
||||
it('EF deposit (amount > 0) uses 2018', () => {
|
||||
const result = getCategoryAccountMapping('private', 500, false, 'enskild_firma')
|
||||
expect(result.debitAccount).toBe('1930')
|
||||
expect(result.creditAccount).toBe('2018')
|
||||
})
|
||||
|
||||
it('AB uses 2893 for both withdrawal and deposit', () => {
|
||||
const withdrawal = getCategoryAccountMapping('private', -500, false, 'aktiebolag')
|
||||
expect(withdrawal.debitAccount).toBe('2893')
|
||||
|
||||
const deposit = getCategoryAccountMapping('private', 500, false, 'aktiebolag')
|
||||
expect(deposit.creditAccount).toBe('2893')
|
||||
})
|
||||
|
||||
it('getDefaultAccountForCategory still returns 2013 for EF (default/withdrawal account)', () => {
|
||||
expect(getDefaultAccountForCategory('private', 'enskild_firma')).toBe('2013')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,907 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupplierInvoiceItem, CreateJournalEntryLineInput, CreateJournalEntryInput } from '@/types'
|
||||
import { makeSupplierInvoice } from '@/tests/helpers'
|
||||
|
||||
// Mock engine
|
||||
vi.mock('../engine', () => ({
|
||||
findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
|
||||
createJournalEntry: vi.fn().mockImplementation(
|
||||
async (_supabase: unknown, _userId: string, input: CreateJournalEntryInput) => ({
|
||||
id: 'entry-1',
|
||||
...input,
|
||||
lines: input.lines,
|
||||
})
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock currency-utils with real logic
|
||||
vi.mock('../currency-utils', () => ({
|
||||
resolveSekAmount: vi.fn().mockImplementation(
|
||||
(amount: number, amountSek: number | null, currency: string | null, exchangeRate: number | null) => {
|
||||
if (!currency || currency === 'SEK') return amount
|
||||
if (amountSek != null) return Math.round(amountSek * 100) / 100
|
||||
if (exchangeRate != null && exchangeRate > 0) return Math.round(amount * exchangeRate * 100) / 100
|
||||
return amount
|
||||
}
|
||||
),
|
||||
buildCurrencyMetadata: vi.fn().mockImplementation(
|
||||
(currency: string | null, amountInCurrency: number | null | undefined, exchangeRate: number | null) => {
|
||||
if (!currency || currency === 'SEK') return {}
|
||||
return {
|
||||
...(currency ? { currency } : {}),
|
||||
...(amountInCurrency != null ? { amount_in_currency: amountInCurrency } : {}),
|
||||
...(exchangeRate != null && exchangeRate > 0 ? { exchange_rate: exchangeRate } : {}),
|
||||
}
|
||||
}
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock vat-entries with real reverse charge logic
|
||||
vi.mock('../vat-entries', () => ({
|
||||
generateReverseChargeLines: vi.fn().mockImplementation(
|
||||
(baseAmount: number, vatRate: number = 0.25) => {
|
||||
const vatAmount = Math.round(baseAmount * vatRate * 100) / 100
|
||||
let outputAccount: string
|
||||
switch (vatRate) {
|
||||
case 0.12: outputAccount = '2624'; break
|
||||
case 0.06: outputAccount = '2634'; break
|
||||
default: outputAccount = '2614'; break
|
||||
}
|
||||
return [
|
||||
{ account_number: '2645', debit_amount: vatAmount, credit_amount: 0, line_description: `Fiktiv ingående moms ${vatRate * 100}% (omvänd skattskyldighet)` },
|
||||
{ account_number: outputAccount, debit_amount: 0, credit_amount: vatAmount, line_description: `Fiktiv utgående moms ${vatRate * 100}% (omvänd skattskyldighet)` },
|
||||
]
|
||||
}
|
||||
),
|
||||
}))
|
||||
|
||||
const { createJournalEntry, findFiscalPeriod } = await import('../engine')
|
||||
const mockedCreateEntry = vi.mocked(createJournalEntry)
|
||||
const mockedFindFiscalPeriod = vi.mocked(findFiscalPeriod)
|
||||
|
||||
const {
|
||||
createSupplierInvoiceRegistrationEntry,
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
createSupplierCreditNoteEntry,
|
||||
} = await import('../supplier-invoice-entries')
|
||||
|
||||
function makeItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoiceItem {
|
||||
return {
|
||||
id: 'si-item-1',
|
||||
supplier_invoice_id: 'si-1',
|
||||
sort_order: 0,
|
||||
description: 'Consulting services',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 8000,
|
||||
line_total: 8000,
|
||||
account_number: '6200',
|
||||
vat_code: null,
|
||||
vat_rate: 0.25,
|
||||
vat_amount: 2000,
|
||||
created_at: '2024-06-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function findByAccount(lines: CreateJournalEntryLineInput[], account: string) {
|
||||
return lines.filter((l) => l.account_number === account)
|
||||
}
|
||||
|
||||
/** Balance check helper */
|
||||
function assertBalanced(input: CreateJournalEntryInput) {
|
||||
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(Math.round(totalDebit * 100)).toBe(Math.round(totalCredit * 100))
|
||||
expect(totalDebit).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// createSupplierInvoiceRegistrationEntry
|
||||
// ============================================================
|
||||
|
||||
describe('createSupplierInvoiceRegistrationEntry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockedFindFiscalPeriod.mockResolvedValue('period-1')
|
||||
})
|
||||
|
||||
it('returns null when no fiscal period found', async () => {
|
||||
mockedFindFiscalPeriod.mockResolvedValue(null)
|
||||
const invoice = makeSupplierInvoice()
|
||||
const items = [makeItem()]
|
||||
|
||||
const result = await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates domestic entry with VAT (D expense + D 2641 + C 2440)', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 8000,
|
||||
vat_amount: 2000,
|
||||
total: 10000,
|
||||
})
|
||||
const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
expect(mockedCreateEntry).toHaveBeenCalledOnce()
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
const debit6200 = findByAccount(input.lines, '6200')
|
||||
expect(debit6200).toHaveLength(1)
|
||||
expect(debit6200[0].debit_amount).toBe(8000)
|
||||
|
||||
const debit2641 = findByAccount(input.lines, '2641')
|
||||
expect(debit2641).toHaveLength(1)
|
||||
expect(debit2641[0].debit_amount).toBe(2000) // 8000 * 0.25
|
||||
|
||||
const credit2440 = findByAccount(input.lines, '2440')
|
||||
expect(credit2440).toHaveLength(1)
|
||||
expect(credit2440[0].credit_amount).toBe(10000) // 8000 + 2000
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates domestic entry with zero VAT (no 2641 line)', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 5000,
|
||||
vat_amount: 0,
|
||||
total: 5000,
|
||||
})
|
||||
const items = [makeItem({ line_total: 5000, account_number: '5410', vat_rate: 0 })]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
const debit5410 = findByAccount(input.lines, '5410')
|
||||
expect(debit5410).toHaveLength(1)
|
||||
expect(debit5410[0].debit_amount).toBe(5000)
|
||||
|
||||
const credit2440 = findByAccount(input.lines, '2440')
|
||||
expect(credit2440[0].credit_amount).toBe(5000)
|
||||
|
||||
expect(findByAccount(input.lines, '2641')).toHaveLength(0)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates EU reverse charge entry at 25%', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 10000,
|
||||
vat_amount: 0,
|
||||
total: 10000,
|
||||
reverse_charge: true,
|
||||
})
|
||||
const items = [makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0.25 })]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'eu_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
const debit6540 = findByAccount(input.lines, '6540')
|
||||
expect(debit6540[0].debit_amount).toBe(10000)
|
||||
|
||||
const debit2645 = findByAccount(input.lines, '2645')
|
||||
expect(debit2645).toHaveLength(1)
|
||||
expect(debit2645[0].debit_amount).toBe(2500) // 10000 * 0.25
|
||||
|
||||
const credit2614 = findByAccount(input.lines, '2614')
|
||||
expect(credit2614).toHaveLength(1)
|
||||
expect(credit2614[0].credit_amount).toBe(2500)
|
||||
|
||||
const credit2440 = findByAccount(input.lines, '2440')
|
||||
// 2440 = totalDebits - totalCredits = (10000 + 2500) - 2500 = 10000
|
||||
// The fiktiv moms (D 2645 / C 2614) are offsetting; 2440 only reflects actual supplier debt
|
||||
expect(credit2440[0].credit_amount).toBe(10000)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates EU reverse charge entry at reduced 12%', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 5000,
|
||||
vat_amount: 0,
|
||||
total: 5000,
|
||||
reverse_charge: true,
|
||||
})
|
||||
const items = [makeItem({ line_total: 5000, account_number: '6540', vat_rate: 0.12 })]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'eu_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
const debit2645 = findByAccount(input.lines, '2645')
|
||||
expect(debit2645[0].debit_amount).toBe(600) // 5000 * 0.12
|
||||
|
||||
const credit2624 = findByAccount(input.lines, '2624')
|
||||
expect(credit2624).toHaveLength(1)
|
||||
expect(credit2624[0].credit_amount).toBe(600)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('handles multi-item with different accounts', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 8000,
|
||||
vat_amount: 2000,
|
||||
total: 10000,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ id: 'item-1', line_total: 3000, account_number: '5410', vat_rate: 0.25 }),
|
||||
makeItem({ id: 'item-2', line_total: 5000, account_number: '6200', vat_rate: 0.25 }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
const debit5410 = findByAccount(input.lines, '5410')
|
||||
expect(debit5410[0].debit_amount).toBe(3000)
|
||||
|
||||
const debit6200 = findByAccount(input.lines, '6200')
|
||||
expect(debit6200[0].debit_amount).toBe(5000)
|
||||
|
||||
const debit2641 = findByAccount(input.lines, '2641')
|
||||
expect(debit2641[0].debit_amount).toBe(2000) // (3000 + 5000) * 0.25
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('aggregates multi-item with same account', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 5000,
|
||||
vat_amount: 1250,
|
||||
total: 6250,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ id: 'item-1', line_total: 3000, account_number: '6200', vat_rate: 0.25 }),
|
||||
makeItem({ id: 'item-2', line_total: 2000, account_number: '6200', vat_rate: 0.25 }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
const lines6200 = findByAccount(input.lines, '6200')
|
||||
expect(lines6200).toHaveLength(1)
|
||||
expect(lines6200[0].debit_amount).toBe(5000) // 3000 + 2000
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates per-rate 2641 lines for mixed-rate domestic invoice', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 18000,
|
||||
vat_amount: 3280,
|
||||
total: 21280,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ id: 'item-1', account_number: '4010', line_total: 10000, vat_rate: 0.25 }),
|
||||
makeItem({ id: 'item-2', account_number: '5410', line_total: 5000, vat_rate: 0.12 }),
|
||||
makeItem({ id: 'item-3', account_number: '6200', line_total: 3000, vat_rate: 0.06 }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
const vat2641 = findByAccount(input.lines, '2641')
|
||||
expect(vat2641).toHaveLength(3)
|
||||
|
||||
// 25%: 10000 * 0.25 = 2500
|
||||
expect(vat2641.find((l) => l.line_description.includes('25%'))?.debit_amount).toBe(2500)
|
||||
// 12%: 5000 * 0.12 = 600
|
||||
expect(vat2641.find((l) => l.line_description.includes('12%'))?.debit_amount).toBe(600)
|
||||
// 6%: 3000 * 0.06 = 180
|
||||
expect(vat2641.find((l) => l.line_description.includes('6%'))?.debit_amount).toBe(180)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('adds foreign currency metadata on 2440 line', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.50,
|
||||
subtotal: 800,
|
||||
vat_amount: 0,
|
||||
total: 800,
|
||||
})
|
||||
const items = [makeItem({ line_total: 800, account_number: '6200', vat_rate: 0 })]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
const credit2440 = findByAccount(input.lines, '2440')[0]
|
||||
expect(credit2440.currency).toBe('EUR')
|
||||
expect(credit2440.amount_in_currency).toBe(800)
|
||||
expect(credit2440.exchange_rate).toBe(11.50)
|
||||
})
|
||||
|
||||
it('sets source_type to supplier_invoice_registered', async () => {
|
||||
const invoice = makeSupplierInvoice({ id: 'si-xyz' })
|
||||
const items = [makeItem()]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.source_type).toBe('supplier_invoice_registered')
|
||||
expect(input.source_id).toBe('si-xyz')
|
||||
})
|
||||
|
||||
it('description includes invoice number and arrival number', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
supplier_invoice_number: 'LF-999',
|
||||
arrival_number: 42,
|
||||
})
|
||||
const items = [makeItem()]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.description).toContain('LF-999')
|
||||
expect(input.description).toContain('42')
|
||||
})
|
||||
|
||||
it('handles non-EU reverse charge (services)', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 5000,
|
||||
vat_amount: 0,
|
||||
total: 5000,
|
||||
reverse_charge: true,
|
||||
})
|
||||
const items = [makeItem({ line_total: 5000, vat_rate: 0.25, account_number: '6540' })]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'non_eu_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
expect(findByAccount(input.lines, '2645')).toHaveLength(1)
|
||||
expect(findByAccount(input.lines, '2614')).toHaveLength(1)
|
||||
expect(findByAccount(input.lines, '2641')).toHaveLength(0)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates per-rate 2645/26x4 pairs for mixed-rate reverse charge', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 15000,
|
||||
vat_amount: 0,
|
||||
total: 15000,
|
||||
reverse_charge: true,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ line_total: 10000, vat_rate: 0.25, account_number: '6540' }),
|
||||
makeItem({ id: 'item-2', line_total: 5000, vat_rate: 0.12, account_number: '5410' }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'user-1', invoice, items, 'eu_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
const vat2645 = findByAccount(input.lines, '2645')
|
||||
expect(vat2645).toHaveLength(2)
|
||||
|
||||
// 25%: 2614
|
||||
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(2500)
|
||||
// 12%: 2624
|
||||
expect(findByAccount(input.lines, '2624')[0].credit_amount).toBe(600)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// createSupplierInvoicePaymentEntry
|
||||
// ============================================================
|
||||
|
||||
describe('createSupplierInvoicePaymentEntry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockedFindFiscalPeriod.mockResolvedValue('period-1')
|
||||
})
|
||||
|
||||
it('returns null when no fiscal period found', async () => {
|
||||
mockedFindFiscalPeriod.mockResolvedValue(null)
|
||||
const invoice = makeSupplierInvoice()
|
||||
|
||||
const result = await createSupplierInvoicePaymentEntry(
|
||||
null as never, 'user-1', invoice, 10000, '2024-07-01'
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates standard SEK payment (2 lines)', async () => {
|
||||
const invoice = makeSupplierInvoice({ total: 10000 })
|
||||
|
||||
await createSupplierInvoicePaymentEntry(
|
||||
null as never, 'user-1', invoice, 10000, '2024-07-01'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.lines).toHaveLength(2)
|
||||
|
||||
const debit2440 = findByAccount(input.lines, '2440')[0]
|
||||
expect(debit2440.debit_amount).toBe(10000)
|
||||
|
||||
const credit1930 = findByAccount(input.lines, '1930')[0]
|
||||
expect(credit1930.credit_amount).toBe(10000)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates entry with FX gain (credit 3960)', async () => {
|
||||
const invoice = makeSupplierInvoice({ total: 11500, currency: 'EUR' })
|
||||
|
||||
// paymentAmount = original SEK amount, exchangeRateDifference > 0 = gain
|
||||
await createSupplierInvoicePaymentEntry(
|
||||
null as never, 'user-1', invoice, 11500, '2024-07-15', 500
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.lines).toHaveLength(3)
|
||||
|
||||
const debit2440 = findByAccount(input.lines, '2440')[0]
|
||||
expect(debit2440.debit_amount).toBe(11500)
|
||||
|
||||
const credit1930 = findByAccount(input.lines, '1930')[0]
|
||||
expect(credit1930.credit_amount).toBe(11000) // 11500 - 500
|
||||
|
||||
const credit3960 = findByAccount(input.lines, '3960')[0]
|
||||
expect(credit3960.credit_amount).toBe(500)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates entry with FX loss (debit 7960)', async () => {
|
||||
const invoice = makeSupplierInvoice({ total: 11500, currency: 'EUR' })
|
||||
|
||||
// exchangeRateDifference < 0 = loss
|
||||
await createSupplierInvoicePaymentEntry(
|
||||
null as never, 'user-1', invoice, 11500, '2024-07-15', -300
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.lines).toHaveLength(3)
|
||||
|
||||
const debit2440 = findByAccount(input.lines, '2440')[0]
|
||||
expect(debit2440.debit_amount).toBe(11500)
|
||||
|
||||
const credit1930 = findByAccount(input.lines, '1930')[0]
|
||||
expect(credit1930.credit_amount).toBe(11800) // 11500 - (-300)
|
||||
|
||||
const debit7960 = findByAccount(input.lines, '7960')[0]
|
||||
expect(debit7960.debit_amount).toBe(300)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('exchangeRateDifference=0 creates standard 2-line entry', async () => {
|
||||
const invoice = makeSupplierInvoice()
|
||||
|
||||
await createSupplierInvoicePaymentEntry(
|
||||
null as never, 'user-1', invoice, 10000, '2024-07-01', 0
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.lines).toHaveLength(2)
|
||||
|
||||
expect(findByAccount(input.lines, '3960')).toHaveLength(0)
|
||||
expect(findByAccount(input.lines, '7960')).toHaveLength(0)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('rounds amounts to 2 decimal places', async () => {
|
||||
const invoice = makeSupplierInvoice()
|
||||
|
||||
await createSupplierInvoicePaymentEntry(
|
||||
null as never, 'user-1', invoice, 10000.555, '2024-07-01'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
for (const line of input.lines) {
|
||||
if (line.debit_amount > 0) {
|
||||
expect(line.debit_amount).toBe(Math.round(10000.555 * 100) / 100)
|
||||
}
|
||||
if (line.credit_amount > 0) {
|
||||
expect(line.credit_amount).toBe(Math.round(10000.555 * 100) / 100)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('sets source_type to supplier_invoice_paid', async () => {
|
||||
const invoice = makeSupplierInvoice({ id: 'si-pay-1' })
|
||||
|
||||
await createSupplierInvoicePaymentEntry(
|
||||
null as never, 'user-1', invoice, 10000, '2024-07-01'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.source_type).toBe('supplier_invoice_paid')
|
||||
expect(input.source_id).toBe('si-pay-1')
|
||||
})
|
||||
|
||||
it('uses paymentDate not invoice_date as entry_date', async () => {
|
||||
const invoice = makeSupplierInvoice({ invoice_date: '2024-06-01' })
|
||||
|
||||
await createSupplierInvoicePaymentEntry(
|
||||
null as never, 'user-1', invoice, 10000, '2024-08-15'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.entry_date).toBe('2024-08-15')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// createSupplierInvoiceCashEntry
|
||||
// ============================================================
|
||||
|
||||
describe('createSupplierInvoiceCashEntry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockedFindFiscalPeriod.mockResolvedValue('period-1')
|
||||
})
|
||||
|
||||
it('returns null when no fiscal period found', async () => {
|
||||
mockedFindFiscalPeriod.mockResolvedValue(null)
|
||||
const invoice = makeSupplierInvoice()
|
||||
const items = [makeItem()]
|
||||
|
||||
const result = await createSupplierInvoiceCashEntry(
|
||||
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('domestic with VAT — credits 1930 (not 2440)', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 8000,
|
||||
vat_amount: 2000,
|
||||
total: 10000,
|
||||
})
|
||||
const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
|
||||
|
||||
await createSupplierInvoiceCashEntry(
|
||||
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
expect(findByAccount(input.lines, '6200')[0].debit_amount).toBe(8000)
|
||||
expect(findByAccount(input.lines, '2641')[0].debit_amount).toBe(2000)
|
||||
|
||||
const credit1930 = findByAccount(input.lines, '1930')
|
||||
expect(credit1930).toHaveLength(1)
|
||||
expect(credit1930[0].credit_amount).toBe(10000)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('domestic zero VAT', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 5000,
|
||||
vat_amount: 0,
|
||||
total: 5000,
|
||||
})
|
||||
const items = [makeItem({ line_total: 5000, account_number: '5410', vat_rate: 0 })]
|
||||
|
||||
await createSupplierInvoiceCashEntry(
|
||||
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
expect(findByAccount(input.lines, '5410')[0].debit_amount).toBe(5000)
|
||||
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(5000)
|
||||
expect(findByAccount(input.lines, '2641')).toHaveLength(0)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('EU reverse charge — credits 1930', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 10000,
|
||||
vat_amount: 0,
|
||||
total: 10000,
|
||||
reverse_charge: true,
|
||||
})
|
||||
const items = [makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0.25 })]
|
||||
|
||||
await createSupplierInvoiceCashEntry(
|
||||
null as never, 'user-1', invoice, items, '2024-07-01', 'eu_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(2500)
|
||||
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(2500)
|
||||
|
||||
const credit1930 = findByAccount(input.lines, '1930')
|
||||
expect(credit1930).toHaveLength(1)
|
||||
// 1930 = totalDebits - totalCredits = (10000 + 2500) - 2500 = 10000
|
||||
// Fiktiv moms entries are offsetting; bank payment equals actual invoice amount
|
||||
expect(credit1930[0].credit_amount).toBe(10000)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('has no 2440 line', async () => {
|
||||
const invoice = makeSupplierInvoice()
|
||||
const items = [makeItem()]
|
||||
|
||||
await createSupplierInvoiceCashEntry(
|
||||
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(findByAccount(input.lines, '2440')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('creates per-rate 2641 lines for mixed-rate domestic cash entry', async () => {
|
||||
const invoice = makeSupplierInvoice({ vat_amount: 2680, total: 15680 })
|
||||
const items = [
|
||||
makeItem({ line_total: 10000, vat_rate: 0.25 }),
|
||||
makeItem({ id: 'item-2', line_total: 3000, vat_rate: 0.06, account_number: '5410' }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceCashEntry(
|
||||
null as never, 'user-1', invoice, items, '2024-06-01', 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
const vat2641 = findByAccount(input.lines, '2641')
|
||||
expect(vat2641).toHaveLength(2)
|
||||
expect(vat2641.find((l) => l.line_description.includes('25%'))?.debit_amount).toBe(2500)
|
||||
expect(vat2641.find((l) => l.line_description.includes('6%'))?.debit_amount).toBe(180)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('sets source_type to supplier_invoice_cash_payment', async () => {
|
||||
const invoice = makeSupplierInvoice({ id: 'si-cash-1' })
|
||||
const items = [makeItem()]
|
||||
|
||||
await createSupplierInvoiceCashEntry(
|
||||
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.source_type).toBe('supplier_invoice_cash_payment')
|
||||
expect(input.source_id).toBe('si-cash-1')
|
||||
})
|
||||
|
||||
it('description contains "kontantmetoden"', async () => {
|
||||
const invoice = makeSupplierInvoice()
|
||||
const items = [makeItem()]
|
||||
|
||||
await createSupplierInvoiceCashEntry(
|
||||
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.description).toContain('kontantmetoden')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// createSupplierCreditNoteEntry
|
||||
// ============================================================
|
||||
|
||||
describe('createSupplierCreditNoteEntry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockedFindFiscalPeriod.mockResolvedValue('period-1')
|
||||
})
|
||||
|
||||
it('returns null when no fiscal period found', async () => {
|
||||
mockedFindFiscalPeriod.mockResolvedValue(null)
|
||||
const creditNote = makeSupplierInvoice({ is_credit_note: true })
|
||||
const items = [makeItem()]
|
||||
|
||||
const result = await createSupplierCreditNoteEntry(
|
||||
null as never, 'user-1', creditNote, items, 'swedish_business'
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('domestic: D 2440, C expense, C 2641', async () => {
|
||||
const creditNote = makeSupplierInvoice({
|
||||
is_credit_note: true,
|
||||
subtotal: -8000,
|
||||
vat_amount: -2000,
|
||||
total: -10000,
|
||||
})
|
||||
const items = [makeItem({ line_total: -8000, account_number: '6200', vat_rate: 0.25 })]
|
||||
|
||||
await createSupplierCreditNoteEntry(
|
||||
null as never, 'user-1', creditNote, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
const debit2440 = findByAccount(input.lines, '2440')[0]
|
||||
expect(debit2440.debit_amount).toBe(10000) // abs
|
||||
expect(debit2440.credit_amount).toBe(0)
|
||||
|
||||
const credit6200 = findByAccount(input.lines, '6200')[0]
|
||||
expect(credit6200.credit_amount).toBe(8000) // abs
|
||||
expect(credit6200.debit_amount).toBe(0)
|
||||
|
||||
const credit2641 = findByAccount(input.lines, '2641')[0]
|
||||
expect(credit2641.credit_amount).toBe(2000) // abs(8000) * 0.25
|
||||
expect(credit2641.debit_amount).toBe(0)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('domestic zero VAT', async () => {
|
||||
const creditNote = makeSupplierInvoice({
|
||||
is_credit_note: true,
|
||||
subtotal: -5000,
|
||||
vat_amount: 0,
|
||||
total: -5000,
|
||||
})
|
||||
const items = [makeItem({ line_total: -5000, account_number: '6200', vat_rate: 0 })]
|
||||
|
||||
await createSupplierCreditNoteEntry(
|
||||
null as never, 'user-1', creditNote, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
expect(findByAccount(input.lines, '2440')[0].debit_amount).toBe(5000)
|
||||
expect(findByAccount(input.lines, '6200')[0].credit_amount).toBe(5000)
|
||||
expect(findByAccount(input.lines, '2641')).toHaveLength(0)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('EU reverse charge reversal (C 2645, D 2614)', async () => {
|
||||
const creditNote = makeSupplierInvoice({
|
||||
is_credit_note: true,
|
||||
subtotal: -10000,
|
||||
vat_amount: 0,
|
||||
total: -10000,
|
||||
reverse_charge: true,
|
||||
})
|
||||
const items = [makeItem({ line_total: -10000, account_number: '6540', vat_rate: 0.25 })]
|
||||
|
||||
await createSupplierCreditNoteEntry(
|
||||
null as never, 'user-1', creditNote, items, 'eu_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
// Reversed fiktiv moms
|
||||
const credit2645 = findByAccount(input.lines, '2645')[0]
|
||||
expect(credit2645.credit_amount).toBe(2500) // abs(10000) * 0.25
|
||||
expect(credit2645.debit_amount).toBe(0)
|
||||
|
||||
const debit2614 = findByAccount(input.lines, '2614')[0]
|
||||
expect(debit2614.debit_amount).toBe(2500)
|
||||
expect(debit2614.credit_amount).toBe(0)
|
||||
|
||||
const credit6540 = findByAccount(input.lines, '6540')[0]
|
||||
expect(credit6540.credit_amount).toBe(10000)
|
||||
|
||||
const debit2440 = findByAccount(input.lines, '2440')[0]
|
||||
expect(debit2440.debit_amount).toBe(10000) // totalCredits - totalDebits = (2500 + 10000) - 2500
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('uses Math.abs for all amounts (negative inputs produce positive lines)', async () => {
|
||||
const creditNote = makeSupplierInvoice({
|
||||
is_credit_note: true,
|
||||
total: -7500,
|
||||
vat_amount: 0,
|
||||
})
|
||||
const items = [makeItem({ line_total: -7500, account_number: '6200', vat_rate: 0 })]
|
||||
|
||||
await createSupplierCreditNoteEntry(
|
||||
null as never, 'user-1', creditNote, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
for (const line of input.lines) {
|
||||
expect(line.debit_amount).toBeGreaterThanOrEqual(0)
|
||||
expect(line.credit_amount).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('2440 line is first (unshift)', async () => {
|
||||
const creditNote = makeSupplierInvoice({
|
||||
is_credit_note: true,
|
||||
total: -10000,
|
||||
vat_amount: -2000,
|
||||
})
|
||||
const items = [makeItem({ line_total: -8000, account_number: '6200', vat_rate: 0.25 })]
|
||||
|
||||
await createSupplierCreditNoteEntry(
|
||||
null as never, 'user-1', creditNote, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.lines[0].account_number).toBe('2440')
|
||||
})
|
||||
|
||||
it('sets source_type to supplier_credit_note', async () => {
|
||||
const creditNote = makeSupplierInvoice({ id: 'si-cn-1', is_credit_note: true })
|
||||
const items = [makeItem()]
|
||||
|
||||
await createSupplierCreditNoteEntry(
|
||||
null as never, 'user-1', creditNote, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.source_type).toBe('supplier_credit_note')
|
||||
expect(input.source_id).toBe('si-cn-1')
|
||||
})
|
||||
|
||||
it('reverses mixed-rate reverse charge with correct per-rate accounts', async () => {
|
||||
const creditNote = makeSupplierInvoice({
|
||||
is_credit_note: true,
|
||||
vat_amount: 0,
|
||||
total: -15000,
|
||||
reverse_charge: true,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ line_total: -10000, vat_rate: 0.25, account_number: '6540' }),
|
||||
makeItem({ id: 'item-2', line_total: -5000, vat_rate: 0.12, account_number: '5410' }),
|
||||
]
|
||||
|
||||
await createSupplierCreditNoteEntry(
|
||||
null as never, 'user-1', creditNote, items, 'eu_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
// 2645 credit lines: 2 (one per rate)
|
||||
const vat2645 = findByAccount(input.lines, '2645')
|
||||
expect(vat2645).toHaveLength(2)
|
||||
|
||||
// 2614 debit (25%): abs(10000) * 0.25 = 2500
|
||||
expect(findByAccount(input.lines, '2614')[0].debit_amount).toBe(2500)
|
||||
// 2624 debit (12%): abs(5000) * 0.12 = 600
|
||||
expect(findByAccount(input.lines, '2624')[0].debit_amount).toBe(600)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,524 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
import type { CreateJournalEntryInput, MappingResult, VatJournalLine } from '@/types'
|
||||
|
||||
// Mock engine
|
||||
vi.mock('../engine', () => ({
|
||||
findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
|
||||
createJournalEntry: vi.fn().mockImplementation(
|
||||
async (_supabase: unknown, _userId: string, input: CreateJournalEntryInput) => ({
|
||||
id: 'entry-1',
|
||||
...input,
|
||||
lines: input.lines,
|
||||
})
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock currency-utils with real logic
|
||||
vi.mock('../currency-utils', () => ({
|
||||
resolveSekAmount: vi.fn().mockImplementation(
|
||||
(amount: number, amountSek: number | null, currency: string | null, exchangeRate: number | null) => {
|
||||
if (!currency || currency === 'SEK') return amount
|
||||
if (amountSek != null) return Math.round(amountSek * 100) / 100
|
||||
if (exchangeRate != null && exchangeRate > 0) return Math.round(amount * exchangeRate * 100) / 100
|
||||
return amount
|
||||
}
|
||||
),
|
||||
buildCurrencyMetadata: vi.fn().mockImplementation(
|
||||
(currency: string | null, amountInCurrency: number | null | undefined, exchangeRate: number | null) => {
|
||||
if (!currency || currency === 'SEK') return {}
|
||||
return {
|
||||
...(currency ? { currency } : {}),
|
||||
...(amountInCurrency != null ? { amount_in_currency: amountInCurrency } : {}),
|
||||
...(exchangeRate != null && exchangeRate > 0 ? { exchange_rate: exchangeRate } : {}),
|
||||
}
|
||||
}
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock vat-entries with real logic
|
||||
vi.mock('../vat-entries', () => ({
|
||||
generateInputVatLine: vi.fn().mockImplementation(
|
||||
(totalAmount: number, vatRate: number = 0.25) => {
|
||||
if (vatRate === 0) return null
|
||||
const vatAmount = Math.round((totalAmount * vatRate) / (1 + vatRate) * 100) / 100
|
||||
return {
|
||||
account_number: '2641',
|
||||
debit_amount: vatAmount,
|
||||
credit_amount: 0,
|
||||
line_description: `Ingående moms ${vatRate * 100}%`,
|
||||
}
|
||||
}
|
||||
),
|
||||
generateReverseChargeLines: vi.fn().mockImplementation(
|
||||
(baseAmount: number, vatRate: number = 0.25) => {
|
||||
const vatAmount = Math.round(baseAmount * vatRate * 100) / 100
|
||||
let outputAccount: string
|
||||
switch (vatRate) {
|
||||
case 0.12: outputAccount = '2624'; break
|
||||
case 0.06: outputAccount = '2634'; break
|
||||
default: outputAccount = '2614'; break
|
||||
}
|
||||
return [
|
||||
{ account_number: '2645', debit_amount: vatAmount, credit_amount: 0, line_description: `Fiktiv ingående moms` },
|
||||
{ account_number: outputAccount, debit_amount: 0, credit_amount: vatAmount, line_description: `Fiktiv utgående moms` },
|
||||
]
|
||||
}
|
||||
),
|
||||
extractNetAmount: vi.fn().mockImplementation(
|
||||
(totalAmount: number, vatRate: number) => {
|
||||
if (vatRate === 0) return totalAmount
|
||||
return Math.round((totalAmount / (1 + vatRate)) * 100) / 100
|
||||
}
|
||||
),
|
||||
extractVatAmount: vi.fn().mockImplementation(
|
||||
(totalAmount: number, vatRate: number) => {
|
||||
if (vatRate === 0) return 0
|
||||
return Math.round((totalAmount - totalAmount / (1 + vatRate)) * 100) / 100
|
||||
}
|
||||
),
|
||||
}))
|
||||
|
||||
const { createJournalEntry, findFiscalPeriod } = await import('../engine')
|
||||
const mockedCreateEntry = vi.mocked(createJournalEntry)
|
||||
const mockedFindFiscalPeriod = vi.mocked(findFiscalPeriod)
|
||||
|
||||
const { createTransactionJournalEntry, buildDomesticExpenseLines } = await import('../transaction-entries')
|
||||
|
||||
function makeMappingResult(overrides: Partial<MappingResult> = {}): MappingResult {
|
||||
return {
|
||||
rule: null,
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
risk_level: 'low',
|
||||
confidence: 0.95,
|
||||
requires_review: false,
|
||||
default_private: false,
|
||||
vat_lines: [],
|
||||
description: 'Test mapping',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Balance check helper */
|
||||
function assertBalanced(input: CreateJournalEntryInput) {
|
||||
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(totalDebit).toBeCloseTo(totalCredit, 2)
|
||||
expect(totalDebit).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
describe('createTransactionJournalEntry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockedFindFiscalPeriod.mockResolvedValue('period-1')
|
||||
})
|
||||
|
||||
// --- Validation ---
|
||||
|
||||
it('throws when debit_account is missing', async () => {
|
||||
const tx = makeTransaction()
|
||||
const mapping = makeMappingResult({ debit_account: '' })
|
||||
|
||||
await expect(
|
||||
createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
).rejects.toThrow('Invalid mapping result')
|
||||
})
|
||||
|
||||
it('throws when credit_account is missing', async () => {
|
||||
const tx = makeTransaction()
|
||||
const mapping = makeMappingResult({ credit_account: '' })
|
||||
|
||||
await expect(
|
||||
createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
).rejects.toThrow('Invalid mapping result')
|
||||
})
|
||||
|
||||
// --- Fiscal period ---
|
||||
|
||||
it('returns null when no fiscal period found', async () => {
|
||||
mockedFindFiscalPeriod.mockResolvedValue(null)
|
||||
const tx = makeTransaction()
|
||||
const mapping = makeMappingResult()
|
||||
|
||||
const result = await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// --- Private expense ---
|
||||
|
||||
it('creates private expense entry for EF (2013)', async () => {
|
||||
const tx = makeTransaction({ amount: -500, description: 'Lunch privat' })
|
||||
const mapping = makeMappingResult({
|
||||
debit_account: '2013',
|
||||
credit_account: '1930',
|
||||
default_private: true,
|
||||
})
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
expect(mockedCreateEntry).toHaveBeenCalledOnce()
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
expect(input.lines).toHaveLength(2)
|
||||
|
||||
const debit2013 = input.lines.find(l => l.account_number === '2013')
|
||||
expect(debit2013?.debit_amount).toBe(500)
|
||||
expect(debit2013?.credit_amount).toBe(0)
|
||||
expect(debit2013?.line_description).toMatch(/^Privat:/)
|
||||
|
||||
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
||||
expect(credit1930?.credit_amount).toBe(500)
|
||||
expect(credit1930?.debit_amount).toBe(0)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates private expense entry for AB (2893)', async () => {
|
||||
const tx = makeTransaction({ amount: -1200, description: 'Privat uttag' })
|
||||
const mapping = makeMappingResult({
|
||||
debit_account: '2893',
|
||||
credit_account: '1930',
|
||||
default_private: true,
|
||||
})
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.lines).toHaveLength(2)
|
||||
|
||||
const debit2893 = input.lines.find(l => l.account_number === '2893')
|
||||
expect(debit2893?.debit_amount).toBe(1200)
|
||||
|
||||
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
||||
expect(credit1930?.credit_amount).toBe(1200)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
// --- Business expense ---
|
||||
|
||||
it('creates business expense without VAT (2 lines)', async () => {
|
||||
const tx = makeTransaction({ amount: -299, description: 'Office supplies' })
|
||||
const mapping = makeMappingResult({
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
vat_lines: [],
|
||||
})
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.lines).toHaveLength(2)
|
||||
|
||||
const debit5410 = input.lines.find(l => l.account_number === '5410')
|
||||
expect(debit5410?.debit_amount).toBe(299)
|
||||
|
||||
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
||||
expect(credit1930?.credit_amount).toBe(299)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates business expense with 25% input VAT (3 lines)', async () => {
|
||||
const tx = makeTransaction({ amount: -1250, description: 'Software license' })
|
||||
const vatLines: VatJournalLine[] = [
|
||||
{ account_number: '2641', debit_amount: 250, credit_amount: 0, description: 'Ingående moms 25%' },
|
||||
]
|
||||
const mapping = makeMappingResult({
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
vat_lines: vatLines,
|
||||
})
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.lines).toHaveLength(3)
|
||||
|
||||
const debit2641 = input.lines.find(l => l.account_number === '2641')
|
||||
expect(debit2641?.debit_amount).toBe(250)
|
||||
|
||||
const debit5410 = input.lines.find(l => l.account_number === '5410')
|
||||
expect(debit5410?.debit_amount).toBe(1000) // 1250 - 250 VAT
|
||||
|
||||
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
||||
expect(credit1930?.credit_amount).toBe(1250)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('handles VAT rounding precision on expense', async () => {
|
||||
const tx = makeTransaction({ amount: -997.50, description: 'Expense with rounding' })
|
||||
const vatLines: VatJournalLine[] = [
|
||||
{ account_number: '2641', debit_amount: 199.50, credit_amount: 0, description: 'Ingående moms 25%' },
|
||||
]
|
||||
const mapping = makeMappingResult({
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
vat_lines: vatLines,
|
||||
})
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
const debit5410 = input.lines.find(l => l.account_number === '5410')
|
||||
// net = Math.round((997.50 - 199.50) * 100) / 100 = 798
|
||||
expect(debit5410?.debit_amount).toBe(Math.round((997.50 - 199.50) * 100) / 100)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates expense with EU reverse charge (2645/2614)', async () => {
|
||||
const tx = makeTransaction({ amount: -5000, description: 'EU SaaS service' })
|
||||
const vatLines: VatJournalLine[] = [
|
||||
{ account_number: '2645', debit_amount: 1250, credit_amount: 0, description: 'Fiktiv ingående moms' },
|
||||
{ account_number: '2614', debit_amount: 0, credit_amount: 1250, description: 'Fiktiv utgående moms' },
|
||||
]
|
||||
const mapping = makeMappingResult({
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
vat_lines: vatLines,
|
||||
})
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
const debit2645 = input.lines.find(l => l.account_number === '2645')
|
||||
expect(debit2645?.debit_amount).toBe(1250)
|
||||
|
||||
const credit2614 = input.lines.find(l => l.account_number === '2614')
|
||||
expect(credit2614?.credit_amount).toBe(1250)
|
||||
|
||||
// Expense: For reverse charge, no 2641 line means netAmount = absAmount - 0 = 5000
|
||||
const debit5410 = input.lines.find(l => l.account_number === '5410')
|
||||
expect(debit5410?.debit_amount).toBe(5000)
|
||||
|
||||
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
||||
expect(credit1930?.credit_amount).toBe(5000)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
// --- Income ---
|
||||
|
||||
it('creates income entry without VAT (2 lines)', async () => {
|
||||
const tx = makeTransaction({ amount: 8000, description: 'Export revenue' })
|
||||
const mapping = makeMappingResult({
|
||||
debit_account: '1930',
|
||||
credit_account: '3001',
|
||||
vat_lines: [],
|
||||
})
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.lines).toHaveLength(2)
|
||||
|
||||
const debit1930 = input.lines.find(l => l.account_number === '1930')
|
||||
expect(debit1930?.debit_amount).toBe(8000)
|
||||
|
||||
const credit3001 = input.lines.find(l => l.account_number === '3001')
|
||||
expect(credit3001?.credit_amount).toBe(8000)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates income entry with output VAT', async () => {
|
||||
const tx = makeTransaction({ amount: 12500, description: 'Sales income' })
|
||||
const vatLines: VatJournalLine[] = [
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 2500, description: 'Utgående moms 25%' },
|
||||
]
|
||||
const mapping = makeMappingResult({
|
||||
debit_account: '1930',
|
||||
credit_account: '3001',
|
||||
vat_lines: vatLines,
|
||||
})
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
|
||||
const debit1930 = input.lines.find(l => l.account_number === '1930')
|
||||
expect(debit1930?.debit_amount).toBe(12500)
|
||||
|
||||
const credit3001 = input.lines.find(l => l.account_number === '3001')
|
||||
expect(credit3001?.credit_amount).toBe(10000) // 12500 - 2500 VAT
|
||||
|
||||
const credit2611 = input.lines.find(l => l.account_number === '2611')
|
||||
expect(credit2611?.credit_amount).toBe(2500)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('handles VAT rounding precision on income', async () => {
|
||||
const tx = makeTransaction({ amount: 333.33, description: 'Small sale' })
|
||||
const vatLines: VatJournalLine[] = [
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 66.67, description: 'Utgående moms 25%' },
|
||||
]
|
||||
const mapping = makeMappingResult({
|
||||
debit_account: '1930',
|
||||
credit_account: '3001',
|
||||
vat_lines: vatLines,
|
||||
})
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
const credit3001 = input.lines.find(l => l.account_number === '3001')
|
||||
// net = Math.round((333.33 - 66.67) * 100) / 100 = 266.66
|
||||
expect(credit3001?.credit_amount).toBe(Math.round((333.33 - 66.67) * 100) / 100)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
// --- Foreign currency ---
|
||||
|
||||
it('adds currency metadata to 1930 line for EUR expense', async () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -100,
|
||||
currency: 'EUR',
|
||||
amount_sek: null,
|
||||
exchange_rate: 11.50,
|
||||
description: 'EUR purchase',
|
||||
})
|
||||
const mapping = makeMappingResult({
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
vat_lines: [],
|
||||
})
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
||||
|
||||
expect(credit1930?.currency).toBe('EUR')
|
||||
expect(credit1930?.amount_in_currency).toBe(100)
|
||||
expect(credit1930?.exchange_rate).toBe(11.50)
|
||||
|
||||
// All amounts in SEK
|
||||
expect(credit1930?.credit_amount).toBe(1150) // 100 * 11.50
|
||||
const debit5410 = input.lines.find(l => l.account_number === '5410')
|
||||
expect(debit5410?.debit_amount).toBe(1150)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('SEK transaction has no currency metadata', async () => {
|
||||
const tx = makeTransaction({ amount: -500, currency: 'SEK' })
|
||||
const mapping = makeMappingResult()
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
for (const line of input.lines) {
|
||||
expect(line.currency).toBeUndefined()
|
||||
expect(line.amount_in_currency).toBeUndefined()
|
||||
expect(line.exchange_rate).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
// --- Metadata ---
|
||||
|
||||
it('sets source_type and source_id correctly', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-abc-123', amount: -100 })
|
||||
const mapping = makeMappingResult()
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.source_type).toBe('bank_transaction')
|
||||
expect(input.source_id).toBe('tx-abc-123')
|
||||
})
|
||||
|
||||
it('uses transaction.date as entry_date', async () => {
|
||||
const tx = makeTransaction({ date: '2024-09-15', amount: -100 })
|
||||
const mapping = makeMappingResult()
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][2]
|
||||
expect(input.entry_date).toBe('2024-09-15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDomesticExpenseLines', () => {
|
||||
it('25% VAT: 3 lines (expense net + 2641 + 1930)', () => {
|
||||
const lines = buildDomesticExpenseLines(1250, '5410', 'Office supplies', 0.25)
|
||||
|
||||
expect(lines).toHaveLength(3)
|
||||
|
||||
const expense = lines.find(l => l.account_number === '5410')
|
||||
expect(expense?.debit_amount).toBe(1000) // 1250 / 1.25
|
||||
|
||||
const vat = lines.find(l => l.account_number === '2641')
|
||||
expect(vat?.debit_amount).toBe(250) // 1250 - 1000
|
||||
|
||||
const bank = lines.find(l => l.account_number === '1930')
|
||||
expect(bank?.credit_amount).toBe(1250)
|
||||
|
||||
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(totalDebit).toBeCloseTo(totalCredit, 2)
|
||||
})
|
||||
|
||||
it('12% VAT: correct amounts', () => {
|
||||
const lines = buildDomesticExpenseLines(1120, '5400', 'Food supplies', 0.12)
|
||||
|
||||
expect(lines).toHaveLength(3)
|
||||
|
||||
const expense = lines.find(l => l.account_number === '5400')
|
||||
expect(expense?.debit_amount).toBe(1000) // 1120 / 1.12
|
||||
|
||||
const vat = lines.find(l => l.account_number === '2641')
|
||||
expect(vat?.debit_amount).toBe(120) // 1120 - 1000
|
||||
|
||||
const bank = lines.find(l => l.account_number === '1930')
|
||||
expect(bank?.credit_amount).toBe(1120)
|
||||
|
||||
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(totalDebit).toBeCloseTo(totalCredit, 2)
|
||||
})
|
||||
|
||||
it('vatRate=0: 2 lines, no 2641', () => {
|
||||
const lines = buildDomesticExpenseLines(500, '5410', 'No VAT expense', 0)
|
||||
|
||||
expect(lines).toHaveLength(2)
|
||||
|
||||
const expense = lines.find(l => l.account_number === '5410')
|
||||
expect(expense?.debit_amount).toBe(500)
|
||||
|
||||
const bank = lines.find(l => l.account_number === '1930')
|
||||
expect(bank?.credit_amount).toBe(500)
|
||||
|
||||
const vatLine = lines.find(l => l.account_number === '2641')
|
||||
expect(vatLine).toBeUndefined()
|
||||
|
||||
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
|
||||
it('negative amount uses Math.abs', () => {
|
||||
const lines = buildDomesticExpenseLines(-750, '5410', 'Negative test', 0)
|
||||
|
||||
expect(lines).toHaveLength(2)
|
||||
|
||||
const expense = lines.find(l => l.account_number === '5410')
|
||||
expect(expense?.debit_amount).toBe(750)
|
||||
|
||||
const bank = lines.find(l => l.account_number === '1930')
|
||||
expect(bank?.credit_amount).toBe(750)
|
||||
|
||||
// All amounts positive
|
||||
for (const line of lines) {
|
||||
expect(line.debit_amount).toBeGreaterThanOrEqual(0)
|
||||
expect(line.credit_amount).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -72,6 +72,30 @@ function getExpenseAccount(category: string, entityType: EntityType = 'enskild_f
|
||||
return EXPENSE_ACCOUNTS[category] || '6991'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the income account for a category, resolving by VAT treatment.
|
||||
* BAS mandates revenue account segregation by VAT rate:
|
||||
* 3001=25%, 3002=12%, 3003=6%, 3305=Export, 3308=EU services, 3004=Exempt.
|
||||
*/
|
||||
function getIncomeAccount(category: string, vatTreatment?: VatTreatment): string {
|
||||
// income_other always maps to 3900 regardless of VAT treatment
|
||||
if (category === 'income_other') return '3900'
|
||||
|
||||
if (vatTreatment) {
|
||||
switch (vatTreatment) {
|
||||
case 'standard_25': return '3001'
|
||||
case 'reduced_12': return '3002'
|
||||
case 'reduced_6': return '3003'
|
||||
case 'export': return '3305'
|
||||
case 'reverse_charge': return '3308'
|
||||
case 'exempt': return '3004'
|
||||
}
|
||||
}
|
||||
|
||||
// No vatTreatment provided — fall back to static mapping
|
||||
return INCOME_ACCOUNTS[category] || '3900'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account mapping for a transaction category
|
||||
*
|
||||
@@ -86,8 +110,15 @@ export function getCategoryAccountMapping(
|
||||
vatTreatment?: VatTreatment
|
||||
): CategoryAccountMapping {
|
||||
// Private/owner transactions use entity-specific accounts
|
||||
// EF: 2013 for withdrawals (uttag), 2018 for deposits (insättningar)
|
||||
// AB: 2893 for both directions
|
||||
if (!isBusiness) {
|
||||
const privateAccount = PRIVATE_ACCOUNTS[entityType] || PRIVATE_ACCOUNTS.enskild_firma
|
||||
let privateAccount: string
|
||||
if (entityType === 'enskild_firma') {
|
||||
privateAccount = amount < 0 ? '2013' : '2018'
|
||||
} else {
|
||||
privateAccount = PRIVATE_ACCOUNTS[entityType] || PRIVATE_ACCOUNTS.enskild_firma
|
||||
}
|
||||
return {
|
||||
debitAccount: amount < 0 ? privateAccount : BANK_ACCOUNT,
|
||||
creditAccount: amount < 0 ? BANK_ACCOUNT : privateAccount,
|
||||
@@ -101,8 +132,9 @@ export function getCategoryAccountMapping(
|
||||
if (category.startsWith('expense_')) {
|
||||
const expenseAccount = getExpenseAccount(category, entityType)
|
||||
|
||||
// Bank fees, card fees, and currency exchange are VAT-exempt in Sweden
|
||||
const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange']
|
||||
// Bank fees, card fees, currency exchange, and representation are VAT-exempt in Sweden
|
||||
// Representation has zero input VAT deduction since 2017-01-01 (ML 8:9)
|
||||
const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange', 'expense_representation']
|
||||
const isVatExempt = vatExemptCategories.includes(category)
|
||||
|
||||
// Use provided vatTreatment, or default based on category
|
||||
@@ -119,7 +151,7 @@ export function getCategoryAccountMapping(
|
||||
|
||||
// Check if it's an income category
|
||||
if (category.startsWith('income_')) {
|
||||
const incomeAccount = INCOME_ACCOUNTS[category] || '3900'
|
||||
const incomeAccount = getIncomeAccount(category, vatTreatment)
|
||||
|
||||
// Use provided vatTreatment, or default to standard_25
|
||||
const resolvedVat = vatTreatment ?? 'standard_25'
|
||||
@@ -313,7 +345,8 @@ export function getDefaultVatTreatmentForCategory(
|
||||
return null
|
||||
}
|
||||
|
||||
const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange']
|
||||
// Representation has zero input VAT deduction since 2017-01-01 (ML 8:9)
|
||||
const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange', 'expense_representation']
|
||||
if (vatExemptCategories.includes(category)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const ACCOUNT_NAMES: Record<string, string> = {
|
||||
|
||||
// Equity & Liabilities (2xxx)
|
||||
'2013': 'Ovriga egna uttag',
|
||||
'2018': 'Egna insattningar',
|
||||
'2440': 'Leverantorsskulder',
|
||||
'2611': 'Utg. moms 25%',
|
||||
'2621': 'Utg. moms 12%',
|
||||
@@ -24,6 +25,7 @@ const ACCOUNT_NAMES: Record<string, string> = {
|
||||
'3001': 'Forsaljning 25%',
|
||||
'3002': 'Forsaljning 12%',
|
||||
'3003': 'Forsaljning 6%',
|
||||
'3004': 'Momsfri forsaljning',
|
||||
'3305': 'Exportforsaljning',
|
||||
'3308': 'EU-tjanster',
|
||||
'3900': 'Ovriga rorelseintakter',
|
||||
|
||||
@@ -18,14 +18,17 @@ const log = createLogger('supplier-invoice-entries')
|
||||
*
|
||||
* Swedish domestic (25% VAT):
|
||||
* Debit 5xxx/6xxx (per item's account_number) [item line_total]
|
||||
* Debit 2641 Ingående moms [total VAT]
|
||||
* Debit 2641 Ingående moms (per rate) [VAT per rate group]
|
||||
* Credit 2440 Leverantörsskulder [total incl VAT]
|
||||
*
|
||||
* EU reverse charge (supplier_type = 'eu_business'):
|
||||
* EU/non-EU reverse charge (services):
|
||||
* Debit 5xxx/6xxx (per item) [total]
|
||||
* Debit 2645 Beräknad ingående moms [fiktiv VAT]
|
||||
* Credit 2614 Utgående moms omvänd [fiktiv VAT]
|
||||
* 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,
|
||||
@@ -64,29 +67,40 @@ export async function createSupplierInvoiceRegistrationEntry(
|
||||
}
|
||||
lines.push(...debitLines)
|
||||
|
||||
if (supplierType === 'eu_business' && invoice.reverse_charge) {
|
||||
// EU reverse charge: fiktiv moms entries (computed on SEK subtotal)
|
||||
const vatRate = getDefaultVatRate(invoice.vat_treatment)
|
||||
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
|
||||
const reverseChargeLines = generateReverseChargeLines(subtotalSek, vatRate)
|
||||
lines.push(...reverseChargeLines)
|
||||
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 (in SEK)
|
||||
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
|
||||
lines.push({
|
||||
account_number: '2641',
|
||||
debit_amount: Math.round(vatSek * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: `Ingående moms ${desc}`,
|
||||
})
|
||||
// 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: credit = sum of all debit lines
|
||||
// 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 * 100) / 100,
|
||||
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
|
||||
line_description: desc,
|
||||
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
|
||||
})
|
||||
@@ -242,29 +256,40 @@ export async function createSupplierInvoiceCashEntry(
|
||||
})
|
||||
}
|
||||
|
||||
if (supplierType === 'eu_business' && invoice.reverse_charge) {
|
||||
// EU reverse charge: fiktiv moms entries (computed on SEK subtotal)
|
||||
const vatRate = getDefaultVatRate(invoice.vat_treatment)
|
||||
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
|
||||
const reverseChargeLines = generateReverseChargeLines(subtotalSek, vatRate)
|
||||
lines.push(...reverseChargeLines)
|
||||
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 (in SEK)
|
||||
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
|
||||
lines.push({
|
||||
account_number: '2641',
|
||||
debit_amount: Math.round(vatSek * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: `Ingående moms ${desc}`,
|
||||
})
|
||||
// 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: credit = sum of all debit lines
|
||||
// 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 * 100) / 100,
|
||||
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
@@ -321,34 +346,46 @@ export async function createSupplierCreditNoteEntry(
|
||||
})
|
||||
}
|
||||
|
||||
if (supplierType === 'eu_business' && creditNote.reverse_charge) {
|
||||
// Reverse the fiktiv moms (swap debit/credit from registration)
|
||||
const vatRate = getDefaultVatRate(creditNote.vat_treatment)
|
||||
const absSubtotalSek = Math.abs(resolveSekAmount(creditNote.subtotal, creditNote.subtotal_sek, creditNote.currency, creditNote.exchange_rate))
|
||||
const vatAmount = Math.round(absSubtotalSek * vatRate * 100) / 100
|
||||
creditLines.push({
|
||||
account_number: '2645',
|
||||
debit_amount: 0,
|
||||
credit_amount: vatAmount,
|
||||
line_description: `Omvänd fiktiv ingående moms ${desc}`,
|
||||
})
|
||||
// 2614 is a debit (reversal of the output VAT credit)
|
||||
lines.push({
|
||||
account_number: '2614',
|
||||
debit_amount: vatAmount,
|
||||
credit_amount: 0,
|
||||
line_description: `Omvänd fiktiv utgående moms ${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 {
|
||||
const absVat = Math.abs(resolveSekAmount(creditNote.vat_amount, creditNote.vat_amount_sek, creditNote.currency, creditNote.exchange_rate))
|
||||
if (absVat > 0) {
|
||||
// Credit: Ingående moms (reverse)
|
||||
creditLines.push({
|
||||
account_number: '2641',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(absVat * 100) / 100,
|
||||
line_description: `Ingående moms ${desc}`,
|
||||
})
|
||||
// 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}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,13 +414,22 @@ export async function createSupplierCreditNoteEntry(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default VAT rate from treatment string
|
||||
* Group items by VAT rate and sum the VAT amount per rate.
|
||||
* Returns a Map<rate, totalVatAmount> for generating per-rate journal lines.
|
||||
*/
|
||||
function getDefaultVatRate(vatTreatment: string): number {
|
||||
switch (vatTreatment) {
|
||||
case 'standard_25': return 0.25
|
||||
case 'reduced_12': return 0.12
|
||||
case 'reduced_6': return 0.06
|
||||
default: return 0.25
|
||||
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
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ export type CoreEvent =
|
||||
privateTotal: number;
|
||||
userId: string;
|
||||
}}
|
||||
// Supplier Invoice Lifecycle
|
||||
| { type: 'supplier_invoice.registered'; payload: { supplierInvoice: SupplierInvoice; userId: string } }
|
||||
| { type: 'supplier_invoice.approved'; payload: { supplierInvoice: SupplierInvoice; userId: string } }
|
||||
| { type: 'supplier_invoice.paid'; payload: { supplierInvoice: SupplierInvoice; paymentAmount: number; userId: string } }
|
||||
| { type: 'supplier_invoice.credited'; payload: { supplierInvoice: SupplierInvoice; creditNote: SupplierInvoice; userId: string } }
|
||||
// Supplier Invoice Inbox
|
||||
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string } }
|
||||
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string } }
|
||||
|
||||
@@ -53,9 +53,9 @@ const warningPatterns: {
|
||||
pattern: /restaurang|lunch|middag|dinner|café|fika/i,
|
||||
warning: {
|
||||
category: 'Representation',
|
||||
warningLevel: 'info',
|
||||
message: 'Måltider kan vara avdragsgilla som representation med max 300 kr per person (exkl. moms)',
|
||||
legalBasis: 'IL 16 kap 2§',
|
||||
warningLevel: 'warning',
|
||||
message: 'Måltider kan vara avdragsgilla som representation med max 300 kr per person (exkl. moms). Momsen är inte avdragsgill sedan 2017.',
|
||||
legalBasis: 'IL 16 kap 2§, ML 8:9',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user