Merge remote-tracking branch 'origin/main' into code-quality-improvements
This commit is contained in:
@@ -1,5 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getRevenueAccount } from '../invoice-entries'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { getRevenueAccount, getOutputVatAccount } from '../invoice-entries'
|
||||
import type { Invoice, InvoiceItem, CreateJournalEntryInput } from '@/types'
|
||||
|
||||
// Mock the engine so we can capture the input passed to createJournalEntry
|
||||
vi.mock('../engine', () => ({
|
||||
findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
|
||||
createJournalEntry: vi.fn().mockImplementation(
|
||||
async (_userId: string, input: CreateJournalEntryInput) => ({
|
||||
id: 'entry-1',
|
||||
...input,
|
||||
lines: input.lines,
|
||||
})
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock vat-entries to avoid indirect dependency issues
|
||||
vi.mock('../vat-entries', () => ({
|
||||
generateSalesVatLines: vi.fn().mockImplementation(({ vatTreatment, baseAmount }: { vatTreatment: string; baseAmount: number }) => {
|
||||
const rate = vatTreatment === 'standard_25' ? 0.25
|
||||
: vatTreatment === 'reduced_12' ? 0.12
|
||||
: vatTreatment === 'reduced_6' ? 0.06 : 0
|
||||
if (rate === 0) return []
|
||||
const account = vatTreatment === 'standard_25' ? '2611'
|
||||
: vatTreatment === 'reduced_12' ? '2621' : '2631'
|
||||
return [{
|
||||
account_number: account,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(baseAmount * rate * 100) / 100,
|
||||
line_description: `Utgående moms`,
|
||||
}]
|
||||
}),
|
||||
generateReverseChargeLines: vi.fn().mockReturnValue([]),
|
||||
}))
|
||||
|
||||
const { createJournalEntry } = await import('../engine')
|
||||
const mockedCreateEntry = vi.mocked(createJournalEntry)
|
||||
|
||||
// Import functions under test AFTER mocks are set up
|
||||
const {
|
||||
createInvoiceJournalEntry,
|
||||
createCreditNoteJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} = await import('../invoice-entries')
|
||||
|
||||
// Helper to build a minimal Invoice with items
|
||||
function makeInvoice(overrides: Partial<Invoice> & { items?: InvoiceItem[] }): Invoice {
|
||||
return {
|
||||
id: 'inv-1',
|
||||
user_id: 'user-1',
|
||||
customer_id: 'cust-1',
|
||||
invoice_number: '1001',
|
||||
invoice_date: '2024-06-15',
|
||||
due_date: '2024-07-15',
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
exchange_rate_date: null,
|
||||
subtotal: 1000,
|
||||
subtotal_sek: null,
|
||||
vat_amount: 250,
|
||||
vat_amount_sek: null,
|
||||
total: 1250,
|
||||
total_sek: null,
|
||||
vat_treatment: 'standard_25',
|
||||
vat_rate: 25,
|
||||
moms_ruta: '05',
|
||||
reverse_charge_text: null,
|
||||
your_reference: null,
|
||||
our_reference: null,
|
||||
notes: null,
|
||||
status: 'sent',
|
||||
sent_at: null,
|
||||
paid_at: null,
|
||||
payment_date: null,
|
||||
credited_invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
payment_journal_entry_id: null,
|
||||
document_type: 'invoice',
|
||||
created_at: '2024-06-15T00:00:00Z',
|
||||
updated_at: '2024-06-15T00:00:00Z',
|
||||
items: [],
|
||||
...overrides,
|
||||
} as Invoice
|
||||
}
|
||||
|
||||
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: 'inv-1',
|
||||
sort_order: 0,
|
||||
description: 'Service',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 1000,
|
||||
line_total: 1000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 250,
|
||||
created_at: '2024-06-15T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('getRevenueAccount', () => {
|
||||
it('standard_25 returns 3001', () => {
|
||||
@@ -37,3 +136,255 @@ describe('getRevenueAccount', () => {
|
||||
expect(getRevenueAccount('export', 'aktiebolag')).toBe('3305')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOutputVatAccount', () => {
|
||||
it('standard_25 returns 2611', () => {
|
||||
expect(getOutputVatAccount('standard_25')).toBe('2611')
|
||||
})
|
||||
|
||||
it('reduced_12 returns 2621', () => {
|
||||
expect(getOutputVatAccount('reduced_12')).toBe('2621')
|
||||
})
|
||||
|
||||
it('reduced_6 returns 2631', () => {
|
||||
expect(getOutputVatAccount('reduced_6')).toBe('2631')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createInvoiceJournalEntry — per-line VAT', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('single-rate invoice creates one revenue + one VAT line', async () => {
|
||||
const invoice = makeInvoice({
|
||||
subtotal: 1000,
|
||||
vat_amount: 250,
|
||||
total: 1250,
|
||||
vat_treatment: 'standard_25',
|
||||
items: [
|
||||
makeItem({ description: 'A', quantity: 2, unit_price: 300, line_total: 600, vat_rate: 25, vat_amount: 150 }),
|
||||
makeItem({ id: 'item-2', description: 'B', quantity: 1, unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100 }),
|
||||
],
|
||||
})
|
||||
|
||||
await createInvoiceJournalEntry('user-1', invoice)
|
||||
|
||||
expect(mockedCreateEntry).toHaveBeenCalledOnce()
|
||||
const input = mockedCreateEntry.mock.calls[0][1]
|
||||
|
||||
// Should have 3 lines: 1510 debit, 3001 credit, 2611 credit
|
||||
expect(input.lines).toHaveLength(3)
|
||||
|
||||
// Debit 1510 = total
|
||||
const debit1510 = input.lines.find((l) => l.account_number === '1510')
|
||||
expect(debit1510?.debit_amount).toBe(1250)
|
||||
expect(debit1510?.credit_amount).toBe(0)
|
||||
|
||||
// Credit 3001 = subtotal
|
||||
const credit3001 = input.lines.find((l) => l.account_number === '3001')
|
||||
expect(credit3001?.debit_amount).toBe(0)
|
||||
expect(credit3001?.credit_amount).toBe(1000)
|
||||
|
||||
// Credit 2611 = VAT
|
||||
const credit2611 = input.lines.find((l) => l.account_number === '2611')
|
||||
expect(credit2611?.debit_amount).toBe(0)
|
||||
expect(credit2611?.credit_amount).toBe(250)
|
||||
})
|
||||
|
||||
it('mixed 25%/12% creates two revenue + two VAT lines', async () => {
|
||||
const invoice = makeInvoice({
|
||||
subtotal: 1000,
|
||||
vat_amount: 184, // 600*0.25 + 400*0.12 = 150 + 48 = 198... let's recalc
|
||||
total: 1198,
|
||||
vat_treatment: 'standard_25',
|
||||
vat_rate: null as unknown as number,
|
||||
items: [
|
||||
makeItem({ description: 'Consulting', quantity: 1, unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150 }),
|
||||
makeItem({ id: 'item-2', description: 'Food service', quantity: 1, unit_price: 400, line_total: 400, vat_rate: 12, vat_amount: 48 }),
|
||||
],
|
||||
})
|
||||
invoice.vat_amount = 198
|
||||
invoice.total = 1198
|
||||
|
||||
await createInvoiceJournalEntry('user-1', invoice)
|
||||
|
||||
expect(mockedCreateEntry).toHaveBeenCalledOnce()
|
||||
const input = mockedCreateEntry.mock.calls[0][1]
|
||||
|
||||
// Should have 5 lines: 1510, 3001(25%), 2611(25%), 3002(12%), 2621(12%)
|
||||
expect(input.lines).toHaveLength(5)
|
||||
|
||||
// Debit 1510 = total
|
||||
const debit1510 = input.lines.find((l) => l.account_number === '1510')
|
||||
expect(debit1510?.debit_amount).toBe(1198)
|
||||
|
||||
// Revenue 3001 (25% group)
|
||||
const credit3001 = input.lines.find((l) => l.account_number === '3001')
|
||||
expect(credit3001?.credit_amount).toBe(600)
|
||||
|
||||
// VAT 2611 (25% group)
|
||||
const credit2611 = input.lines.find((l) => l.account_number === '2611')
|
||||
expect(credit2611?.credit_amount).toBe(150)
|
||||
|
||||
// Revenue 3002 (12% group)
|
||||
const credit3002 = input.lines.find((l) => l.account_number === '3002')
|
||||
expect(credit3002?.credit_amount).toBe(400)
|
||||
|
||||
// VAT 2621 (12% group)
|
||||
const credit2621 = input.lines.find((l) => l.account_number === '2621')
|
||||
expect(credit2621?.credit_amount).toBe(48)
|
||||
})
|
||||
|
||||
it('reverse charge creates single 3308, no VAT lines', async () => {
|
||||
const invoice = makeInvoice({
|
||||
subtotal: 5000,
|
||||
vat_amount: 0,
|
||||
total: 5000,
|
||||
vat_treatment: 'reverse_charge',
|
||||
vat_rate: 0,
|
||||
items: [
|
||||
makeItem({ quantity: 1, unit_price: 5000, line_total: 5000, vat_rate: 0, vat_amount: 0 }),
|
||||
],
|
||||
})
|
||||
|
||||
await createInvoiceJournalEntry('user-1', invoice)
|
||||
|
||||
expect(mockedCreateEntry).toHaveBeenCalledOnce()
|
||||
const input = mockedCreateEntry.mock.calls[0][1]
|
||||
|
||||
// Should have 2 lines: 1510 debit, 3308 credit (no VAT)
|
||||
expect(input.lines).toHaveLength(2)
|
||||
|
||||
const debit1510 = input.lines.find((l) => l.account_number === '1510')
|
||||
expect(debit1510?.debit_amount).toBe(5000)
|
||||
|
||||
const credit3308 = input.lines.find((l) => l.account_number === '3308')
|
||||
expect(credit3308?.credit_amount).toBe(5000)
|
||||
|
||||
// No VAT lines
|
||||
const vatLines = input.lines.filter((l) =>
|
||||
l.account_number.startsWith('26')
|
||||
)
|
||||
expect(vatLines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('balance: debit(1510) = sum(revenue + VAT credits)', async () => {
|
||||
const invoice = makeInvoice({
|
||||
subtotal: 2000,
|
||||
vat_amount: 380, // 1200*0.25 + 500*0.12 + 300*0.06 = 300 + 60 + 18 = 378
|
||||
total: 2378,
|
||||
vat_treatment: 'standard_25',
|
||||
vat_rate: null as unknown as number,
|
||||
items: [
|
||||
makeItem({ description: 'A', quantity: 1, unit_price: 1200, line_total: 1200, vat_rate: 25, vat_amount: 300 }),
|
||||
makeItem({ id: 'item-2', description: 'B', quantity: 1, unit_price: 500, line_total: 500, vat_rate: 12, vat_amount: 60 }),
|
||||
makeItem({ id: 'item-3', description: 'C', quantity: 1, unit_price: 300, line_total: 300, vat_rate: 6, vat_amount: 18 }),
|
||||
],
|
||||
})
|
||||
invoice.vat_amount = 378
|
||||
invoice.total = 2378
|
||||
|
||||
await createInvoiceJournalEntry('user-1', invoice)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][1]
|
||||
|
||||
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).toBe(totalCredit)
|
||||
expect(totalDebit).toBe(2378)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCreditNoteJournalEntry — per-line VAT', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('reverses per-rate lines correctly for mixed rates', async () => {
|
||||
const creditNote = makeInvoice({
|
||||
invoice_number: 'KR-1001',
|
||||
subtotal: -1000,
|
||||
vat_amount: -198,
|
||||
total: -1198,
|
||||
vat_treatment: 'standard_25',
|
||||
items: [
|
||||
makeItem({ quantity: -1, unit_price: 600, line_total: -600, vat_rate: 25, vat_amount: -150 }),
|
||||
makeItem({ id: 'item-2', quantity: -1, unit_price: 400, line_total: -400, vat_rate: 12, vat_amount: -48 }),
|
||||
],
|
||||
})
|
||||
|
||||
await createCreditNoteJournalEntry('user-1', creditNote)
|
||||
|
||||
expect(mockedCreateEntry).toHaveBeenCalledOnce()
|
||||
const input = mockedCreateEntry.mock.calls[0][1]
|
||||
|
||||
// Revenue and VAT lines should be debits (reversed)
|
||||
const debit3001 = input.lines.find((l) => l.account_number === '3001')
|
||||
expect(debit3001?.debit_amount).toBe(600)
|
||||
expect(debit3001?.credit_amount).toBe(0)
|
||||
|
||||
const debit2611 = input.lines.find((l) => l.account_number === '2611')
|
||||
expect(debit2611?.debit_amount).toBe(150)
|
||||
|
||||
const debit3002 = input.lines.find((l) => l.account_number === '3002')
|
||||
expect(debit3002?.debit_amount).toBe(400)
|
||||
|
||||
const debit2621 = input.lines.find((l) => l.account_number === '2621')
|
||||
expect(debit2621?.debit_amount).toBe(48)
|
||||
|
||||
// 1510 should be credit
|
||||
const credit1510 = input.lines.find((l) => l.account_number === '1510')
|
||||
expect(credit1510?.credit_amount).toBe(1198)
|
||||
expect(credit1510?.debit_amount).toBe(0)
|
||||
|
||||
// Balance check
|
||||
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).toBe(totalCredit)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createInvoiceCashEntry — per-line VAT', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('cash method with mixed rates creates per-rate revenue + VAT', async () => {
|
||||
const invoice = makeInvoice({
|
||||
subtotal: 1000,
|
||||
vat_amount: 198,
|
||||
total: 1198,
|
||||
vat_treatment: 'standard_25',
|
||||
items: [
|
||||
makeItem({ quantity: 1, unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150 }),
|
||||
makeItem({ id: 'item-2', quantity: 1, unit_price: 400, line_total: 400, vat_rate: 12, vat_amount: 48 }),
|
||||
],
|
||||
})
|
||||
|
||||
await createInvoiceCashEntry('user-1', invoice, '2024-07-01')
|
||||
|
||||
expect(mockedCreateEntry).toHaveBeenCalledOnce()
|
||||
const input = mockedCreateEntry.mock.calls[0][1]
|
||||
|
||||
// Debit 1930 (bank account) instead of 1510
|
||||
const debit1930 = input.lines.find((l) => l.account_number === '1930')
|
||||
expect(debit1930?.debit_amount).toBe(1198)
|
||||
|
||||
// Same per-rate credits as accrual
|
||||
const credit3001 = input.lines.find((l) => l.account_number === '3001')
|
||||
expect(credit3001?.credit_amount).toBe(600)
|
||||
|
||||
const credit2611 = input.lines.find((l) => l.account_number === '2611')
|
||||
expect(credit2611?.credit_amount).toBe(150)
|
||||
|
||||
const credit3002 = input.lines.find((l) => l.account_number === '3002')
|
||||
expect(credit3002?.credit_amount).toBe(400)
|
||||
|
||||
// Balance
|
||||
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).toBe(totalCredit)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { validatePeriodDuration, monthsBetween } from '../validate-period-duration'
|
||||
|
||||
describe('monthsBetween', () => {
|
||||
it('returns 12 for a standard calendar year', () => {
|
||||
expect(monthsBetween('2025-01-01', '2025-12-31')).toBe(12)
|
||||
})
|
||||
|
||||
it('returns 18 for an 18-month period', () => {
|
||||
expect(monthsBetween('2025-07-01', '2026-12-31')).toBe(18)
|
||||
})
|
||||
|
||||
it('returns 1 for a single month', () => {
|
||||
expect(monthsBetween('2025-03-01', '2025-03-31')).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 3 for a quarter', () => {
|
||||
expect(monthsBetween('2025-10-01', '2025-12-31')).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePeriodDuration', () => {
|
||||
it('returns null for a valid 12-month period', () => {
|
||||
expect(validatePeriodDuration('2025-01-01', '2025-12-31')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for exactly 18 months (max allowed)', () => {
|
||||
expect(validatePeriodDuration('2025-07-01', '2026-12-31')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a short first year (1 month)', () => {
|
||||
expect(validatePeriodDuration('2025-12-01', '2025-12-31')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a short first year (3 months)', () => {
|
||||
expect(validatePeriodDuration('2025-10-01', '2025-12-31')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns error for 19 months (exceeds max)', () => {
|
||||
const result = validatePeriodDuration('2025-06-01', '2026-12-31')
|
||||
expect(result).toContain('19 months')
|
||||
expect(result).toContain('18 months')
|
||||
})
|
||||
|
||||
it('returns error when end is before start', () => {
|
||||
expect(validatePeriodDuration('2025-06-01', '2025-01-31')).toBe(
|
||||
'Period end must be after period start'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns error when start is not 1st of month', () => {
|
||||
expect(validatePeriodDuration('2025-01-15', '2025-12-31')).toBe(
|
||||
'Period start must be the 1st of a month'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns error when end is not last day of month', () => {
|
||||
expect(validatePeriodDuration('2025-01-01', '2025-12-15')).toBe(
|
||||
'Period end must be the last day of a month'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles February end correctly (non-leap year)', () => {
|
||||
expect(validatePeriodDuration('2025-01-01', '2025-02-28')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles February end correctly (leap year)', () => {
|
||||
expect(validatePeriodDuration('2024-01-01', '2024-02-29')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects Feb 28 in a leap year (not last day)', () => {
|
||||
expect(validatePeriodDuration('2024-01-01', '2024-02-28')).toBe(
|
||||
'Period end must be the last day of a month'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for a broken fiscal year (May-Apr)', () => {
|
||||
expect(validatePeriodDuration('2025-05-01', '2026-04-30')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -326,7 +326,55 @@ const ACCOUNT_DESCRIPTIONS: Record<string, AccountDescription> = {
|
||||
}
|
||||
|
||||
export function getAccountDescription(accountNumber: string): AccountDescription | undefined {
|
||||
return ACCOUNT_DESCRIPTIONS[accountNumber]
|
||||
// Check hardcoded descriptions first (most detailed explanations)
|
||||
const hardcoded = ACCOUNT_DESCRIPTIONS[accountNumber]
|
||||
if (hardcoded) return hardcoded
|
||||
|
||||
// Fall back to BAS reference data for accounts not in the hardcoded list
|
||||
try {
|
||||
// Dynamic import avoided — use lazy require pattern
|
||||
const { getBASReference, ACCOUNT_CLASS_LABELS } = require('./bas-reference')
|
||||
const ref = getBASReference(accountNumber)
|
||||
if (ref) {
|
||||
const classLabel = ACCOUNT_CLASS_LABELS[ref.account_class] || ''
|
||||
return {
|
||||
name: ref.account_name,
|
||||
classLabel,
|
||||
type: ref.account_type,
|
||||
explanation: ref.description,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// BAS reference not available — that's fine
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable account class name for BAS account classes.
|
||||
*/
|
||||
export function getAccountClassName(accountClass: number): string {
|
||||
switch (accountClass) {
|
||||
case 1:
|
||||
return '1xxx - Tillgångar'
|
||||
case 2:
|
||||
return '2xxx - Eget kapital & Skulder'
|
||||
case 3:
|
||||
return '3xxx - Intäkter'
|
||||
case 4:
|
||||
return '4xxx - Varuinköp'
|
||||
case 5:
|
||||
return '5xxx - Externa kostnader'
|
||||
case 6:
|
||||
return '6xxx - Övriga externa kostnader'
|
||||
case 7:
|
||||
return '7xxx - Personal'
|
||||
case 8:
|
||||
return '8xxx - Finansiella poster'
|
||||
default:
|
||||
return `${accountClass}xxx - Övrigt`
|
||||
}
|
||||
}
|
||||
|
||||
export { ACCOUNT_DESCRIPTIONS }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,9 @@ export async function findFiscalPeriod(
|
||||
): Promise<string | null> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Use limit(1) instead of single() to handle overlapping fiscal periods
|
||||
// gracefully. When multiple periods cover the same date, we pick the one
|
||||
// with the latest start date (most specific / narrowest period).
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
@@ -97,13 +100,14 @@ export async function findFiscalPeriod(
|
||||
.lte('period_start', date)
|
||||
.gte('period_end', date)
|
||||
.eq('is_closed', false)
|
||||
.single()
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
if (error || !data) {
|
||||
if (error || !data || data.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return data.id
|
||||
return data[0].id
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,21 +1,104 @@
|
||||
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||||
import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries'
|
||||
import { getVatTreatmentForRate } from '@/lib/invoice/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]
|
||||
* Credit 2611 Utgående moms 25% [vat_amount]
|
||||
* Credit 30xx Försäljning [subtotal per rate]
|
||||
* Credit 26xx Utgående moms [vat per rate]
|
||||
*
|
||||
* EU reverse charge:
|
||||
* Debit 1510 Kundfordringar [subtotal]
|
||||
@@ -38,9 +121,6 @@ export async function createInvoiceJournalEntry(
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Determine revenue account based on VAT treatment
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
|
||||
|
||||
// Debit: Kundfordringar (total including VAT)
|
||||
lines.push({
|
||||
account_number: '1510',
|
||||
@@ -52,22 +132,27 @@ export async function createInvoiceJournalEntry(
|
||||
exchange_rate: invoice.exchange_rate || undefined,
|
||||
})
|
||||
|
||||
// Credit: Revenue account (subtotal, excl VAT)
|
||||
lines.push({
|
||||
account_number: revenueAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: invoice.subtotal,
|
||||
line_description: `Försäljning faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
|
||||
// VAT lines (if applicable)
|
||||
if (invoice.vat_amount > 0) {
|
||||
const vatLines = generateSalesVatLines({
|
||||
vatTreatment: invoice.vat_treatment,
|
||||
baseAmount: invoice.subtotal,
|
||||
direction: 'sales',
|
||||
// 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}`,
|
||||
})
|
||||
lines.push(...vatLines)
|
||||
|
||||
if (invoice.vat_amount > 0) {
|
||||
const vatLines = generateSalesVatLines({
|
||||
vatTreatment: invoice.vat_treatment,
|
||||
baseAmount: invoice.subtotal,
|
||||
direction: 'sales',
|
||||
})
|
||||
lines.push(...vatLines)
|
||||
}
|
||||
}
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
@@ -128,9 +213,10 @@ export async function createInvoicePaymentJournalEntry(
|
||||
|
||||
/**
|
||||
* 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]
|
||||
* Debit 26xx Utgående moms [vat_amount]
|
||||
* Debit 30xx Försäljning [subtotal per rate]
|
||||
* Debit 26xx Utgående moms [vat per rate]
|
||||
* Credit 1510 Kundfordringar [total]
|
||||
*/
|
||||
export async function createCreditNoteJournalEntry(
|
||||
@@ -144,30 +230,43 @@ export async function createCreditNoteJournalEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
const revenueAccount = getRevenueAccount(creditNote.vat_treatment, entityType)
|
||||
const absSubtotal = Math.abs(creditNote.subtotal)
|
||||
const absVat = Math.abs(creditNote.vat_amount)
|
||||
const absTotal = Math.abs(creditNote.total)
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Debit: Revenue account (reverse the credit)
|
||||
lines.push({
|
||||
account_number: revenueAccount,
|
||||
debit_amount: absSubtotal,
|
||||
credit_amount: 0,
|
||||
line_description: `Kreditfaktura ${creditNote.invoice_number}`,
|
||||
})
|
||||
// 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)
|
||||
|
||||
// Debit: VAT account (reverse the credit, if applicable)
|
||||
if (absVat > 0) {
|
||||
const vatAccount = getOutputVatAccount(creditNote.vat_treatment)
|
||||
lines.push({
|
||||
account_number: vatAccount,
|
||||
debit_amount: absVat,
|
||||
account_number: revenueAccount,
|
||||
debit_amount: absSubtotal,
|
||||
credit_amount: 0,
|
||||
line_description: `Moms kreditfaktura ${creditNote.invoice_number}`,
|
||||
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)
|
||||
@@ -192,11 +291,11 @@ export async function createCreditNoteJournalEntry(
|
||||
|
||||
/**
|
||||
* Create journal entry for kontantmetoden (cash method) when payment is received.
|
||||
* Combined entry: revenue + VAT recognised at payment.
|
||||
* Supports per-item VAT rates. Revenue + VAT recognised at payment.
|
||||
*
|
||||
* Debit 1930 Företagskonto [total]
|
||||
* Credit 30xx Försäljning [subtotal]
|
||||
* Credit 26xx Utgående moms [vat_amount] (if applicable)
|
||||
* Credit 30xx Försäljning [subtotal per rate]
|
||||
* Credit 26xx Utgående moms [vat per rate] (if applicable)
|
||||
*/
|
||||
export async function createInvoiceCashEntry(
|
||||
userId: string,
|
||||
@@ -210,7 +309,6 @@ export async function createInvoiceCashEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Debit: Företagskonto (total received)
|
||||
@@ -221,23 +319,28 @@ export async function createInvoiceCashEntry(
|
||||
line_description: `Betalning faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
|
||||
// Credit: Revenue account (subtotal excl VAT)
|
||||
lines.push({
|
||||
account_number: revenueAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: invoice.subtotal,
|
||||
line_description: `Försäljning faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
|
||||
// Credit: Output VAT (if applicable)
|
||||
if (invoice.vat_amount > 0) {
|
||||
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
|
||||
// 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: vatAccount,
|
||||
account_number: revenueAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: invoice.vat_amount,
|
||||
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
|
||||
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 = {
|
||||
|
||||
@@ -190,7 +190,7 @@ function getDefaultResult(transaction: Transaction): MappingResult {
|
||||
requires_review: true,
|
||||
default_private: false,
|
||||
vat_lines: [],
|
||||
description: 'Okategoriserad transaktion',
|
||||
description: 'Obokförd transaktion',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Validates fiscal period duration per BFL 3 kap.
|
||||
* Maximum 18 months for any fiscal period (first year may be extended).
|
||||
* Normal ongoing periods are 12 months.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate the number of months between two dates (inclusive of partial months).
|
||||
* Assumes start is 1st of month and end is last of month.
|
||||
*/
|
||||
export function monthsBetween(start: string, end: string): number {
|
||||
const s = new Date(start)
|
||||
const e = new Date(end)
|
||||
return (e.getFullYear() - s.getFullYear()) * 12 + (e.getMonth() - s.getMonth()) + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a fiscal period's duration and date constraints.
|
||||
* Returns null if valid, or an error message string if invalid.
|
||||
*/
|
||||
export function validatePeriodDuration(start: string, end: string): string | null {
|
||||
const startDate = new Date(start)
|
||||
const endDate = new Date(end)
|
||||
|
||||
// end must be after start
|
||||
if (endDate <= startDate) {
|
||||
return 'Period end must be after period start'
|
||||
}
|
||||
|
||||
// start must be 1st of month
|
||||
if (startDate.getDate() !== 1) {
|
||||
return 'Period start must be the 1st of a month'
|
||||
}
|
||||
|
||||
// end must be last day of month
|
||||
const lastDayOfEndMonth = new Date(endDate.getFullYear(), endDate.getMonth() + 1, 0).getDate()
|
||||
if (endDate.getDate() !== lastDayOfEndMonth) {
|
||||
return 'Period end must be the last day of a month'
|
||||
}
|
||||
|
||||
// Max 18 months per BFL 3 kap.
|
||||
const months = monthsBetween(start, end)
|
||||
if (months > 18) {
|
||||
return `Period duration ${months} months exceeds maximum 18 months (BFL 3 kap.)`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import type { FiscalPeriod, PeriodStatus } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -161,6 +162,12 @@ export async function createNextPeriod(
|
||||
const nextStartStr = nextStart.toISOString().split('T')[0]
|
||||
const nextEndStr = nextEnd.toISOString().split('T')[0]
|
||||
|
||||
// Validate period duration (max 18 months per BFL 3 kap.)
|
||||
const durationError = validatePeriodDuration(nextStartStr, nextEndStr)
|
||||
if (durationError) {
|
||||
throw new Error(durationError)
|
||||
}
|
||||
|
||||
// Generate name: e.g. "FY 2025" or "FY 2025/2026"
|
||||
const startYear = nextStart.getFullYear()
|
||||
const endYear = nextEnd.getFullYear()
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { Invoice, Customer, CompanySettings } from '@/types'
|
||||
import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
|
||||
function getDocumentLabel(invoice: Invoice): string {
|
||||
if (invoice.credited_invoice_id) return 'Kreditfaktura'
|
||||
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
||||
if (docType === 'proforma') return 'Proformafaktura'
|
||||
if (docType === 'delivery_note') return 'Följesedel'
|
||||
return 'Faktura'
|
||||
}
|
||||
|
||||
export interface InvoiceEmailData {
|
||||
invoice: Invoice
|
||||
customer: Customer
|
||||
@@ -13,8 +21,12 @@ export interface InvoiceEmailData {
|
||||
export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
const { invoice, customer, company } = data
|
||||
|
||||
const documentType = getDocumentLabel(invoice)
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const documentType = isCreditNote ? 'Kreditfaktura' : 'Faktura'
|
||||
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const isProforma = docType === 'proforma'
|
||||
const hidePayment = isCreditNote || isDeliveryNote || isProforma
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
@@ -79,7 +91,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
</div>
|
||||
|
||||
<!-- Payment Details -->
|
||||
${!isCreditNote ? `
|
||||
${!hidePayment ? `
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h2 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600; color: #111;">
|
||||
Betalningsinformation
|
||||
@@ -146,8 +158,12 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
export function generateInvoiceEmailText(data: InvoiceEmailData): string {
|
||||
const { invoice, customer, company } = data
|
||||
|
||||
const documentType = getDocumentLabel(invoice)
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const documentType = isCreditNote ? 'Kreditfaktura' : 'Faktura'
|
||||
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const isProforma = docType === 'proforma'
|
||||
const hidePayment = isCreditNote || isDeliveryNote || isProforma
|
||||
|
||||
let text = `${documentType} från ${company.company_name}\n`
|
||||
text += `${documentType}nummer: ${invoice.invoice_number}\n\n`
|
||||
@@ -168,7 +184,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
|
||||
text += `Att betala: ${formatCurrency(invoice.total, invoice.currency)}\n`
|
||||
text += `---\n\n`
|
||||
|
||||
if (!isCreditNote) {
|
||||
if (!hidePayment) {
|
||||
text += `Betalningsinformation:\n`
|
||||
if (company.bank_name) text += `Bank: ${company.bank_name}\n`
|
||||
if (company.clearing_number && company.account_number) {
|
||||
@@ -198,8 +214,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
|
||||
*/
|
||||
export function generateInvoiceEmailSubject(data: InvoiceEmailData): string {
|
||||
const { invoice, company } = data
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const documentType = isCreditNote ? 'Kreditfaktura' : 'Faktura'
|
||||
const documentType = getDocumentLabel(invoice)
|
||||
|
||||
return `${documentType} ${invoice.invoice_number} från ${company.company_name}`
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
CAMT053Statement,
|
||||
CAMT054Notification,
|
||||
AuditSecurityEvent,
|
||||
ReconciliationMethod,
|
||||
} from '@/types'
|
||||
|
||||
// ============================================================
|
||||
@@ -32,6 +33,7 @@ export type CoreEvent =
|
||||
// Banking
|
||||
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string } }
|
||||
| { type: 'transaction.categorized'; payload: { transaction: Transaction; account: string; taxCode: string; userId: string } }
|
||||
| { type: 'transaction.reconciled'; payload: { transaction: Transaction; journalEntryId: string; method: ReconciliationMethod; userId: string } }
|
||||
| { type: 'bank.statement_received'; payload: { statement: CAMT053Statement; userId: string } }
|
||||
| { type: 'bank.payment_notification'; payload: { notification: CAMT054Notification; userId: string } }
|
||||
// Periods
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { BASAccount } from '@/types'
|
||||
import type { SIEAccount, SIEAccountMappingRecord } from '../types'
|
||||
import {
|
||||
suggestMappings,
|
||||
validateMappings,
|
||||
getMappingStats,
|
||||
applyMappingOverride,
|
||||
mappingsToMap,
|
||||
} from '../account-mapper'
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeBASAccount(number: string, name: string): BASAccount {
|
||||
const classNum = parseInt(number.charAt(0), 10)
|
||||
const accountType =
|
||||
classNum <= 1
|
||||
? 'asset'
|
||||
: classNum === 2
|
||||
? 'liability'
|
||||
: classNum === 3
|
||||
? 'revenue'
|
||||
: 'expense'
|
||||
return {
|
||||
id: `bas-${number}`,
|
||||
user_id: 'user-1',
|
||||
account_number: number,
|
||||
account_name: name,
|
||||
account_class: classNum,
|
||||
account_group: number.substring(0, 2),
|
||||
account_type: accountType,
|
||||
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
|
||||
plan_type: 'k1',
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
default_vat_code: null,
|
||||
description: null,
|
||||
sru_code: null,
|
||||
sort_order: parseInt(number, 10),
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
}
|
||||
}
|
||||
|
||||
function makeSIEAccount(number: string, name: string): SIEAccount {
|
||||
return { number, name }
|
||||
}
|
||||
|
||||
// --- Fixtures ---
|
||||
|
||||
const basAccounts: BASAccount[] = [
|
||||
makeBASAccount('1510', 'Kundfordringar'),
|
||||
makeBASAccount('1930', 'Företagskonto'),
|
||||
makeBASAccount('2440', 'Leverantörsskulder'),
|
||||
makeBASAccount('3001', 'Försäljning varor 25%'),
|
||||
makeBASAccount('3002', 'Försäljning varor 12%'),
|
||||
makeBASAccount('5010', 'Lokalhyra'),
|
||||
makeBASAccount('6211', 'Telekommunikation'),
|
||||
]
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('suggestMappings', () => {
|
||||
it('returns exact match with confidence 1.0', () => {
|
||||
const source = [makeSIEAccount('1510', 'Kundfordringar')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('1510')
|
||||
expect(result[0].targetName).toBe('Kundfordringar')
|
||||
expect(result[0].confidence).toBe(1.0)
|
||||
expect(result[0].matchType).toBe('exact')
|
||||
expect(result[0].isOverride).toBe(false)
|
||||
})
|
||||
|
||||
it('returns unmapped entry when no match exists', () => {
|
||||
const source = [makeSIEAccount('9999', 'Okänt konto')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('')
|
||||
expect(result[0].targetName).toBe('')
|
||||
expect(result[0].confidence).toBe(0)
|
||||
expect(result[0].matchType).toBe('manual')
|
||||
})
|
||||
|
||||
it('does not fuzzy match accounts with similar names', () => {
|
||||
// 3400 should NOT match 3001 or 3002 despite being in same class
|
||||
const source = [makeSIEAccount('3400', 'Försäljning tjänster')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('')
|
||||
expect(result[0].confidence).toBe(0)
|
||||
})
|
||||
|
||||
it('does not fuzzy match accounts with similar numbers', () => {
|
||||
// 2510 should NOT match 2440 despite being in same class
|
||||
const source = [makeSIEAccount('2510', 'Skatteskulder')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('')
|
||||
expect(result[0].confidence).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves user overrides from existing mappings', () => {
|
||||
const source = [makeSIEAccount('3400', 'Försäljning tjänster')]
|
||||
const existingMappings: SIEAccountMappingRecord[] = [
|
||||
{
|
||||
id: 'map-1',
|
||||
user_id: 'user-1',
|
||||
source_account: '3400',
|
||||
source_name: 'Försäljning tjänster',
|
||||
target_account: '3001',
|
||||
confidence: 1.0,
|
||||
match_type: 'manual',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
]
|
||||
|
||||
const result = suggestMappings(source, basAccounts, existingMappings)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('3001')
|
||||
expect(result[0].isOverride).toBe(true)
|
||||
expect(result[0].matchType).toBe('manual')
|
||||
})
|
||||
|
||||
it('sorts unmapped accounts first (lowest confidence)', () => {
|
||||
const source = [
|
||||
makeSIEAccount('1510', 'Kundfordringar'),
|
||||
makeSIEAccount('9999', 'Okänt konto'),
|
||||
makeSIEAccount('1930', 'Företagskonto'),
|
||||
]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
// Unmapped (confidence 0) should come first
|
||||
expect(result[0].sourceAccount).toBe('9999')
|
||||
expect(result[0].confidence).toBe(0)
|
||||
// Exact matches (confidence 1.0) come after
|
||||
expect(result[1].confidence).toBe(1.0)
|
||||
expect(result[2].confidence).toBe(1.0)
|
||||
})
|
||||
|
||||
it('handles multiple accounts with mixed results', () => {
|
||||
const source = [
|
||||
makeSIEAccount('1510', 'Kundfordringar'),
|
||||
makeSIEAccount('3400', 'Försäljning tjänster'),
|
||||
makeSIEAccount('5010', 'Lokalhyra'),
|
||||
]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
|
||||
const mapped = result.filter((m) => m.targetAccount)
|
||||
const unmapped = result.filter((m) => !m.targetAccount)
|
||||
expect(mapped).toHaveLength(2)
|
||||
expect(unmapped).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles empty source accounts', () => {
|
||||
const result = suggestMappings([], basAccounts)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles empty BAS accounts — everything unmapped', () => {
|
||||
const source = [makeSIEAccount('1510', 'Kundfordringar')]
|
||||
const result = suggestMappings(source, [])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('')
|
||||
expect(result[0].confidence).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateMappings', () => {
|
||||
it('returns valid when all accounts are mapped', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('1930', 'Företagskonto')],
|
||||
basAccounts
|
||||
)
|
||||
const validation = validateMappings(mappings)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.unmappedAccounts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns invalid when accounts are unmapped', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('9999', 'Okänt konto')],
|
||||
basAccounts
|
||||
)
|
||||
const validation = validateMappings(mappings)
|
||||
|
||||
expect(validation.valid).toBe(false)
|
||||
expect(validation.unmappedAccounts).toContain('9999')
|
||||
expect(validation.unmappedAccounts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('detects low confidence accounts', () => {
|
||||
// With exact-match-only mapper, low confidence only comes from existing overrides
|
||||
const mappings = [
|
||||
{
|
||||
sourceAccount: '3400',
|
||||
sourceName: 'Försäljning tjänster',
|
||||
targetAccount: '3001',
|
||||
targetName: 'Försäljning varor 25%',
|
||||
confidence: 0.3,
|
||||
matchType: 'class' as const,
|
||||
isOverride: false,
|
||||
},
|
||||
]
|
||||
|
||||
const validation = validateMappings(mappings)
|
||||
expect(validation.lowConfidenceAccounts).toContain('3400')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMappingStats', () => {
|
||||
it('counts total, mapped, unmapped correctly', () => {
|
||||
const mappings = suggestMappings(
|
||||
[
|
||||
makeSIEAccount('1510', 'Kundfordringar'),
|
||||
makeSIEAccount('9999', 'Okänt konto'),
|
||||
makeSIEAccount('5010', 'Lokalhyra'),
|
||||
],
|
||||
basAccounts
|
||||
)
|
||||
const stats = getMappingStats(mappings)
|
||||
|
||||
expect(stats.total).toBe(3)
|
||||
expect(stats.mapped).toBe(2)
|
||||
expect(stats.unmapped).toBe(1)
|
||||
})
|
||||
|
||||
it('counts match types correctly', () => {
|
||||
const mappings = suggestMappings(
|
||||
[
|
||||
makeSIEAccount('1510', 'Kundfordringar'),
|
||||
makeSIEAccount('9999', 'Okänt konto'),
|
||||
],
|
||||
basAccounts
|
||||
)
|
||||
const stats = getMappingStats(mappings)
|
||||
|
||||
expect(stats.exact).toBe(1)
|
||||
expect(stats.manual).toBe(1) // unmapped gets matchType 'manual'
|
||||
expect(stats.name).toBe(0)
|
||||
expect(stats.class).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates average confidence for mapped accounts only', () => {
|
||||
const mappings = suggestMappings(
|
||||
[
|
||||
makeSIEAccount('1510', 'Kundfordringar'), // exact, confidence 1.0
|
||||
makeSIEAccount('1930', 'Företagskonto'), // exact, confidence 1.0
|
||||
makeSIEAccount('9999', 'Okänt konto'), // unmapped, confidence 0
|
||||
],
|
||||
basAccounts
|
||||
)
|
||||
const stats = getMappingStats(mappings)
|
||||
|
||||
// Average of mapped only: (1.0 + 1.0) / 2 = 1.0
|
||||
expect(stats.averageConfidence).toBe(1.0)
|
||||
})
|
||||
|
||||
it('returns 0 average confidence when nothing is mapped', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('9999', 'Okänt konto')],
|
||||
basAccounts
|
||||
)
|
||||
const stats = getMappingStats(mappings)
|
||||
expect(stats.averageConfidence).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyMappingOverride', () => {
|
||||
it('sets target, confidence 1.0, matchType manual, and isOverride', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('3400', 'Försäljning tjänster')],
|
||||
basAccounts
|
||||
)
|
||||
|
||||
const updated = applyMappingOverride(mappings, '3400', '3001', 'Försäljning varor 25%')
|
||||
|
||||
expect(updated).toHaveLength(1)
|
||||
expect(updated[0].targetAccount).toBe('3001')
|
||||
expect(updated[0].targetName).toBe('Försäljning varor 25%')
|
||||
expect(updated[0].confidence).toBe(1.0)
|
||||
expect(updated[0].matchType).toBe('manual')
|
||||
expect(updated[0].isOverride).toBe(true)
|
||||
})
|
||||
|
||||
it('does not mutate the original array', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('3400', 'Försäljning tjänster')],
|
||||
basAccounts
|
||||
)
|
||||
const original = [...mappings]
|
||||
|
||||
applyMappingOverride(mappings, '3400', '3001', 'Försäljning varor 25%')
|
||||
|
||||
expect(mappings[0].targetAccount).toBe(original[0].targetAccount)
|
||||
expect(mappings[0].confidence).toBe(original[0].confidence)
|
||||
})
|
||||
|
||||
it('only affects the specified source account', () => {
|
||||
const mappings = suggestMappings(
|
||||
[
|
||||
makeSIEAccount('3400', 'Försäljning tjänster'),
|
||||
makeSIEAccount('9998', 'Annat okänt konto'),
|
||||
],
|
||||
basAccounts
|
||||
)
|
||||
|
||||
const updated = applyMappingOverride(mappings, '3400', '3001', 'Försäljning varor 25%')
|
||||
|
||||
const unchanged = updated.find((m) => m.sourceAccount === '9998')
|
||||
expect(unchanged?.targetAccount).toBe('')
|
||||
expect(unchanged?.confidence).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mappingsToMap', () => {
|
||||
it('creates a Map from source to target account', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('1930', 'Företagskonto')],
|
||||
basAccounts
|
||||
)
|
||||
const map = mappingsToMap(mappings)
|
||||
|
||||
expect(map.get('1510')).toBe('1510')
|
||||
expect(map.get('1930')).toBe('1930')
|
||||
expect(map.size).toBe(2)
|
||||
})
|
||||
|
||||
it('skips unmapped accounts', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('9999', 'Okänt konto')],
|
||||
basAccounts
|
||||
)
|
||||
const map = mappingsToMap(mappings)
|
||||
|
||||
expect(map.get('1510')).toBe('1510')
|
||||
expect(map.has('9999')).toBe(false)
|
||||
expect(map.size).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateImportPreview } from '../sie-import'
|
||||
import type { ParsedSIEFile, AccountMapping } from '../types'
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
|
||||
return {
|
||||
header: {
|
||||
sieType: 4,
|
||||
program: 'TestProg',
|
||||
programVersion: '1.0',
|
||||
generatedDate: new Date(2024, 0, 1),
|
||||
format: 'PC8',
|
||||
companyName: 'Test AB',
|
||||
orgNumber: '5566778899',
|
||||
address: null,
|
||||
fiscalYears: [{ yearIndex: 0, start: new Date(2024, 0, 1), end: new Date(2024, 11, 31) }],
|
||||
currency: 'SEK',
|
||||
},
|
||||
accounts: [
|
||||
{ number: '1510', name: 'Kundfordringar' },
|
||||
{ number: '1930', name: 'Företagskonto' },
|
||||
{ number: '2440', name: 'Leverantörsskulder' },
|
||||
],
|
||||
openingBalances: [
|
||||
{ yearIndex: 0, account: '1510', amount: 50000 },
|
||||
{ yearIndex: 0, account: '1930', amount: 100000 },
|
||||
{ yearIndex: 0, account: '2440', amount: -150000 },
|
||||
],
|
||||
closingBalances: [],
|
||||
resultBalances: [],
|
||||
vouchers: [
|
||||
{
|
||||
series: 'A',
|
||||
number: 1,
|
||||
date: new Date(2024, 0, 15),
|
||||
description: 'Faktura 1001',
|
||||
lines: [
|
||||
{ account: '1510', amount: 12500 },
|
||||
{ account: '3001', amount: -10000 },
|
||||
{ account: '2611', amount: -2500 },
|
||||
],
|
||||
},
|
||||
],
|
||||
issues: [],
|
||||
stats: {
|
||||
totalAccounts: 3,
|
||||
totalVouchers: 1,
|
||||
totalTransactionLines: 3,
|
||||
fiscalYearStart: new Date(2024, 0, 1),
|
||||
fiscalYearEnd: new Date(2024, 11, 31),
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMapping(source: string, target: string, confidence: number = 1.0): AccountMapping {
|
||||
return {
|
||||
sourceAccount: source,
|
||||
sourceName: `Account ${source}`,
|
||||
targetAccount: target,
|
||||
targetName: `Target ${target}`,
|
||||
confidence,
|
||||
matchType: target ? 'exact' : 'manual',
|
||||
isOverride: false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('generateImportPreview', () => {
|
||||
describe('trial balance from IB', () => {
|
||||
it('calculates debit totals from positive IB amounts', () => {
|
||||
const parsed = makeParsedFile()
|
||||
const mappings = [
|
||||
makeMapping('1510', '1510'),
|
||||
makeMapping('1930', '1930'),
|
||||
makeMapping('2440', '2440'),
|
||||
]
|
||||
const preview = generateImportPreview(parsed, mappings)
|
||||
|
||||
// Positive amounts: 50000 + 100000 = 150000
|
||||
expect(preview.trialBalance.totalDebit).toBe(150000)
|
||||
})
|
||||
|
||||
it('calculates credit totals from negative IB amounts', () => {
|
||||
const parsed = makeParsedFile()
|
||||
const mappings = [
|
||||
makeMapping('1510', '1510'),
|
||||
makeMapping('1930', '1930'),
|
||||
makeMapping('2440', '2440'),
|
||||
]
|
||||
const preview = generateImportPreview(parsed, mappings)
|
||||
|
||||
// Negative amounts: |-150000| = 150000
|
||||
expect(preview.trialBalance.totalCredit).toBe(150000)
|
||||
})
|
||||
|
||||
it('detects balanced trial balance', () => {
|
||||
const parsed = makeParsedFile()
|
||||
const mappings = [makeMapping('1510', '1510')]
|
||||
const preview = generateImportPreview(parsed, mappings)
|
||||
|
||||
// 150000 debit = 150000 credit
|
||||
expect(preview.trialBalance.isBalanced).toBe(true)
|
||||
})
|
||||
|
||||
it('detects unbalanced trial balance', () => {
|
||||
const parsed = makeParsedFile({
|
||||
openingBalances: [
|
||||
{ yearIndex: 0, account: '1510', amount: 50000 },
|
||||
{ yearIndex: 0, account: '1930', amount: 100000 },
|
||||
// Missing credit side — only 150000 debit, 0 credit
|
||||
],
|
||||
})
|
||||
const mappings = [makeMapping('1510', '1510')]
|
||||
const preview = generateImportPreview(parsed, mappings)
|
||||
|
||||
expect(preview.trialBalance.isBalanced).toBe(false)
|
||||
})
|
||||
|
||||
it('handles zero opening balances', () => {
|
||||
const parsed = makeParsedFile({ openingBalances: [] })
|
||||
const mappings: AccountMapping[] = []
|
||||
const preview = generateImportPreview(parsed, mappings)
|
||||
|
||||
expect(preview.trialBalance.totalDebit).toBe(0)
|
||||
expect(preview.trialBalance.totalCredit).toBe(0)
|
||||
expect(preview.trialBalance.isBalanced).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('company info passthrough', () => {
|
||||
it('passes company name', () => {
|
||||
const parsed = makeParsedFile()
|
||||
const preview = generateImportPreview(parsed, [])
|
||||
expect(preview.companyName).toBe('Test AB')
|
||||
})
|
||||
|
||||
it('passes org number', () => {
|
||||
const parsed = makeParsedFile()
|
||||
const preview = generateImportPreview(parsed, [])
|
||||
expect(preview.orgNumber).toBe('5566778899')
|
||||
})
|
||||
|
||||
it('handles null company info', () => {
|
||||
const parsed = makeParsedFile({
|
||||
header: {
|
||||
...makeParsedFile().header,
|
||||
companyName: null,
|
||||
orgNumber: null,
|
||||
},
|
||||
})
|
||||
const preview = generateImportPreview(parsed, [])
|
||||
expect(preview.companyName).toBeNull()
|
||||
expect(preview.orgNumber).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapping status', () => {
|
||||
it('reflects mapper output counts', () => {
|
||||
const parsed = makeParsedFile()
|
||||
const mappings = [
|
||||
makeMapping('1510', '1510'), // mapped
|
||||
makeMapping('1930', '1930'), // mapped
|
||||
makeMapping('2440', '', 0), // unmapped
|
||||
]
|
||||
const preview = generateImportPreview(parsed, mappings)
|
||||
|
||||
expect(preview.mappingStatus.total).toBe(3)
|
||||
expect(preview.mappingStatus.mapped).toBe(2)
|
||||
expect(preview.mappingStatus.unmapped).toBe(1)
|
||||
})
|
||||
|
||||
it('reports low confidence mappings', () => {
|
||||
const mappings = [
|
||||
makeMapping('1510', '1510', 1.0),
|
||||
makeMapping('3400', '3001', 0.3), // low confidence
|
||||
]
|
||||
const parsed = makeParsedFile()
|
||||
const preview = generateImportPreview(parsed, mappings)
|
||||
|
||||
expect(preview.mappingStatus.lowConfidence).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('statistics', () => {
|
||||
it('passes account count', () => {
|
||||
const parsed = makeParsedFile()
|
||||
const preview = generateImportPreview(parsed, [])
|
||||
expect(preview.accountCount).toBe(3)
|
||||
})
|
||||
|
||||
it('passes voucher count', () => {
|
||||
const parsed = makeParsedFile()
|
||||
const preview = generateImportPreview(parsed, [])
|
||||
expect(preview.voucherCount).toBe(1)
|
||||
})
|
||||
|
||||
it('passes transaction line count', () => {
|
||||
const parsed = makeParsedFile()
|
||||
const preview = generateImportPreview(parsed, [])
|
||||
expect(preview.transactionLineCount).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('issues passthrough', () => {
|
||||
it('passes parse issues to preview', () => {
|
||||
const parsed = makeParsedFile({
|
||||
issues: [
|
||||
{ severity: 'warning', line: 5, message: 'Unknown tag: #FOO', tag: 'FOO' },
|
||||
{ severity: 'error', line: 10, message: 'Invalid voucher', tag: 'VER' },
|
||||
],
|
||||
})
|
||||
const preview = generateImportPreview(parsed, [])
|
||||
|
||||
expect(preview.issues).toHaveLength(2)
|
||||
expect(preview.issues[0].severity).toBe('warning')
|
||||
expect(preview.issues[1].severity).toBe('error')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,345 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseSIEFile, validateSIEFile } from '../sie-parser'
|
||||
|
||||
// --- SIE content fixtures ---
|
||||
|
||||
const MINIMAL_SIE = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#PROGRAM "TestProg" "1.0"',
|
||||
'#FORMAT PC8',
|
||||
'#GEN 20240101',
|
||||
'#FNAMN "Test AB"',
|
||||
'#ORGNR 5566778899',
|
||||
'#VALUTA SEK',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1510 "Kundfordringar"',
|
||||
'#KONTO 1930 "Företagskonto"',
|
||||
'#KONTO 3001 "Försäljning varor 25%"',
|
||||
].join('\n')
|
||||
|
||||
const SIE_WITH_BALANCES = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Balans AB"',
|
||||
'#ORGNR 1234567890',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1510 "Kundfordringar"',
|
||||
'#KONTO 1930 "Företagskonto"',
|
||||
'#KONTO 2440 "Leverantörsskulder"',
|
||||
'#IB 0 1510 50000.00',
|
||||
'#IB 0 1930 100000.00',
|
||||
'#IB 0 2440 -150000.00',
|
||||
'#UB 0 1510 75000.00',
|
||||
'#UB 0 1930 125000.00',
|
||||
'#UB 0 2440 -200000.00',
|
||||
].join('\n')
|
||||
|
||||
const SIE_WITH_VOUCHERS = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Voucher AB"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1510 "Kundfordringar"',
|
||||
'#KONTO 1930 "Företagskonto"',
|
||||
'#KONTO 3001 "Försäljning"',
|
||||
'#KONTO 2611 "Utgående moms 25%"',
|
||||
'#VER A 1 20240115 "Faktura 1001"',
|
||||
'{',
|
||||
'#TRANS 1510 {} 12500.00',
|
||||
'#TRANS 3001 {} -10000.00',
|
||||
'#TRANS 2611 {} -2500.00',
|
||||
'}',
|
||||
'#VER A 2 20240220 "Inbetalning faktura 1001"',
|
||||
'{',
|
||||
'#TRANS 1930 {} 12500.00',
|
||||
'#TRANS 1510 {} -12500.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
const SIE_TYPE_1 = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 1',
|
||||
'#FNAMN "SIE1 AB"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1510 "Kundfordringar"',
|
||||
'#IB 0 1510 50000.00',
|
||||
'#UB 0 1510 75000.00',
|
||||
].join('\n')
|
||||
|
||||
const SIE_WITH_SRU = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "SRU AB"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1510 "Kundfordringar"',
|
||||
'#SRU 1510 7251',
|
||||
'#KONTO 3001 "Försäljning"',
|
||||
'#SRU 3001 7410',
|
||||
].join('\n')
|
||||
|
||||
const SIE_UNBALANCED_VOUCHER = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Obalanserad AB"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1510 "Kundfordringar"',
|
||||
'#KONTO 3001 "Försäljning"',
|
||||
'#VER A 1 20240115 "Obalanserad verifikation"',
|
||||
'{',
|
||||
'#TRANS 1510 {} 10000.00',
|
||||
'#TRANS 3001 {} -5000.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
const SIE_WITH_OBJECT_LIST = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Objects AB"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 5010 "Lokalhyra"',
|
||||
'#KONTO 1930 "Företagskonto"',
|
||||
'#VER A 1 20240115 "Hyra januari"',
|
||||
'{',
|
||||
'#TRANS 5010 {1 "Kontor"} 15000.00',
|
||||
'#TRANS 1930 {} -15000.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
// --- parseSIEFile tests ---
|
||||
|
||||
describe('parseSIEFile', () => {
|
||||
describe('header parsing', () => {
|
||||
it('parses SIE type', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.header.sieType).toBe(4)
|
||||
})
|
||||
|
||||
it('parses company name from #FNAMN', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.header.companyName).toBe('Test AB')
|
||||
})
|
||||
|
||||
it('parses org number from #ORGNR', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.header.orgNumber).toBe('5566778899')
|
||||
})
|
||||
|
||||
it('parses fiscal year from #RAR', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.header.fiscalYears).toHaveLength(1)
|
||||
expect(result.header.fiscalYears[0].yearIndex).toBe(0)
|
||||
expect(result.header.fiscalYears[0].start).toEqual(new Date(2024, 0, 1))
|
||||
expect(result.header.fiscalYears[0].end).toEqual(new Date(2024, 11, 31))
|
||||
})
|
||||
|
||||
it('parses currency from #VALUTA', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.header.currency).toBe('SEK')
|
||||
})
|
||||
|
||||
it('defaults currency to SEK when not specified', () => {
|
||||
const content = '#FLAGGA 0\n#SIETYP 4\n#FNAMN "Test"\n#RAR 0 20240101 20241231'
|
||||
const result = parseSIEFile(content)
|
||||
expect(result.header.currency).toBe('SEK')
|
||||
})
|
||||
|
||||
it('parses program info', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.header.program).toBe('TestProg')
|
||||
expect(result.header.programVersion).toBe('1.0')
|
||||
})
|
||||
|
||||
it('parses generated date', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.header.generatedDate).toEqual(new Date(2024, 0, 1))
|
||||
})
|
||||
|
||||
it('parses SIE type 1', () => {
|
||||
const result = parseSIEFile(SIE_TYPE_1)
|
||||
expect(result.header.sieType).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('account parsing', () => {
|
||||
it('parses #KONTO with number and name', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.accounts).toHaveLength(3)
|
||||
expect(result.accounts[0]).toEqual({ number: '1510', name: 'Kundfordringar' })
|
||||
expect(result.accounts[1]).toEqual({ number: '1930', name: 'Företagskonto' })
|
||||
})
|
||||
|
||||
it('parses #SRU codes onto accounts', () => {
|
||||
const result = parseSIEFile(SIE_WITH_SRU)
|
||||
const account1510 = result.accounts.find((a) => a.number === '1510')
|
||||
expect(account1510?.sruCode).toBe('7251')
|
||||
const account3001 = result.accounts.find((a) => a.number === '3001')
|
||||
expect(account3001?.sruCode).toBe('7410')
|
||||
})
|
||||
})
|
||||
|
||||
describe('balance parsing', () => {
|
||||
it('parses opening balances (#IB) with positive amounts', () => {
|
||||
const result = parseSIEFile(SIE_WITH_BALANCES)
|
||||
const ib1510 = result.openingBalances.find((b) => b.account === '1510')
|
||||
expect(ib1510?.amount).toBe(50000)
|
||||
expect(ib1510?.yearIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('parses opening balances (#IB) with negative amounts', () => {
|
||||
const result = parseSIEFile(SIE_WITH_BALANCES)
|
||||
const ib2440 = result.openingBalances.find((b) => b.account === '2440')
|
||||
expect(ib2440?.amount).toBe(-150000)
|
||||
})
|
||||
|
||||
it('parses closing balances (#UB)', () => {
|
||||
const result = parseSIEFile(SIE_WITH_BALANCES)
|
||||
expect(result.closingBalances).toHaveLength(3)
|
||||
const ub1930 = result.closingBalances.find((b) => b.account === '1930')
|
||||
expect(ub1930?.amount).toBe(125000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('voucher parsing', () => {
|
||||
it('parses #VER with series, number, date, description', () => {
|
||||
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
||||
expect(result.vouchers).toHaveLength(2)
|
||||
|
||||
const v1 = result.vouchers[0]
|
||||
expect(v1.series).toBe('A')
|
||||
expect(v1.number).toBe(1)
|
||||
expect(v1.date).toEqual(new Date(2024, 0, 15))
|
||||
expect(v1.description).toBe('Faktura 1001')
|
||||
})
|
||||
|
||||
it('parses #TRANS lines within a voucher', () => {
|
||||
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
||||
const v1 = result.vouchers[0]
|
||||
|
||||
expect(v1.lines).toHaveLength(3)
|
||||
expect(v1.lines[0]).toMatchObject({ account: '1510', amount: 12500 })
|
||||
expect(v1.lines[1]).toMatchObject({ account: '3001', amount: -10000 })
|
||||
expect(v1.lines[2]).toMatchObject({ account: '2611', amount: -2500 })
|
||||
})
|
||||
|
||||
it('handles object lists in braces', () => {
|
||||
const result = parseSIEFile(SIE_WITH_OBJECT_LIST)
|
||||
expect(result.vouchers).toHaveLength(1)
|
||||
|
||||
const v = result.vouchers[0]
|
||||
expect(v.lines).toHaveLength(2)
|
||||
expect(v.lines[0]).toMatchObject({ account: '5010', amount: 15000 })
|
||||
expect(v.lines[1]).toMatchObject({ account: '1930', amount: -15000 })
|
||||
})
|
||||
|
||||
it('detects unbalanced vouchers as errors', () => {
|
||||
const result = parseSIEFile(SIE_UNBALANCED_VOUCHER)
|
||||
expect(result.vouchers).toHaveLength(1)
|
||||
|
||||
const errors = result.issues.filter((i) => i.severity === 'error')
|
||||
expect(errors.length).toBeGreaterThanOrEqual(1)
|
||||
expect(errors.some((e) => e.message.includes('not balanced'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('statistics', () => {
|
||||
it('calculates account count', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.stats.totalAccounts).toBe(3)
|
||||
})
|
||||
|
||||
it('calculates voucher count', () => {
|
||||
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
||||
expect(result.stats.totalVouchers).toBe(2)
|
||||
})
|
||||
|
||||
it('calculates transaction line count', () => {
|
||||
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
||||
// Voucher 1: 3 lines, Voucher 2: 2 lines
|
||||
expect(result.stats.totalTransactionLines).toBe(5)
|
||||
})
|
||||
|
||||
it('sets fiscal year start/end from RAR 0', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.stats.fiscalYearStart).toEqual(new Date(2024, 0, 1))
|
||||
expect(result.stats.fiscalYearEnd).toEqual(new Date(2024, 11, 31))
|
||||
})
|
||||
|
||||
it('returns null fiscal year dates when no RAR', () => {
|
||||
const content = '#FLAGGA 0\n#SIETYP 4\n#FNAMN "Test"'
|
||||
const result = parseSIEFile(content)
|
||||
expect(result.stats.fiscalYearStart).toBeNull()
|
||||
expect(result.stats.fiscalYearEnd).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --- validateSIEFile tests ---
|
||||
|
||||
describe('validateSIEFile', () => {
|
||||
it('returns valid for a complete SIE file', () => {
|
||||
const parsed = parseSIEFile(SIE_WITH_VOUCHERS)
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('adds error for unbalanced vouchers', () => {
|
||||
const parsed = parseSIEFile(SIE_UNBALANCED_VOUCHER)
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
expect(validation.valid).toBe(false)
|
||||
expect(validation.errors.some((e) => e.includes('not balanced'))).toBe(true)
|
||||
})
|
||||
|
||||
it('adds warning for undefined account references', () => {
|
||||
const content = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Test"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1510 "Kundfordringar"',
|
||||
'#IB 0 9999 50000.00',
|
||||
].join('\n')
|
||||
|
||||
const parsed = parseSIEFile(content)
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
expect(validation.warnings.some((w) => w.includes('9999') && w.includes('not defined'))).toBe(true)
|
||||
})
|
||||
|
||||
it('adds error for missing #RAR', () => {
|
||||
const content = '#FLAGGA 0\n#SIETYP 4\n#FNAMN "Test"\n#KONTO 1510 "Kund"'
|
||||
const parsed = parseSIEFile(content)
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
expect(validation.valid).toBe(false)
|
||||
expect(validation.errors.some((e) => e.includes('fiscal year') || e.includes('#RAR'))).toBe(true)
|
||||
})
|
||||
|
||||
it('adds warning for unbalanced opening balances', () => {
|
||||
const content = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Test"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1510 "Kundfordringar"',
|
||||
'#IB 0 1510 50000.00',
|
||||
].join('\n')
|
||||
|
||||
const parsed = parseSIEFile(content)
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
expect(validation.warnings.some((w) => w.includes('Opening balances not balanced'))).toBe(true)
|
||||
})
|
||||
|
||||
it('passes with balanced opening balances', () => {
|
||||
const parsed = parseSIEFile(SIE_WITH_BALANCES)
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
// IB: 50000 + 100000 + (-150000) = 0 → balanced
|
||||
const ibWarning = validation.warnings.find((w) => w.includes('Opening balances not balanced'))
|
||||
expect(ibWarning).toBeUndefined()
|
||||
})
|
||||
})
|
||||
+21
-181
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* Account Mapping Engine
|
||||
*
|
||||
* Intelligently maps accounts from an imported SIE file to
|
||||
* the user's BAS chart of accounts. Uses multiple strategies:
|
||||
* 1. Exact account number match
|
||||
* 2. Account name similarity (Levenshtein distance)
|
||||
* 3. Account class consistency (5xxx → expense, etc.)
|
||||
* 4. User-defined overrides
|
||||
* Maps accounts from an imported SIE file to the user's BAS chart of accounts.
|
||||
* Uses exact account number matching only — no fuzzy/heuristic matching.
|
||||
* This aligns with Swedish industry standard (e.g. Fortnox): exact match,
|
||||
* create new, or let the user map manually.
|
||||
*/
|
||||
|
||||
import type { BASAccount } from '@/types'
|
||||
@@ -18,113 +16,8 @@ import type {
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
* Used for name similarity scoring
|
||||
*/
|
||||
function levenshteinDistance(str1: string, str2: string): number {
|
||||
const s1 = str1.toLowerCase()
|
||||
const s2 = str2.toLowerCase()
|
||||
|
||||
if (s1.length === 0) return s2.length
|
||||
if (s2.length === 0) return s1.length
|
||||
|
||||
const matrix: number[][] = []
|
||||
|
||||
// Initialize first column
|
||||
for (let i = 0; i <= s1.length; i++) {
|
||||
matrix[i] = [i]
|
||||
}
|
||||
|
||||
// Initialize first row
|
||||
for (let j = 0; j <= s2.length; j++) {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
// Fill in the rest of the matrix
|
||||
for (let i = 1; i <= s1.length; i++) {
|
||||
for (let j = 1; j <= s2.length; j++) {
|
||||
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1, // deletion
|
||||
matrix[i][j - 1] + 1, // insertion
|
||||
matrix[i - 1][j - 1] + cost // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[s1.length][s2.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity score between two strings (0-1)
|
||||
*/
|
||||
function nameSimilarity(name1: string, name2: string): number {
|
||||
if (!name1 || !name2) return 0
|
||||
|
||||
const distance = levenshteinDistance(name1, name2)
|
||||
const maxLength = Math.max(name1.length, name2.length)
|
||||
|
||||
if (maxLength === 0) return 1
|
||||
|
||||
return 1 - distance / maxLength
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Swedish account names for better matching
|
||||
*/
|
||||
function normalizeAccountName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\wåäö\s]/gi, '') // Remove special chars except Swedish
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.trim()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the account class (first digit) from an account number
|
||||
*/
|
||||
function getAccountClass(accountNumber: string): number {
|
||||
const firstDigit = accountNumber.charAt(0)
|
||||
return parseInt(firstDigit, 10) || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the account group (first two digits) from an account number
|
||||
*/
|
||||
function getAccountGroup(accountNumber: string): string {
|
||||
return accountNumber.substring(0, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two accounts are in compatible classes
|
||||
*/
|
||||
function areClassesCompatible(sourceNumber: string, targetNumber: string): boolean {
|
||||
const sourceClass = getAccountClass(sourceNumber)
|
||||
const targetClass = getAccountClass(targetNumber)
|
||||
|
||||
// Same class is always compatible
|
||||
if (sourceClass === targetClass) return true
|
||||
|
||||
// Allow some flexibility for related classes
|
||||
// 1xxx (assets) can map to 1xxx
|
||||
// 2xxx (equity/liabilities) can map to 2xxx
|
||||
// 3xxx (revenue) can map to 3xxx
|
||||
// 4xxx (cost of goods) can map to 4xxx or 5xxx
|
||||
// 5xxx (external expenses) can map to 5xxx or 6xxx
|
||||
// 6xxx (other external) can map to 6xxx or 5xxx
|
||||
// 7xxx (personnel) can map to 7xxx
|
||||
// 8xxx (financial) can map to 8xxx
|
||||
|
||||
if (sourceClass === 4 && targetClass === 5) return true
|
||||
if (sourceClass === 5 && targetClass === 6) return true
|
||||
if (sourceClass === 6 && targetClass === 5) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching BAS account for a source account
|
||||
* Find the best matching BAS account for a source account.
|
||||
* Only matches on exact account number — no fuzzy matching.
|
||||
*/
|
||||
function findBestMatch(
|
||||
source: SIEAccount,
|
||||
@@ -139,78 +32,25 @@ function findBestMatch(
|
||||
}
|
||||
}
|
||||
|
||||
let bestMatch: BASAccount | null = null
|
||||
let bestScore = 0
|
||||
let matchType: AccountMatchType = 'class'
|
||||
// Exact account number match
|
||||
const exactMatch = basAccounts.find(
|
||||
(target) => source.number === target.account_number
|
||||
)
|
||||
|
||||
for (const target of basAccounts) {
|
||||
// Strategy 1: Exact account number match
|
||||
if (source.number === target.account_number) {
|
||||
return {
|
||||
sourceAccount: source.number,
|
||||
sourceName: source.name,
|
||||
targetAccount: target.account_number,
|
||||
targetName: target.account_name,
|
||||
confidence: 1.0,
|
||||
matchType: 'exact',
|
||||
isOverride: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Name similarity
|
||||
const nameSim = nameSimilarity(
|
||||
normalizeAccountName(source.name),
|
||||
normalizeAccountName(target.account_name)
|
||||
)
|
||||
|
||||
// Strategy 3: Account class compatibility
|
||||
const classCompatible = areClassesCompatible(source.number, target.account_number)
|
||||
const sameGroup = getAccountGroup(source.number) === getAccountGroup(target.account_number)
|
||||
|
||||
// Calculate combined score
|
||||
let score = 0
|
||||
|
||||
// Name similarity is most important
|
||||
if (nameSim >= 0.9) {
|
||||
score = 0.9 + (nameSim - 0.9) // 0.9 - 1.0
|
||||
} else if (nameSim >= 0.7) {
|
||||
score = 0.7 + (nameSim - 0.7) * 0.5 // 0.7 - 0.85
|
||||
} else if (nameSim >= 0.5) {
|
||||
score = 0.5 + (nameSim - 0.5) * 0.25 // 0.5 - 0.575
|
||||
}
|
||||
|
||||
// Boost for same account group (first 2 digits)
|
||||
if (sameGroup) {
|
||||
score += 0.2
|
||||
} else if (classCompatible) {
|
||||
score += 0.1
|
||||
}
|
||||
|
||||
// Penalize cross-class mappings
|
||||
if (!classCompatible) {
|
||||
score *= 0.5
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestMatch = target
|
||||
matchType = nameSim >= 0.7 ? 'name' : 'class'
|
||||
if (exactMatch) {
|
||||
return {
|
||||
sourceAccount: source.number,
|
||||
sourceName: source.name,
|
||||
targetAccount: exactMatch.account_number,
|
||||
targetName: exactMatch.account_name,
|
||||
confidence: 1.0,
|
||||
matchType: 'exact',
|
||||
isOverride: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestMatch || bestScore < 0.3) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
sourceAccount: source.number,
|
||||
sourceName: source.name,
|
||||
targetAccount: bestMatch.account_number,
|
||||
targetName: bestMatch.account_name,
|
||||
confidence: Math.min(bestScore, 1.0),
|
||||
matchType,
|
||||
isOverride: false,
|
||||
}
|
||||
// No match found
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,20 +2,47 @@
|
||||
* Swedbank CSV format parser
|
||||
*
|
||||
* Format: Comma-delimited, PERIOD decimal separator (exception among Swedish banks!)
|
||||
* Columns: Clearingnummer, Kontonummer, Datum, Text, Belopp, Saldo, and more (12 columns)
|
||||
* Columns (real export): Radnr, Clnr, Kontonr, Produkt, Valuta, Bokfdag, Transdag,
|
||||
* Valutadag, Referens, Text, Belopp, Saldo
|
||||
* Date format: YYYY-MM-DD
|
||||
* Encoding: UTF-8 or Windows-1252
|
||||
*
|
||||
* Notes:
|
||||
* - First line is metadata (account info), SKIP it
|
||||
* - First line is metadata (e.g. "* Transaktionsrapport Period ..."), SKIP it
|
||||
* - Second line is the actual header
|
||||
* - Uses period as decimal separator (unlike Nordea/SEB/Handelsbanken)
|
||||
* - Headers may be abbreviated (Clnr vs Clearingnummer, Bokfdag vs Bokföringsdatum)
|
||||
* - Referens column contains counterparty/payee name
|
||||
* - Text column contains transaction type (e.g. "Bg-bet. via internet")
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { parseCSVLine } from './nordea'
|
||||
|
||||
/**
|
||||
* Check if a header value matches any of the given patterns (case-insensitive)
|
||||
*/
|
||||
function matchesHeader(header: string, patterns: string[]): boolean {
|
||||
const h = header.toLowerCase()
|
||||
return patterns.some((p) => h === p || h.includes(p))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line looks like a Swedbank header row
|
||||
*/
|
||||
function isSwedbankHeader(line: string): boolean {
|
||||
const lower = line.toLowerCase()
|
||||
return (
|
||||
// Full names (legacy or alternative exports)
|
||||
lower.includes('clearingnummer') || lower.includes('radnummer') ||
|
||||
// Abbreviated names (current export format)
|
||||
/\bradnr\b/.test(lower) || /\bclnr\b/.test(lower) ||
|
||||
// Combination of typical Swedbank columns
|
||||
(lower.includes('bokfdag') && lower.includes('belopp'))
|
||||
)
|
||||
}
|
||||
|
||||
export const swedbankFormat: BankFileFormat = {
|
||||
id: 'swedbank',
|
||||
name: 'Swedbank',
|
||||
@@ -25,14 +52,10 @@ export const swedbankFormat: BankFileFormat = {
|
||||
detect(content: string, _filename: string): boolean {
|
||||
const prepared = prepareContent(content)
|
||||
const lines = prepared.split('\n')
|
||||
// Check first two lines — Swedbank has metadata line, then header
|
||||
const line1 = lines[0]?.toLowerCase() || ''
|
||||
const line2 = lines[1]?.toLowerCase() || ''
|
||||
const line1 = lines[0] || ''
|
||||
const line2 = lines[1] || ''
|
||||
|
||||
return (
|
||||
(line1.includes('clearingnummer') || line2.includes('clearingnummer') ||
|
||||
line1.includes('radnummer') || line2.includes('radnummer'))
|
||||
)
|
||||
return isSwedbankHeader(line1) || isSwedbankHeader(line2)
|
||||
},
|
||||
|
||||
parse(content: string): BankFileParseResult {
|
||||
@@ -43,16 +66,30 @@ export const swedbankFormat: BankFileFormat = {
|
||||
const issues: BankFileParseIssue[] = []
|
||||
let skippedRows = 0
|
||||
|
||||
// Determine where the header is
|
||||
// Line 0 might be metadata, line 1 might be header
|
||||
let headerLineIdx = 0
|
||||
const line0Lower = lines[0]?.toLowerCase() || ''
|
||||
const line1Lower = lines[1]?.toLowerCase() || ''
|
||||
// Find the header row — may be line 0 or line 1 (if line 0 is metadata)
|
||||
let headerLineIdx = -1
|
||||
for (let i = 0; i < Math.min(lines.length, 3); i++) {
|
||||
if (isSwedbankHeader(lines[i])) {
|
||||
headerLineIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (line1Lower.includes('clearingnummer') || line1Lower.includes('radnummer')) {
|
||||
headerLineIdx = 1
|
||||
} else if (line0Lower.includes('clearingnummer') || line0Lower.includes('radnummer')) {
|
||||
headerLineIdx = 0
|
||||
if (headerLineIdx === -1) {
|
||||
issues.push({
|
||||
row: 1,
|
||||
message: 'Could not find Swedbank header row',
|
||||
severity: 'error',
|
||||
})
|
||||
return {
|
||||
format: 'swedbank',
|
||||
format_name: 'Swedbank',
|
||||
transactions: [],
|
||||
date_from: null,
|
||||
date_to: null,
|
||||
issues,
|
||||
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
const headerLine = lines[headerLineIdx] || ''
|
||||
@@ -60,16 +97,19 @@ export const swedbankFormat: BankFileFormat = {
|
||||
h.trim().toLowerCase().replace(/"/g, '')
|
||||
)
|
||||
|
||||
// Find column indices
|
||||
const dateIdx = headers.findIndex((h) => h === 'datum' || h.includes('bokföringsdatum'))
|
||||
// Find column indices — support both abbreviated and full header names
|
||||
const dateIdx = headers.findIndex((h) =>
|
||||
matchesHeader(h, ['bokfdag', 'bokföringsdatum', 'datum'])
|
||||
)
|
||||
const descIdx = headers.findIndex((h) => h === 'text' || h.includes('beskrivning'))
|
||||
const amountIdx = headers.findIndex((h) => h === 'belopp')
|
||||
const balanceIdx = headers.findIndex((h) => h === 'saldo')
|
||||
const referenceIdx = headers.findIndex((h) => h === 'referens')
|
||||
|
||||
if (dateIdx === -1 || amountIdx === -1) {
|
||||
issues.push({
|
||||
row: 1,
|
||||
message: 'Could not identify required columns (datum, belopp)',
|
||||
row: headerLineIdx + 1,
|
||||
message: `Could not identify required columns (datum, belopp). Found headers: ${headers.join(', ')}`,
|
||||
severity: 'error',
|
||||
})
|
||||
return {
|
||||
@@ -91,7 +131,8 @@ export const swedbankFormat: BankFileFormat = {
|
||||
const fields = parseCSVLine(line, ',').map((f) => f.trim().replace(/^"|"$/g, ''))
|
||||
|
||||
const date = fields[dateIdx]
|
||||
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
|
||||
const reference = referenceIdx >= 0 ? fields[referenceIdx]?.trim() : null
|
||||
const textDesc = descIdx >= 0 ? fields[descIdx]?.trim() : null
|
||||
const amountStr = fields[amountIdx]
|
||||
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
|
||||
|
||||
@@ -116,14 +157,19 @@ export const swedbankFormat: BankFileFormat = {
|
||||
|
||||
const balance = balanceStr ? parseFloat(balanceStr.replace(/\s/g, '')) : null
|
||||
|
||||
// Build description: use reference (counterparty) as primary, text as secondary
|
||||
const description = reference && textDesc
|
||||
? `${reference} — ${textDesc}`
|
||||
: reference || textDesc || 'Unknown'
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
description: (description || 'Unknown').trim(),
|
||||
description,
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
balance: isNaN(balance as number) ? null : balance,
|
||||
reference: null,
|
||||
counterparty: null,
|
||||
counterparty: reference || null,
|
||||
raw_line: line,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -357,6 +357,7 @@ async function importVouchers(
|
||||
description: v.description,
|
||||
source_type: 'import',
|
||||
status: 'posted',
|
||||
committed_at: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
// Insert headers
|
||||
|
||||
+100
-29
@@ -5,7 +5,7 @@ import {
|
||||
View,
|
||||
StyleSheet,
|
||||
} from '@react-pdf/renderer'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
|
||||
// Create styles
|
||||
const styles = StyleSheet.create({
|
||||
@@ -82,7 +82,7 @@ const styles = StyleSheet.create({
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
colDescription: {
|
||||
flex: 4,
|
||||
flex: 3.5,
|
||||
},
|
||||
colQty: {
|
||||
flex: 1,
|
||||
@@ -96,6 +96,10 @@ const styles = StyleSheet.create({
|
||||
flex: 1.5,
|
||||
textAlign: 'right',
|
||||
},
|
||||
colVat: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
},
|
||||
colTotal: {
|
||||
flex: 1.5,
|
||||
textAlign: 'right',
|
||||
@@ -255,6 +259,14 @@ function formatOrgNumber(orgNumber: string): string {
|
||||
return orgNumber
|
||||
}
|
||||
|
||||
function getDocumentTitle(invoice: Invoice): string {
|
||||
if (invoice.credited_invoice_id) return 'KREDITFAKTURA'
|
||||
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
||||
if (docType === 'proforma') return 'PROFORMAFAKTURA'
|
||||
if (docType === 'delivery_note') return 'FÖLJESEDEL'
|
||||
return 'FAKTURA'
|
||||
}
|
||||
|
||||
interface InvoicePDFProps {
|
||||
invoice: Invoice
|
||||
customer: Customer
|
||||
@@ -266,6 +278,28 @@ interface InvoicePDFProps {
|
||||
export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber }: InvoicePDFProps) {
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
|
||||
// Check if items have mixed VAT rates
|
||||
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
|
||||
const uniqueRates = hasPerLineVat
|
||||
? new Set(items.map((item) => item.vat_rate))
|
||||
: new Set<number>()
|
||||
const showVatColumn = hasPerLineVat && uniqueRates.size > 1
|
||||
|
||||
// Calculate per-rate VAT breakdown for totals
|
||||
const vatByRate = new Map<number, { base: number; vat: number }>()
|
||||
if (hasPerLineVat) {
|
||||
for (const item of items) {
|
||||
const rate = item.vat_rate ?? 25
|
||||
const group = vatByRate.get(rate) || { base: 0, vat: 0 }
|
||||
group.base += Math.abs(item.line_total)
|
||||
group.vat += Math.abs(item.vat_amount || 0)
|
||||
vatByRate.set(rate, group)
|
||||
}
|
||||
}
|
||||
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const isProforma = docType === 'proforma'
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
@@ -273,7 +307,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={[styles.title, isCreditNote ? styles.creditNoteTitle : {}]}>
|
||||
{isCreditNote ? 'KREDITFAKTURA' : 'FAKTURA'}
|
||||
{getDocumentTitle(invoice)}
|
||||
</Text>
|
||||
<Text style={{ marginTop: 5, color: '#666' }}>{invoice.invoice_number}</Text>
|
||||
</View>
|
||||
@@ -356,8 +390,15 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text style={[styles.colDescription, styles.tableHeaderText]}>Beskrivning</Text>
|
||||
<Text style={[styles.colQty, styles.tableHeaderText]}>Antal</Text>
|
||||
<Text style={[styles.colUnit, styles.tableHeaderText]}>Enhet</Text>
|
||||
<Text style={[styles.colPrice, styles.tableHeaderText]}>à-pris</Text>
|
||||
<Text style={[styles.colTotal, styles.tableHeaderText]}>Summa</Text>
|
||||
{!isDeliveryNote && (
|
||||
<Text style={[styles.colPrice, styles.tableHeaderText]}>à-pris</Text>
|
||||
)}
|
||||
{!isDeliveryNote && showVatColumn && (
|
||||
<Text style={[styles.colVat, styles.tableHeaderText]}>Moms</Text>
|
||||
)}
|
||||
{!isDeliveryNote && (
|
||||
<Text style={[styles.colTotal, styles.tableHeaderText]}>Summa</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Table rows */}
|
||||
@@ -366,37 +407,67 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text style={styles.colDescription}>{item.description}</Text>
|
||||
<Text style={styles.colQty}>{item.quantity}</Text>
|
||||
<Text style={styles.colUnit}>{item.unit}</Text>
|
||||
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency)}</Text>
|
||||
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency)}</Text>
|
||||
{!isDeliveryNote && (
|
||||
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency)}</Text>
|
||||
)}
|
||||
{!isDeliveryNote && showVatColumn && (
|
||||
<Text style={styles.colVat}>{item.vat_rate ?? 25}%</Text>
|
||||
)}
|
||||
{!isDeliveryNote && (
|
||||
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency)}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Totals */}
|
||||
<View style={styles.totalsSection}>
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Delsumma:</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(invoice.subtotal, invoice.currency)}</Text>
|
||||
</View>
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate}%):</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
|
||||
</View>
|
||||
<View style={styles.grandTotal}>
|
||||
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
|
||||
<Text style={styles.grandTotalValue}>{formatCurrency(invoice.total, invoice.currency)}</Text>
|
||||
</View>
|
||||
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||
<View style={[styles.totalRow, { marginTop: 8 }]}>
|
||||
<Text style={[styles.totalLabel, { fontSize: 9 }]}>I SEK (kurs {invoice.exchange_rate}):</Text>
|
||||
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.total_sek, 'SEK')}</Text>
|
||||
{/* Totals - hidden for delivery notes */}
|
||||
{!isDeliveryNote && (
|
||||
<View style={styles.totalsSection}>
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Delsumma:</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(invoice.subtotal, invoice.currency)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{vatByRate.size > 1 ? (
|
||||
Array.from(vatByRate.entries())
|
||||
.filter(([, group]) => group.vat > 0)
|
||||
.sort(([a], [b]) => b - a)
|
||||
.map(([rate, group]) => (
|
||||
<View key={rate} style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Moms {rate}%:</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(group.vat, invoice.currency)}</Text>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate ?? 25}%):</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.grandTotal}>
|
||||
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
|
||||
<Text style={styles.grandTotalValue}>{formatCurrency(invoice.total, invoice.currency)}</Text>
|
||||
</View>
|
||||
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||
<View style={[styles.totalRow, { marginTop: 8 }]}>
|
||||
<Text style={[styles.totalLabel, { fontSize: 9 }]}>I SEK (kurs {invoice.exchange_rate}):</Text>
|
||||
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.total_sek, 'SEK')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Payment information - not shown for credit notes */}
|
||||
{!isCreditNote && (
|
||||
{/* Proforma notice */}
|
||||
{isProforma && (
|
||||
<View style={[styles.reverseChargeBox, { backgroundColor: '#e8f4fd', borderColor: '#90cdf4' }]}>
|
||||
<Text style={[styles.reverseChargeText, { color: '#2b6cb0' }]}>
|
||||
Detta är en proformafaktura och utgör ingen betalningsanmodan.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Payment information - not shown for credit notes, proformas, or delivery notes */}
|
||||
{!isCreditNote && !isProforma && !isDeliveryNote && (
|
||||
<View style={styles.paymentSection}>
|
||||
<Text style={styles.paymentTitle}>Betalningsinformation</Text>
|
||||
{company.bank_name && (
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
import type { CustomerType, VatTreatment } from '@/types'
|
||||
|
||||
export interface VatRateOption {
|
||||
rate: number
|
||||
label: string
|
||||
treatment: VatTreatment
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available VAT rates for invoice line items based on customer type.
|
||||
*
|
||||
* Swedish/EU-unvalidated customers can choose between 25%, 12%, 6%, and 0% (exempt).
|
||||
* Reverse charge and export customers are locked to 0%.
|
||||
*/
|
||||
export function getAvailableVatRates(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false
|
||||
): VatRateOption[] {
|
||||
// EU business with validated VAT → reverse charge, locked to 0%
|
||||
if (customerType === 'eu_business' && vatNumberValidated) {
|
||||
return [{ rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' }]
|
||||
}
|
||||
|
||||
// Non-EU → export, locked to 0%
|
||||
if (customerType === 'non_eu_business') {
|
||||
return [{ rate: 0, label: '0% (export)', treatment: 'export' }]
|
||||
}
|
||||
|
||||
// Swedish customers (or EU without validated VAT) can choose any rate
|
||||
return [
|
||||
{ rate: 25, label: '25%', treatment: 'standard_25' },
|
||||
{ rate: 12, label: '12%', treatment: 'reduced_12' },
|
||||
{ rate: 6, label: '6%', treatment: 'reduced_6' },
|
||||
{ rate: 0, label: '0% (momsfritt)', treatment: 'exempt' },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a numeric VAT rate to a VatTreatment.
|
||||
*/
|
||||
export function getVatTreatmentForRate(rate: number): VatTreatment {
|
||||
switch (rate) {
|
||||
case 25:
|
||||
return 'standard_25'
|
||||
case 12:
|
||||
return 'reduced_12'
|
||||
case 6:
|
||||
return 'reduced_6'
|
||||
case 0:
|
||||
return 'exempt'
|
||||
default:
|
||||
return 'standard_25'
|
||||
}
|
||||
}
|
||||
|
||||
export interface VatRule {
|
||||
treatment: VatTreatment
|
||||
rate: number
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* Tests for the bank reconciliation engine.
|
||||
*
|
||||
* Covers: matching algorithm (4 passes), direction compatibility,
|
||||
* greedy assignment, dry run, manual link/unlink, status calculation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
tryReconcileTransaction,
|
||||
runReconciliation,
|
||||
manualLink,
|
||||
unlinkReconciliation,
|
||||
} from '../bank-reconciliation'
|
||||
import type { UnlinkedGLLine } from '../bank-reconciliation'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
vi.mock('@/lib/supabase/server')
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
function makeGLLine(overrides: Partial<UnlinkedGLLine> = {}): UnlinkedGLLine {
|
||||
return {
|
||||
line_id: `line-${Math.random().toString(36).slice(2, 8)}`,
|
||||
journal_entry_id: `je-${Math.random().toString(36).slice(2, 8)}`,
|
||||
debit_amount: 0,
|
||||
credit_amount: 0,
|
||||
line_description: null,
|
||||
entry_date: '2024-06-15',
|
||||
voucher_number: 1,
|
||||
voucher_series: 'A',
|
||||
entry_description: 'Test entry',
|
||||
source_type: 'import',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// tryReconcileTransaction — in-memory matching
|
||||
// ============================================================
|
||||
|
||||
describe('tryReconcileTransaction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pass 1: Exact amount + exact date
|
||||
// ------------------------------------------------------------------
|
||||
it('matches income transaction with exact amount and date (debit on 1930)', () => {
|
||||
const tx = makeTransaction({ amount: 5000, date: '2024-06-15', currency: 'SEK' })
|
||||
const line = makeGLLine({ debit_amount: 5000, entry_date: '2024-06-15' })
|
||||
|
||||
const result = tryReconcileTransaction(tx, [line])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.method).toBe('auto_exact')
|
||||
expect(result!.confidence).toBe(0.95)
|
||||
})
|
||||
|
||||
it('matches expense transaction with exact amount and date (credit on 1930)', () => {
|
||||
const tx = makeTransaction({ amount: -1200, date: '2024-06-15', currency: 'SEK' })
|
||||
const line = makeGLLine({ credit_amount: 1200, entry_date: '2024-06-15' })
|
||||
|
||||
const result = tryReconcileTransaction(tx, [line])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.method).toBe('auto_exact')
|
||||
expect(result!.confidence).toBe(0.95)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pass 2: Exact amount + reference match
|
||||
// ------------------------------------------------------------------
|
||||
it('matches on exact amount with reference match', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: 3500,
|
||||
date: '2024-06-20',
|
||||
currency: 'SEK',
|
||||
reference: '12345678',
|
||||
})
|
||||
const line = makeGLLine({
|
||||
debit_amount: 3500,
|
||||
entry_date: '2024-06-10',
|
||||
entry_description: 'Payment ref 12345678',
|
||||
})
|
||||
|
||||
const result = tryReconcileTransaction(tx, [line])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.method).toBe('auto_reference')
|
||||
expect(result!.confidence).toBe(0.90)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pass 3: Exact amount + date within ±3 days
|
||||
// ------------------------------------------------------------------
|
||||
it('matches on exact amount within 3 day date range', () => {
|
||||
const tx = makeTransaction({ amount: 750, date: '2024-06-17', currency: 'SEK' })
|
||||
const line = makeGLLine({ debit_amount: 750, entry_date: '2024-06-15' })
|
||||
|
||||
const result = tryReconcileTransaction(tx, [line])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.method).toBe('auto_date_range')
|
||||
expect(result!.confidence).toBe(0.85)
|
||||
})
|
||||
|
||||
it('does not match when date difference exceeds 3 days', () => {
|
||||
const tx = makeTransaction({ amount: 750, date: '2024-06-20', currency: 'SEK' })
|
||||
const line = makeGLLine({ debit_amount: 750, entry_date: '2024-06-15' })
|
||||
|
||||
const result = tryReconcileTransaction(tx, [line])
|
||||
|
||||
// 5 days apart, no reference, different dates — no match
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pass 4: Fuzzy amount (±0.01) + exact date
|
||||
// ------------------------------------------------------------------
|
||||
it('matches on fuzzy amount with exact date', () => {
|
||||
const tx = makeTransaction({ amount: -999.99, date: '2024-06-15', currency: 'SEK' })
|
||||
const line = makeGLLine({ credit_amount: 1000, entry_date: '2024-06-15' })
|
||||
|
||||
const result = tryReconcileTransaction(tx, [line])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.method).toBe('auto_fuzzy')
|
||||
expect(result!.confidence).toBe(0.75)
|
||||
})
|
||||
|
||||
it('does not match when fuzzy amount exceeds 0.01 tolerance', () => {
|
||||
const tx = makeTransaction({ amount: -999.98, date: '2024-06-15', currency: 'SEK' })
|
||||
const line = makeGLLine({ credit_amount: 1000, entry_date: '2024-06-15' })
|
||||
|
||||
const result = tryReconcileTransaction(tx, [line])
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Direction mismatch rejection
|
||||
// ------------------------------------------------------------------
|
||||
it('rejects income transaction against credit line (direction mismatch)', () => {
|
||||
const tx = makeTransaction({ amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
||||
const line = makeGLLine({ credit_amount: 1000, entry_date: '2024-06-15' })
|
||||
|
||||
const result = tryReconcileTransaction(tx, [line])
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects expense transaction against debit line (direction mismatch)', () => {
|
||||
const tx = makeTransaction({ amount: -500, date: '2024-06-15', currency: 'SEK' })
|
||||
const line = makeGLLine({ debit_amount: 500, entry_date: '2024-06-15' })
|
||||
|
||||
const result = tryReconcileTransaction(tx, [line])
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Non-SEK transactions
|
||||
// ------------------------------------------------------------------
|
||||
it('skips non-SEK transactions', () => {
|
||||
const tx = makeTransaction({ amount: 100, date: '2024-06-15', currency: 'EUR' })
|
||||
const line = makeGLLine({ debit_amount: 100, entry_date: '2024-06-15' })
|
||||
|
||||
const result = tryReconcileTransaction(tx, [line])
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Empty pool
|
||||
// ------------------------------------------------------------------
|
||||
it('returns null for empty GL line pool', () => {
|
||||
const tx = makeTransaction({ amount: 100, date: '2024-06-15', currency: 'SEK' })
|
||||
|
||||
const result = tryReconcileTransaction(tx, [])
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Priority: highest confidence wins
|
||||
// ------------------------------------------------------------------
|
||||
it('prefers exact match over date range match', () => {
|
||||
const tx = makeTransaction({ amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
||||
const exactLine = makeGLLine({
|
||||
line_id: 'exact',
|
||||
debit_amount: 1000,
|
||||
entry_date: '2024-06-15',
|
||||
})
|
||||
const rangeLine = makeGLLine({
|
||||
line_id: 'range',
|
||||
debit_amount: 1000,
|
||||
entry_date: '2024-06-14',
|
||||
})
|
||||
|
||||
const result = tryReconcileTransaction(tx, [rangeLine, exactLine])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.glLine.line_id).toBe('exact')
|
||||
expect(result!.method).toBe('auto_exact')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// No double-matching when using greedy algorithm
|
||||
// ------------------------------------------------------------------
|
||||
it('each GL line can only match once in a pool', () => {
|
||||
const tx1 = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
||||
const tx2 = makeTransaction({ id: 'tx-2', amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
||||
const line = makeGLLine({ debit_amount: 1000, entry_date: '2024-06-15' })
|
||||
|
||||
// First transaction matches
|
||||
const result1 = tryReconcileTransaction(tx1, [line])
|
||||
expect(result1).not.toBeNull()
|
||||
|
||||
// Second transaction against the same single line also matches individually
|
||||
const result2 = tryReconcileTransaction(tx2, [line])
|
||||
expect(result2).not.toBeNull()
|
||||
|
||||
// But in the batch reconciliation (greedyMatch), only one would be assigned
|
||||
// This is tested in runReconciliation tests
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// runReconciliation — batch matching with DB calls
|
||||
// ============================================================
|
||||
|
||||
describe('runReconciliation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
function createQueueMockSupabase() {
|
||||
const resultQueue: { data: unknown; error: unknown }[] = []
|
||||
|
||||
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
|
||||
for (const r of results) {
|
||||
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
|
||||
}
|
||||
}
|
||||
|
||||
const buildChain = (): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
const next = resultQueue.shift() ?? { data: null, error: null }
|
||||
return (resolve: (v: unknown) => void) => resolve(next)
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain()
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => buildChain()),
|
||||
rpc: vi.fn().mockImplementation(() => buildChain()),
|
||||
}
|
||||
|
||||
return { supabase, enqueue }
|
||||
}
|
||||
|
||||
it('returns empty matches when no unmatched transactions exist', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
// RPC: get_unlinked_1930_lines returns empty
|
||||
enqueue({ data: [] })
|
||||
// from('transactions').select — unmatched
|
||||
enqueue({ data: [] })
|
||||
|
||||
const result = await runReconciliation(supabase as never, 'user-1')
|
||||
|
||||
expect(result.matches).toEqual([])
|
||||
expect(result.applied).toBe(0)
|
||||
})
|
||||
|
||||
it('dry run returns matches without applying', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
||||
const glLine: UnlinkedGLLine = makeGLLine({
|
||||
line_id: 'line-1',
|
||||
journal_entry_id: 'je-1',
|
||||
debit_amount: 1000,
|
||||
entry_date: '2024-06-15',
|
||||
})
|
||||
|
||||
// RPC returns GL lines
|
||||
enqueue({ data: [glLine] })
|
||||
// from('transactions') returns unmatched transactions
|
||||
enqueue({ data: [tx] })
|
||||
|
||||
const result = await runReconciliation(supabase as never, 'user-1', { dryRun: true })
|
||||
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(result.matches[0].method).toBe('auto_exact')
|
||||
expect(result.applied).toBe(0)
|
||||
})
|
||||
|
||||
it('applies matches when not dry run', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: -500, date: '2024-06-15', currency: 'SEK' })
|
||||
const glLine: UnlinkedGLLine = makeGLLine({
|
||||
line_id: 'line-1',
|
||||
journal_entry_id: 'je-1',
|
||||
credit_amount: 500,
|
||||
entry_date: '2024-06-15',
|
||||
})
|
||||
|
||||
// RPC returns GL lines
|
||||
enqueue({ data: [glLine] })
|
||||
// from('transactions') returns unmatched transactions
|
||||
enqueue({ data: [tx] })
|
||||
// Update transaction with link
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const result = await runReconciliation(supabase as never, 'user-1', { dryRun: false })
|
||||
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(result.applied).toBe(1)
|
||||
expect(result.errors).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// manualLink
|
||||
// ============================================================
|
||||
|
||||
describe('manualLink', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
function createQueueMockSupabase() {
|
||||
const resultQueue: { data: unknown; error: unknown }[] = []
|
||||
|
||||
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
|
||||
for (const r of results) {
|
||||
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
|
||||
}
|
||||
}
|
||||
|
||||
const buildChain = (): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
const next = resultQueue.shift() ?? { data: null, error: null }
|
||||
return (resolve: (v: unknown) => void) => resolve(next)
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain()
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => buildChain()),
|
||||
rpc: vi.fn().mockImplementation(() => buildChain()),
|
||||
}
|
||||
|
||||
return { supabase, enqueue }
|
||||
}
|
||||
|
||||
it('rejects when transaction not found', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
// Transaction query returns null
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const result = await manualLink(supabase as never, 'user-1', 'tx-1', 'je-1')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Transaction not found')
|
||||
})
|
||||
|
||||
it('rejects when transaction is already linked', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: 'je-existing' })
|
||||
|
||||
// Transaction found but already linked
|
||||
enqueue({ data: tx })
|
||||
|
||||
const result = await manualLink(supabase as never, 'user-1', 'tx-1', 'je-1')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Transaction is already linked to a journal entry')
|
||||
})
|
||||
|
||||
it('rejects when journal entry has no 1930 line', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
||||
|
||||
// Transaction found
|
||||
enqueue({ data: tx })
|
||||
// Journal entry found
|
||||
enqueue({ data: { id: 'je-1', user_id: 'user-1', status: 'posted' } })
|
||||
// No 1930 lines
|
||||
enqueue({ data: [] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'user-1', 'tx-1', 'je-1')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Journal entry has no line on account 1930')
|
||||
})
|
||||
|
||||
it('succeeds when all validations pass', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
||||
|
||||
// Transaction found
|
||||
enqueue({ data: tx })
|
||||
// Journal entry found
|
||||
enqueue({ data: { id: 'je-1', user_id: 'user-1', status: 'posted' } })
|
||||
// 1930 line exists
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0 }] })
|
||||
// No existing link
|
||||
enqueue({ data: null, error: null })
|
||||
// Update succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const result = await manualLink(supabase as never, 'user-1', 'tx-1', 'je-1')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// unlinkReconciliation
|
||||
// ============================================================
|
||||
|
||||
describe('unlinkReconciliation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
function createQueueMockSupabase() {
|
||||
const resultQueue: { data: unknown; error: unknown }[] = []
|
||||
|
||||
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
|
||||
for (const r of results) {
|
||||
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
|
||||
}
|
||||
}
|
||||
|
||||
const buildChain = (): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
const next = resultQueue.shift() ?? { data: null, error: null }
|
||||
return (resolve: (v: unknown) => void) => resolve(next)
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain()
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => buildChain()),
|
||||
rpc: vi.fn().mockImplementation(() => buildChain()),
|
||||
}
|
||||
|
||||
return { supabase, enqueue }
|
||||
}
|
||||
|
||||
it('rejects when transaction has no reconciliation_method (categorization entry)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
// Transaction found with journal_entry_id but no reconciliation_method
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'tx-1',
|
||||
journal_entry_id: 'je-1',
|
||||
reconciliation_method: null,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await unlinkReconciliation(supabase as never, 'user-1', 'tx-1')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Cannot unlink')
|
||||
})
|
||||
|
||||
it('succeeds when reconciliation_method is set', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
// Transaction found with reconciliation_method
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'tx-1',
|
||||
journal_entry_id: 'je-1',
|
||||
reconciliation_method: 'auto_exact',
|
||||
},
|
||||
})
|
||||
// Update succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const result = await unlinkReconciliation(supabase as never, 'user-1', 'tx-1')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,548 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Transaction, ReconciliationMethod } from '@/types'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
/** A posted journal entry line on account 1930 not yet linked to any transaction */
|
||||
export interface UnlinkedGLLine {
|
||||
line_id: string
|
||||
journal_entry_id: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
line_description: string | null
|
||||
entry_date: string
|
||||
voucher_number: number
|
||||
voucher_series: string
|
||||
entry_description: string
|
||||
source_type: string
|
||||
}
|
||||
|
||||
export interface ReconciliationMatch {
|
||||
transaction: Transaction
|
||||
glLine: UnlinkedGLLine
|
||||
method: ReconciliationMethod
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface ReconciliationRunResult {
|
||||
matches: ReconciliationMatch[]
|
||||
applied: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export interface ReconciliationStatus {
|
||||
bank_transaction_total: number
|
||||
gl_1930_balance: number
|
||||
difference: number
|
||||
is_reconciled: boolean
|
||||
matched_count: number
|
||||
unmatched_transaction_count: number
|
||||
unmatched_gl_line_count: number
|
||||
}
|
||||
|
||||
export interface ReconciliationOptions {
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// In-memory matching: single transaction against GL line pool
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Try to reconcile a single transaction against a pool of unlinked GL lines.
|
||||
* Returns the best match or null. Purely in-memory, no DB calls.
|
||||
*
|
||||
* Only reconciles SEK transactions.
|
||||
*/
|
||||
export function tryReconcileTransaction(
|
||||
transaction: Transaction,
|
||||
glLines: UnlinkedGLLine[]
|
||||
): ReconciliationMatch | null {
|
||||
if (transaction.currency !== 'SEK') return null
|
||||
if (glLines.length === 0) return null
|
||||
|
||||
const txAmount = transaction.amount
|
||||
const txDate = transaction.date
|
||||
const txDescription = (transaction.description || '').toLowerCase()
|
||||
const txReference = (transaction.reference || '').toLowerCase()
|
||||
|
||||
let bestMatch: ReconciliationMatch | null = null
|
||||
|
||||
for (const line of glLines) {
|
||||
const lineAmount = getDirectionalAmount(line)
|
||||
if (!isDirectionCompatible(txAmount, line)) continue
|
||||
|
||||
const amountMatches = Math.abs(Math.abs(txAmount) - Math.abs(lineAmount)) < 0.005
|
||||
const fuzzyAmountMatches = Math.abs(Math.abs(txAmount) - Math.abs(lineAmount)) <= 0.01
|
||||
const exactDateMatch = txDate === line.entry_date
|
||||
const dateWithinRange = isDateWithinRange(txDate, line.entry_date, 3)
|
||||
const referenceMatch = hasReferenceMatch(txDescription, txReference, line)
|
||||
|
||||
let method: ReconciliationMethod | null = null
|
||||
let confidence = 0
|
||||
|
||||
// Pass 1: Exact amount + exact date
|
||||
if (amountMatches && exactDateMatch) {
|
||||
method = 'auto_exact'
|
||||
confidence = 0.95
|
||||
}
|
||||
// Pass 2: Exact amount + reference match
|
||||
else if (amountMatches && referenceMatch) {
|
||||
method = 'auto_reference'
|
||||
confidence = 0.90
|
||||
}
|
||||
// Pass 3: Exact amount + date within ±3 days
|
||||
else if (amountMatches && dateWithinRange) {
|
||||
method = 'auto_date_range'
|
||||
confidence = 0.85
|
||||
}
|
||||
// Pass 4: Fuzzy amount (±0.01) + exact date
|
||||
else if (fuzzyAmountMatches && exactDateMatch) {
|
||||
method = 'auto_fuzzy'
|
||||
confidence = 0.75
|
||||
}
|
||||
|
||||
if (method && confidence > (bestMatch?.confidence ?? 0)) {
|
||||
bestMatch = { transaction, glLine: line, method, confidence }
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Batch reconciliation
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Run auto-reconciliation for all unmatched transactions.
|
||||
* Fetches data, runs 4-pass matching, optionally applies matches.
|
||||
*/
|
||||
export async function runReconciliation(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
options: ReconciliationOptions = {}
|
||||
): Promise<ReconciliationRunResult> {
|
||||
const { dateFrom, dateTo, dryRun = false } = options
|
||||
|
||||
// Fetch unlinked GL lines via RPC
|
||||
const glLines = await fetchUnlinkedGLLines(supabase, userId, dateFrom, dateTo)
|
||||
|
||||
// Fetch unmatched transactions
|
||||
let query = supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.is('journal_entry_id', null)
|
||||
.eq('currency', 'SEK')
|
||||
|
||||
if (dateFrom) query = query.gte('date', dateFrom)
|
||||
if (dateTo) query = query.lte('date', dateTo)
|
||||
|
||||
const { data: transactions } = await query
|
||||
|
||||
if (!transactions || transactions.length === 0 || glLines.length === 0) {
|
||||
return { matches: [], applied: 0, errors: 0 }
|
||||
}
|
||||
|
||||
// Run greedy matching, highest confidence first
|
||||
const matches = greedyMatch(transactions as Transaction[], glLines)
|
||||
|
||||
if (dryRun) {
|
||||
return { matches, applied: 0, errors: 0 }
|
||||
}
|
||||
|
||||
// Apply matches
|
||||
let applied = 0
|
||||
let errors = 0
|
||||
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: match.glLine.journal_entry_id,
|
||||
reconciliation_method: match.method,
|
||||
is_business: true,
|
||||
})
|
||||
.eq('id', match.transaction.id)
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (error) {
|
||||
errors++
|
||||
} else {
|
||||
applied++
|
||||
try {
|
||||
eventBus.emit({
|
||||
type: 'transaction.reconciled',
|
||||
payload: {
|
||||
transaction: match.transaction,
|
||||
journalEntryId: match.glLine.journal_entry_id,
|
||||
method: match.method,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Event emission is non-critical
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errors++
|
||||
}
|
||||
}
|
||||
|
||||
return { matches, applied, errors }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Reconciliation status
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Compare bank transaction totals vs GL 1930 balance.
|
||||
*/
|
||||
export async function getReconciliationStatus(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string
|
||||
): Promise<ReconciliationStatus> {
|
||||
// Get all transactions in range
|
||||
let txQuery = supabase
|
||||
.from('transactions')
|
||||
.select('amount, journal_entry_id, reconciliation_method')
|
||||
.eq('user_id', userId)
|
||||
.eq('currency', 'SEK')
|
||||
|
||||
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
|
||||
if (dateTo) txQuery = txQuery.lte('date', dateTo)
|
||||
|
||||
const { data: transactions } = await txQuery
|
||||
|
||||
// Get GL 1930 lines (all, not just unlinked)
|
||||
let glQuery = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('debit_amount, credit_amount, journal_entries!inner(user_id, entry_date, status)')
|
||||
.eq('account_number', '1930')
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
|
||||
if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom)
|
||||
if (dateTo) glQuery = glQuery.lte('journal_entries.entry_date', dateTo)
|
||||
|
||||
const { data: glLines } = await glQuery
|
||||
|
||||
// Calculate totals
|
||||
const bankTotal = (transactions || []).reduce(
|
||||
(sum, tx) => sum + (Number(tx.amount) || 0),
|
||||
0
|
||||
)
|
||||
|
||||
const glBalance = (glLines || []).reduce(
|
||||
(sum, line) => sum + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
|
||||
0
|
||||
)
|
||||
|
||||
const matchedCount = (transactions || []).filter(
|
||||
(tx) => tx.journal_entry_id !== null
|
||||
).length
|
||||
|
||||
const unmatchedTransactionCount = (transactions || []).filter(
|
||||
(tx) => tx.journal_entry_id === null
|
||||
).length
|
||||
|
||||
// Unlinked GL lines count
|
||||
const unlinkedLines = await fetchUnlinkedGLLines(supabase, userId, dateFrom, dateTo)
|
||||
|
||||
const difference = Math.round((bankTotal - glBalance) * 100) / 100
|
||||
|
||||
return {
|
||||
bank_transaction_total: Math.round(bankTotal * 100) / 100,
|
||||
gl_1930_balance: Math.round(glBalance * 100) / 100,
|
||||
difference,
|
||||
is_reconciled: Math.abs(difference) < 0.01,
|
||||
matched_count: matchedCount,
|
||||
unmatched_transaction_count: unmatchedTransactionCount,
|
||||
unmatched_gl_line_count: unlinkedLines.length,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Manual link/unlink
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Manually link a transaction to an existing journal entry.
|
||||
* Validates that the journal entry has a 1930 line and amounts are directionally compatible.
|
||||
*/
|
||||
export async function manualLink(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
transactionId: string,
|
||||
journalEntryId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Fetch transaction
|
||||
const { data: tx, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', transactionId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (txError || !tx) {
|
||||
return { success: false, error: 'Transaction not found' }
|
||||
}
|
||||
|
||||
if (tx.journal_entry_id) {
|
||||
return { success: false, error: 'Transaction is already linked to a journal entry' }
|
||||
}
|
||||
|
||||
// Fetch journal entry + verify it has a 1930 line
|
||||
const { data: entry, error: entryError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, user_id, status')
|
||||
.eq('id', journalEntryId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (entryError || !entry) {
|
||||
return { success: false, error: 'Journal entry not found' }
|
||||
}
|
||||
|
||||
if (entry.status !== 'posted') {
|
||||
return { success: false, error: 'Journal entry is not posted' }
|
||||
}
|
||||
|
||||
// Check for 1930 line
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('debit_amount, credit_amount')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
.eq('account_number', '1930')
|
||||
|
||||
if (!lines || lines.length === 0) {
|
||||
return { success: false, error: 'Journal entry has no line on account 1930' }
|
||||
}
|
||||
|
||||
// Check that no other transaction is already linked to this entry
|
||||
const { data: existingLink } = await supabase
|
||||
.from('transactions')
|
||||
.select('id')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (existingLink) {
|
||||
return { success: false, error: 'Another transaction is already linked to this journal entry' }
|
||||
}
|
||||
|
||||
// Apply link
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: journalEntryId,
|
||||
reconciliation_method: 'manual' as ReconciliationMethod,
|
||||
is_business: true,
|
||||
})
|
||||
.eq('id', transactionId)
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (updateError) {
|
||||
return { success: false, error: 'Failed to link transaction' }
|
||||
}
|
||||
|
||||
try {
|
||||
eventBus.emit({
|
||||
type: 'transaction.reconciled',
|
||||
payload: {
|
||||
transaction: tx as Transaction,
|
||||
journalEntryId,
|
||||
method: 'manual' as ReconciliationMethod,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a reconciliation link.
|
||||
* Only allowed when reconciliation_method IS NOT NULL (prevents unlinking categorization-created entries).
|
||||
*/
|
||||
export async function unlinkReconciliation(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
transactionId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Fetch transaction
|
||||
const { data: tx, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, journal_entry_id, reconciliation_method')
|
||||
.eq('id', transactionId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (txError || !tx) {
|
||||
return { success: false, error: 'Transaction not found' }
|
||||
}
|
||||
|
||||
if (!tx.journal_entry_id) {
|
||||
return { success: false, error: 'Transaction is not linked to any journal entry' }
|
||||
}
|
||||
|
||||
if (!tx.reconciliation_method) {
|
||||
return { success: false, error: 'Cannot unlink a categorization-created entry. Use storno to reverse it instead.' }
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: null,
|
||||
reconciliation_method: null,
|
||||
is_business: null,
|
||||
})
|
||||
.eq('id', transactionId)
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (updateError) {
|
||||
return { success: false, error: 'Failed to unlink transaction' }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
/** Fetch unlinked 1930 GL lines via the RPC function */
|
||||
export async function fetchUnlinkedGLLines(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string
|
||||
): Promise<UnlinkedGLLine[]> {
|
||||
const { data, error } = await supabase.rpc('get_unlinked_1930_lines', {
|
||||
p_user_id: userId,
|
||||
p_date_from: dateFrom || null,
|
||||
p_date_to: dateTo || null,
|
||||
})
|
||||
|
||||
if (error || !data) return []
|
||||
return data as UnlinkedGLLine[]
|
||||
}
|
||||
|
||||
/** Get the net amount from a GL line (positive for debit, negative for credit) */
|
||||
function getDirectionalAmount(line: UnlinkedGLLine): number {
|
||||
if (line.debit_amount > 0) return line.debit_amount
|
||||
if (line.credit_amount > 0) return -line.credit_amount
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Check direction compatibility:
|
||||
* - Income (tx.amount > 0) matches debit on 1930 (money coming in to bank)
|
||||
* - Expense (tx.amount < 0) matches credit on 1930 (money going out of bank)
|
||||
*/
|
||||
function isDirectionCompatible(txAmount: number, line: UnlinkedGLLine): boolean {
|
||||
if (txAmount > 0 && line.debit_amount > 0) return true
|
||||
if (txAmount < 0 && line.credit_amount > 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Check if two dates are within ±dayRange of each other */
|
||||
function isDateWithinRange(date1: string, date2: string, dayRange: number): boolean {
|
||||
const d1 = new Date(date1)
|
||||
const d2 = new Date(date2)
|
||||
const diffMs = Math.abs(d1.getTime() - d2.getTime())
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24)
|
||||
return diffDays <= dayRange
|
||||
}
|
||||
|
||||
/** Check if transaction description/reference matches the GL line description */
|
||||
function hasReferenceMatch(
|
||||
txDescription: string,
|
||||
txReference: string,
|
||||
line: UnlinkedGLLine
|
||||
): boolean {
|
||||
const lineDesc = (line.line_description || '').toLowerCase()
|
||||
const entryDesc = (line.entry_description || '').toLowerCase()
|
||||
|
||||
if (!txReference && !txDescription) return false
|
||||
|
||||
// Check OCR/reference number match
|
||||
if (txReference && txReference.length >= 4) {
|
||||
if (lineDesc.includes(txReference) || entryDesc.includes(txReference)) return true
|
||||
}
|
||||
|
||||
// Check description overlap (at least 8 chars matching substring)
|
||||
if (txDescription && txDescription.length >= 8) {
|
||||
if (lineDesc.includes(txDescription) || entryDesc.includes(txDescription)) return true
|
||||
if (txDescription.includes(lineDesc) && lineDesc.length >= 8) return true
|
||||
if (txDescription.includes(entryDesc) && entryDesc.length >= 8) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy matching: run 4-pass matching, each pass at a specific confidence level.
|
||||
* Track used GL lines and transactions to prevent double-matching.
|
||||
*/
|
||||
function greedyMatch(
|
||||
transactions: Transaction[],
|
||||
glLines: UnlinkedGLLine[]
|
||||
): ReconciliationMatch[] {
|
||||
const usedTransactions = new Set<string>()
|
||||
const usedGLLines = new Set<string>()
|
||||
const allMatches: ReconciliationMatch[] = []
|
||||
|
||||
// Collect all candidate matches with confidence
|
||||
const candidates: ReconciliationMatch[] = []
|
||||
|
||||
for (const tx of transactions) {
|
||||
if (tx.currency !== 'SEK') continue
|
||||
|
||||
for (const line of glLines) {
|
||||
const match = tryReconcileTransaction(tx, [line])
|
||||
if (match) {
|
||||
candidates.push(match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by confidence descending, then by date proximity
|
||||
candidates.sort((a, b) => {
|
||||
if (b.confidence !== a.confidence) return b.confidence - a.confidence
|
||||
// Prefer closer dates
|
||||
const dateDistA = Math.abs(
|
||||
new Date(a.transaction.date).getTime() - new Date(a.glLine.entry_date).getTime()
|
||||
)
|
||||
const dateDistB = Math.abs(
|
||||
new Date(b.transaction.date).getTime() - new Date(b.glLine.entry_date).getTime()
|
||||
)
|
||||
return dateDistA - dateDistB
|
||||
})
|
||||
|
||||
// Greedily assign matches
|
||||
for (const candidate of candidates) {
|
||||
const txId = candidate.transaction.id
|
||||
const lineId = candidate.glLine.line_id
|
||||
|
||||
if (usedTransactions.has(txId) || usedGLLines.has(lineId)) continue
|
||||
|
||||
usedTransactions.add(txId)
|
||||
usedGLLines.add(lineId)
|
||||
allMatches.push(candidate)
|
||||
}
|
||||
|
||||
return allMatches
|
||||
}
|
||||
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'lt', 'order']) {
|
||||
for (const m of ['select', 'eq', 'in', 'lt', 'order', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
@@ -51,7 +51,7 @@ describe('generateGeneralLedger', () => {
|
||||
it('returns empty report when no entries in period', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods.single()
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
// 1: journal_entries (empty)
|
||||
{ data: [], error: null },
|
||||
]
|
||||
@@ -64,7 +64,7 @@ describe('generateGeneralLedger', () => {
|
||||
it('groups lines by account with correct totals and running balance', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods.single()
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
// 1: journal_entries for this period
|
||||
{
|
||||
data: [
|
||||
@@ -122,7 +122,7 @@ describe('generateGeneralLedger', () => {
|
||||
it('computes opening balance from prior period entries', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods.single()
|
||||
{ data: { start_date: '2025-01-01', end_date: '2025-12-31' }, error: null },
|
||||
{ data: { period_start: '2025-01-01', period_end: '2025-12-31' }, error: null },
|
||||
// 1: journal_entries for this period
|
||||
{
|
||||
data: [
|
||||
@@ -168,7 +168,7 @@ describe('generateGeneralLedger', () => {
|
||||
it('filters accounts by account_from and account_to', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods.single()
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
// 1: journal_entries
|
||||
{
|
||||
data: [
|
||||
@@ -200,7 +200,7 @@ describe('generateGeneralLedger', () => {
|
||||
it('sorts lines within account by date then voucher number', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods.single()
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
// 1: entries out of order
|
||||
{
|
||||
data: [
|
||||
@@ -236,7 +236,7 @@ describe('generateGeneralLedger', () => {
|
||||
|
||||
it('uses Math.round for monetary precision', async () => {
|
||||
results = [
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Precision', source_type: 'manual' },
|
||||
|
||||
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'order']) {
|
||||
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
@@ -49,7 +49,7 @@ describe('generateJournalRegister', () => {
|
||||
|
||||
it('returns empty report when no entries in period', async () => {
|
||||
results = [
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('generateJournalRegister', () => {
|
||||
it('produces entries in registration order with correct totals', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods.single()
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
// 1: journal_entries (already ordered by series/number)
|
||||
{
|
||||
data: [
|
||||
@@ -117,7 +117,7 @@ describe('generateJournalRegister', () => {
|
||||
|
||||
it('includes reversed entries with correct status', async () => {
|
||||
results = [
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Original', source_type: 'manual', status: 'reversed' },
|
||||
@@ -146,7 +146,7 @@ describe('generateJournalRegister', () => {
|
||||
|
||||
it('resolves account names from chart_of_accounts', async () => {
|
||||
results = [
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual', status: 'posted' },
|
||||
@@ -180,7 +180,7 @@ describe('generateJournalRegister', () => {
|
||||
|
||||
it('defaults voucher_series to A when null', async () => {
|
||||
results = [
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: null, description: 'No series', source_type: 'manual', status: 'posted' },
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockSupabase } from '@/tests/helpers'
|
||||
|
||||
// Mock Supabase server client
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(() => supabase),
|
||||
}))
|
||||
|
||||
import { generateMonthlyBreakdown } from '../monthly-breakdown'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('generateMonthlyBreakdown', () => {
|
||||
it('returns empty months when no fiscal period found', async () => {
|
||||
mockResult({ data: null, error: { message: 'not found' } })
|
||||
|
||||
const result = await generateMonthlyBreakdown('user-1', 'period-1')
|
||||
expect(result.months).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty months when no journal entries exist', async () => {
|
||||
// First call: fiscal period
|
||||
mockResult({
|
||||
data: { period_start: '2024-01-01', period_end: '2024-12-31' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
// We need two sequential calls with different results.
|
||||
// The proxy-based mock returns the same result for all calls,
|
||||
// so we re-mock after the first await completes.
|
||||
// Instead, test that an empty lines result returns initialized months.
|
||||
|
||||
// For this test, override at the supabase.from level to return different chains
|
||||
let callCount = 0
|
||||
supabase.from.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
// fiscal_periods query
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
single: () =>
|
||||
Promise.resolve({
|
||||
data: { period_start: '2024-01-01', period_end: '2024-12-31' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
// journal_entry_lines query
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
eq: () =>
|
||||
Promise.resolve({
|
||||
data: [],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await generateMonthlyBreakdown('user-1', 'period-1')
|
||||
expect(result.months.length).toBe(12)
|
||||
expect(result.months[0].label).toBe('Jan')
|
||||
expect(result.months[0].income).toBe(0)
|
||||
expect(result.months[0].expenses).toBe(0)
|
||||
expect(result.months[11].label).toBe('Dec')
|
||||
})
|
||||
|
||||
it('correctly classifies revenue (class 3) and expense (class 4-7) accounts', async () => {
|
||||
let callCount = 0
|
||||
supabase.from.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
single: () =>
|
||||
Promise.resolve({
|
||||
data: { period_start: '2024-01-01', period_end: '2024-03-31' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
eq: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
account_number: '3001',
|
||||
debit: 0,
|
||||
credit: 10000,
|
||||
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '5010',
|
||||
debit: 3000,
|
||||
credit: 0,
|
||||
journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '3001',
|
||||
debit: 0,
|
||||
credit: 5000,
|
||||
journal_entry: { entry_date: '2024-02-10', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '6200',
|
||||
debit: 1500,
|
||||
credit: 0,
|
||||
journal_entry: { entry_date: '2024-02-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await generateMonthlyBreakdown('user-1', 'period-1')
|
||||
|
||||
// January
|
||||
const jan = result.months.find((m) => m.label === 'Jan')!
|
||||
expect(jan.income).toBe(10000)
|
||||
expect(jan.expenses).toBe(3000)
|
||||
expect(jan.net).toBe(7000)
|
||||
|
||||
// February
|
||||
const feb = result.months.find((m) => m.label === 'Feb')!
|
||||
expect(feb.income).toBe(5000)
|
||||
expect(feb.expenses).toBe(1500)
|
||||
expect(feb.net).toBe(3500)
|
||||
|
||||
// March should be zero
|
||||
const mar = result.months.find((m) => m.label === 'Mar')!
|
||||
expect(mar.income).toBe(0)
|
||||
expect(mar.expenses).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores non-revenue/expense accounts (class 1, 2, 8)', async () => {
|
||||
let callCount = 0
|
||||
supabase.from.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
single: () =>
|
||||
Promise.resolve({
|
||||
data: { period_start: '2024-01-01', period_end: '2024-01-31' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
eq: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
account_number: '1930',
|
||||
debit: 10000,
|
||||
credit: 0,
|
||||
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '2611',
|
||||
debit: 0,
|
||||
credit: 2500,
|
||||
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
{
|
||||
account_number: '8999',
|
||||
debit: 500,
|
||||
credit: 0,
|
||||
journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await generateMonthlyBreakdown('user-1', 'period-1')
|
||||
const jan = result.months.find((m) => m.label === 'Jan')!
|
||||
expect(jan.income).toBe(0)
|
||||
expect(jan.expenses).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'order']) {
|
||||
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
|
||||
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in']) {
|
||||
for (const m of ['select', 'eq', 'in', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
|
||||
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not']) {
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
formatPeriodLabel,
|
||||
getVatDeclarationSummary,
|
||||
calculateVatDeclaration,
|
||||
calculateVatDeclarationFromTaxCodes,
|
||||
} from '../vat-declaration'
|
||||
import type { VatDeclaration } from '@/types'
|
||||
|
||||
@@ -112,8 +111,8 @@ describe('getVatDeclarationSummary', () => {
|
||||
transactionCount: 10,
|
||||
breakdown: {
|
||||
invoices: { ruta05: 2500, ruta06: 0, ruta07: 0, ruta10: 10000, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0 },
|
||||
transactions: { ruta48: 800 },
|
||||
receipts: { ruta48: 200 },
|
||||
transactions: { ruta48: 1000 },
|
||||
receipts: { ruta48: 0 },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -155,17 +154,17 @@ describe('getVatDeclarationSummary', () => {
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Async tests — require Supabase mocks
|
||||
// Ledger-based VAT declaration tests
|
||||
//
|
||||
// Mock queue order per call:
|
||||
// [0] fetchAllRows: journal_entry_lines (VAT-relevant accounts)
|
||||
// [1] entry counts: journal_entries source_type
|
||||
// ============================================================
|
||||
|
||||
describe('calculateVatDeclaration', () => {
|
||||
it('returns all zeros when no data exists', async () => {
|
||||
it('returns all zeros when no ledger lines exist', async () => {
|
||||
results = [
|
||||
// 0: invoices
|
||||
{ data: [], error: null },
|
||||
// 1: transactions
|
||||
{ data: [], error: null },
|
||||
// 2: receipts
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
@@ -180,23 +179,20 @@ describe('calculateVatDeclaration', () => {
|
||||
expect(result.transactionCount).toBe(0)
|
||||
})
|
||||
|
||||
it('maps invoice VAT to correct rutor by moms_ruta', async () => {
|
||||
it('sums output VAT from 2611/2621/2631 credit balances', async () => {
|
||||
results = [
|
||||
// 0: invoices — various moms_ruta values
|
||||
{
|
||||
data: [
|
||||
{ subtotal: 10000, vat_amount: 2500, moms_ruta: '05', subtotal_sek: null, vat_amount_sek: null },
|
||||
{ subtotal: 5000, vat_amount: 600, moms_ruta: '06', subtotal_sek: null, vat_amount_sek: null },
|
||||
{ subtotal: 3000, vat_amount: 180, moms_ruta: '07', subtotal_sek: null, vat_amount_sek: null },
|
||||
{ subtotal: 8000, vat_amount: 0, moms_ruta: '39', subtotal_sek: null, vat_amount_sek: null },
|
||||
{ subtotal: 12000, vat_amount: 0, moms_ruta: '40', subtotal_sek: null, vat_amount_sek: null },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
|
||||
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
|
||||
{ account_number: '2631', debit_amount: 0, credit_amount: 180 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
|
||||
{ account_number: '3002', debit_amount: 0, credit_amount: 5000 },
|
||||
{ account_number: '3003', debit_amount: 0, credit_amount: 3000 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 1: transactions (none)
|
||||
{ data: [], error: null },
|
||||
// 2: receipts (none)
|
||||
{ data: [], error: null },
|
||||
{ data: [{ source_type: 'invoice_created' }, { source_type: 'invoice_created' }], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
@@ -207,224 +203,166 @@ describe('calculateVatDeclaration', () => {
|
||||
expect(result.rutor.ruta10).toBe(10000)
|
||||
expect(result.rutor.ruta11).toBe(5000)
|
||||
expect(result.rutor.ruta12).toBe(3000)
|
||||
expect(result.invoiceCount).toBe(2)
|
||||
})
|
||||
|
||||
it('sums input VAT from 2641 debit balance', async () => {
|
||||
results = [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '2641', debit_amount: 250, credit_amount: 0 },
|
||||
{ account_number: '2641', debit_amount: 120, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [{ source_type: 'bank_transaction' }, { source_type: 'bank_transaction' }], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta48).toBe(370)
|
||||
expect(result.transactionCount).toBe(2)
|
||||
})
|
||||
|
||||
it('includes calculated input VAT (2645) from EU reverse charge in ruta48', async () => {
|
||||
results = [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '2645', debit_amount: 500, credit_amount: 0 },
|
||||
{ account_number: '2641', debit_amount: 200, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
// Both 2641 and 2645 debit balances sum into ruta48
|
||||
expect(result.rutor.ruta48).toBe(700)
|
||||
})
|
||||
|
||||
it('maps EU/export revenue to ruta39/ruta40', async () => {
|
||||
results = [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '3308', debit_amount: 0, credit_amount: 8000 },
|
||||
{ account_number: '3305', debit_amount: 0, credit_amount: 12000 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta39).toBe(8000)
|
||||
expect(result.rutor.ruta40).toBe(12000)
|
||||
})
|
||||
|
||||
it('prefers subtotal_sek/vat_amount_sek for foreign currency invoices', async () => {
|
||||
it('handles credit notes as net reduction on revenue/VAT accounts', async () => {
|
||||
results = [
|
||||
// 0: invoices — foreign currency with SEK conversion
|
||||
{
|
||||
data: [
|
||||
{ subtotal: 1000, vat_amount: 250, moms_ruta: '05', subtotal_sek: 11000, vat_amount_sek: 2750 },
|
||||
// Invoice: C2611 2500, C3001 10000
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
|
||||
// Credit note reversal: D2611 625, D3001 2500
|
||||
{ account_number: '2611', debit_amount: 625, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 2500, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 1: transactions
|
||||
{ data: [], error: null },
|
||||
// 2: receipts
|
||||
{ data: [], error: null },
|
||||
{ data: [{ source_type: 'invoice_created' }, { source_type: 'credit_note' }], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
// Should use _sek values
|
||||
expect(result.rutor.ruta05).toBe(2750)
|
||||
expect(result.rutor.ruta10).toBe(11000)
|
||||
})
|
||||
|
||||
it('defaults to ruta05 when moms_ruta is null but VAT > 0', async () => {
|
||||
results = [
|
||||
// 0: invoices — null moms_ruta with VAT
|
||||
{
|
||||
data: [
|
||||
{ subtotal: 4000, vat_amount: 1000, moms_ruta: null, subtotal_sek: null, vat_amount_sek: null },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 1: transactions
|
||||
{ data: [], error: null },
|
||||
// 2: receipts
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(1000)
|
||||
expect(result.rutor.ruta10).toBe(4000)
|
||||
})
|
||||
|
||||
it('calculates input VAT from transaction categories', async () => {
|
||||
results = [
|
||||
// 0: invoices
|
||||
{ data: [], error: null },
|
||||
// 1: transactions — business expenses with categories
|
||||
{
|
||||
data: [
|
||||
// 25% category: expense_software, amount -1250 → VAT = 1250 * 0.25/1.25 = 250
|
||||
{ amount: -1250, amount_sek: null, is_business: true, category: 'expense_software' },
|
||||
// 12% category: expense_travel, amount -1120 → VAT = 1120 * 0.12/1.12 = 120
|
||||
{ amount: -1120, amount_sek: null, is_business: true, category: 'expense_travel' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 2: receipts
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
// 250 + 120 = 370
|
||||
expect(result.rutor.ruta48).toBe(370)
|
||||
})
|
||||
|
||||
it('sums VAT from confirmed receipts', async () => {
|
||||
results = [
|
||||
// 0: invoices
|
||||
{ data: [], error: null },
|
||||
// 1: transactions
|
||||
{ data: [], error: null },
|
||||
// 2: receipts — confirmed with vat_amount
|
||||
{
|
||||
data: [
|
||||
{ status: 'confirmed', vat_amount: 59.8 },
|
||||
{ status: 'confirmed', vat_amount: 125 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta48).toBe(184.8)
|
||||
// Net: 2500 - 625 = 1875 output VAT, 10000 - 2500 = 7500 revenue
|
||||
expect(result.rutor.ruta05).toBe(1875)
|
||||
expect(result.rutor.ruta10).toBe(7500)
|
||||
expect(result.invoiceCount).toBe(2)
|
||||
})
|
||||
|
||||
it('calculates ruta49 as output minus input VAT', async () => {
|
||||
results = [
|
||||
// 0: invoices — 25% VAT
|
||||
{
|
||||
data: [
|
||||
{ subtotal: 10000, vat_amount: 2500, moms_ruta: '05', subtotal_sek: null, vat_amount_sek: null },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 1: transactions — 25% expense
|
||||
{
|
||||
data: [
|
||||
{ amount: -1250, amount_sek: null, is_business: true, category: 'expense_software' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 2: receipts
|
||||
{
|
||||
data: [
|
||||
{ status: 'confirmed', vat_amount: 100 },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
|
||||
{ account_number: '2641', debit_amount: 350, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
// Output: 2500, Input: 250 + 100 = 350
|
||||
expect(result.rutor.ruta49).toBe(2150)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateVatDeclarationFromTaxCodes', () => {
|
||||
it('maps journal lines to boxes via tax codes', async () => {
|
||||
results = [
|
||||
// 0: tax_codes
|
||||
{
|
||||
data: [
|
||||
{
|
||||
code: 'MP1',
|
||||
user_id: null,
|
||||
moms_basis_boxes: ['10'],
|
||||
moms_tax_boxes: ['05'],
|
||||
moms_input_boxes: [],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 1: journal_entry_lines with tax_code
|
||||
{
|
||||
data: [
|
||||
{
|
||||
tax_code: 'MP1',
|
||||
debit_amount: 0,
|
||||
credit_amount: 2500,
|
||||
journal_entry_id: 'e1',
|
||||
journal_entries: { user_id: 'user-1', entry_date: '2024-01-15', status: 'posted' },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(2500)
|
||||
expect(result.rutor.ruta10).toBe(2500)
|
||||
expect(result.rutor.ruta48).toBe(350)
|
||||
expect(result.rutor.ruta49).toBe(2150) // 2500 - 350
|
||||
})
|
||||
|
||||
it('user tax codes override system codes', async () => {
|
||||
it('detects refund when input VAT exceeds output VAT', async () => {
|
||||
results = [
|
||||
// 0: tax_codes — system and user with same code
|
||||
{
|
||||
data: [
|
||||
{
|
||||
code: 'MP1',
|
||||
user_id: null,
|
||||
moms_basis_boxes: ['10'],
|
||||
moms_tax_boxes: ['05'],
|
||||
moms_input_boxes: [],
|
||||
},
|
||||
{
|
||||
code: 'MP1',
|
||||
user_id: 'user-1',
|
||||
moms_basis_boxes: ['11'],
|
||||
moms_tax_boxes: ['06'],
|
||||
moms_input_boxes: [],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 1: journal_entry_lines
|
||||
{
|
||||
data: [
|
||||
{
|
||||
tax_code: 'MP1',
|
||||
debit_amount: 0,
|
||||
credit_amount: 600,
|
||||
journal_entry_id: 'e1',
|
||||
journal_entries: { user_id: 'user-1', entry_date: '2024-01-15', status: 'posted' },
|
||||
},
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 500 },
|
||||
{ account_number: '2641', debit_amount: 3000, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
// User override maps to ruta06/ruta11 instead of ruta05/ruta10
|
||||
expect(result.rutor.ruta05).toBe(0)
|
||||
expect(result.rutor.ruta06).toBe(600)
|
||||
expect(result.rutor.ruta11).toBe(600)
|
||||
expect(result.rutor.ruta49).toBe(-2500) // 500 - 3000
|
||||
})
|
||||
|
||||
it('returns all zeros when no lines have tax codes', async () => {
|
||||
it('accepts accountingMethod parameter for backward compatibility', async () => {
|
||||
results = [
|
||||
// 0: tax_codes
|
||||
{ data: [], error: null },
|
||||
// 1: journal_entry_lines — empty
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(0)
|
||||
expect(result.rutor.ruta48).toBe(0)
|
||||
// Should not throw — parameter accepted but not used
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1, 'cash')
|
||||
expect(result.rutor.ruta49).toBe(0)
|
||||
})
|
||||
|
||||
it('handles all three VAT rates in a single period', async () => {
|
||||
results = [
|
||||
{
|
||||
data: [
|
||||
// 25% rate: 10,000 revenue, 2,500 VAT
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
|
||||
// 12% rate: 5,000 revenue, 600 VAT
|
||||
{ account_number: '3002', debit_amount: 0, credit_amount: 5000 },
|
||||
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
|
||||
// 6% rate: 3,000 revenue, 180 VAT
|
||||
{ account_number: '3003', debit_amount: 0, credit_amount: 3000 },
|
||||
{ account_number: '2631', debit_amount: 0, credit_amount: 180 },
|
||||
// Input VAT from purchases
|
||||
{ account_number: '2641', debit_amount: 1000, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'quarterly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(2500)
|
||||
expect(result.rutor.ruta06).toBe(600)
|
||||
expect(result.rutor.ruta07).toBe(180)
|
||||
expect(result.rutor.ruta10).toBe(10000)
|
||||
expect(result.rutor.ruta11).toBe(5000)
|
||||
expect(result.rutor.ruta12).toBe(3000)
|
||||
expect(result.rutor.ruta48).toBe(1000)
|
||||
// Output: 2500 + 600 + 180 = 3280, Input: 1000 → Pay: 2280
|
||||
expect(result.rutor.ruta49).toBe(2280)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +55,34 @@ export async function generateBalanceSheet(
|
||||
'credit' // Equity/liabilities have credit normal balance
|
||||
)
|
||||
|
||||
// Calculate period result from income/expense accounts (class 3-8)
|
||||
// Before year-end closing, this result lives on class 3-8 accounts and must
|
||||
// be included in equity for the balance sheet to balance.
|
||||
const incomeExpenseRows = rows.filter(
|
||||
(r) => r.account_class >= 3 && r.account_class <= 8
|
||||
)
|
||||
const periodResult = Math.round(
|
||||
incomeExpenseRows.reduce(
|
||||
(sum, r) => sum + (r.closing_credit - r.closing_debit),
|
||||
0
|
||||
) * 100
|
||||
) / 100
|
||||
|
||||
// Add period result as a synthetic section under equity if non-zero
|
||||
if (Math.abs(periodResult) > 0.005) {
|
||||
equityLiabilitySections.push({
|
||||
title: 'Årets resultat',
|
||||
rows: [
|
||||
{
|
||||
account_number: '',
|
||||
account_name: 'Beräknat resultat',
|
||||
amount: periodResult,
|
||||
},
|
||||
],
|
||||
subtotal: periodResult,
|
||||
})
|
||||
}
|
||||
|
||||
const totalAssets = assetSections.reduce((sum, s) => sum + s.subtotal, 0)
|
||||
const totalEquityLiabilities = equityLiabilitySections.reduce((sum, s) => sum + s.subtotal, 0)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
export interface GeneralLedgerLine {
|
||||
date: string
|
||||
@@ -41,7 +42,7 @@ export async function generateGeneralLedger(
|
||||
// Get fiscal period dates
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('start_date, end_date')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
@@ -59,7 +60,7 @@ export async function generateGeneralLedger(
|
||||
.in('status', ['posted', 'reversed'])
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
return { accounts: [], period: { start: period.start_date, end: period.end_date } }
|
||||
return { accounts: [], period: { start: period.period_start, end: period.period_end } }
|
||||
}
|
||||
|
||||
const entryIds = entries.map((e) => e.id)
|
||||
@@ -72,17 +73,20 @@ export async function generateGeneralLedger(
|
||||
.in('journal_entry_id', entryIds)
|
||||
|
||||
if (!lines) {
|
||||
return { accounts: [], period: { start: period.start_date, end: period.end_date } }
|
||||
return { accounts: [], period: { start: period.period_start, end: period.period_end } }
|
||||
}
|
||||
|
||||
// Fetch account names
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('user_id', userId)
|
||||
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('user_id', userId)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
const accountNameMap = new Map<string, string>()
|
||||
for (const acc of accounts || []) {
|
||||
for (const acc of accounts) {
|
||||
accountNameMap.set(acc.account_number, acc.account_name)
|
||||
}
|
||||
|
||||
@@ -92,7 +96,7 @@ export async function generateGeneralLedger(
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.lt('entry_date', period.start_date)
|
||||
.lt('entry_date', period.period_start)
|
||||
|
||||
const openingBalances = new Map<string, number>()
|
||||
|
||||
@@ -178,6 +182,6 @@ export async function generateGeneralLedger(
|
||||
|
||||
return {
|
||||
accounts: result,
|
||||
period: { start: period.start_date, end: period.end_date },
|
||||
period: { start: period.period_start, end: period.period_end },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
export interface JournalRegisterLine {
|
||||
account_number: string
|
||||
@@ -40,7 +41,7 @@ export async function generateJournalRegister(
|
||||
// Get fiscal period dates
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('start_date, end_date')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
@@ -60,7 +61,7 @@ export async function generateJournalRegister(
|
||||
.order('voucher_number', { ascending: true })
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: period.start_date, end: period.end_date } }
|
||||
return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: period.period_start, end: period.period_end } }
|
||||
}
|
||||
|
||||
const entryIds = entries.map((e) => e.id)
|
||||
@@ -72,13 +73,16 @@ export async function generateJournalRegister(
|
||||
.in('journal_entry_id', entryIds)
|
||||
|
||||
// Fetch account names
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('user_id', userId)
|
||||
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('user_id', userId)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
const accountNameMap = new Map<string, string>()
|
||||
for (const acc of accounts || []) {
|
||||
for (const acc of accounts) {
|
||||
accountNameMap.set(acc.account_number, acc.account_name)
|
||||
}
|
||||
|
||||
@@ -126,6 +130,6 @@ export async function generateJournalRegister(
|
||||
total_entries: result.length,
|
||||
total_debit: Math.round(grandTotalDebit * 100) / 100,
|
||||
total_credit: Math.round(grandTotalCredit * 100) / 100,
|
||||
period: { start: period.start_date, end: period.end_date },
|
||||
period: { start: period.period_start, end: period.period_end },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export interface MonthlyBreakdownMonth {
|
||||
label: string
|
||||
income: number
|
||||
expenses: number
|
||||
net: number
|
||||
}
|
||||
|
||||
export interface MonthlyBreakdown {
|
||||
months: MonthlyBreakdownMonth[]
|
||||
}
|
||||
|
||||
const MONTH_LABELS = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec',
|
||||
]
|
||||
|
||||
/**
|
||||
* Generate monthly income vs expenses breakdown for a fiscal period.
|
||||
*
|
||||
* Groups posted journal entry lines by month and account class:
|
||||
* - Class 3 (30xx) = revenue (credit side)
|
||||
* - Class 4-7 (40xx-79xx) = expenses (debit side)
|
||||
*/
|
||||
export async function generateMonthlyBreakdown(
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<MonthlyBreakdown> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Get the fiscal period date range
|
||||
const { data: period, error: periodError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (periodError || !period) {
|
||||
return { months: [] }
|
||||
}
|
||||
|
||||
// Get all posted journal entry lines for this period with their entry dates
|
||||
const { data: lines, error: linesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
account_number,
|
||||
debit,
|
||||
credit,
|
||||
journal_entry:journal_entries!inner(
|
||||
entry_date,
|
||||
status,
|
||||
user_id,
|
||||
fiscal_period_id
|
||||
)
|
||||
`)
|
||||
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
|
||||
if (linesError || !lines) {
|
||||
return { months: [] }
|
||||
}
|
||||
|
||||
// Build monthly aggregates
|
||||
const monthMap = new Map<number, { income: number; expenses: number }>()
|
||||
|
||||
// Initialize all months in the period range
|
||||
const startDate = new Date(period.period_start)
|
||||
const endDate = new Date(period.period_end)
|
||||
const startMonth = startDate.getMonth()
|
||||
const endMonth = endDate.getMonth() + (endDate.getFullYear() - startDate.getFullYear()) * 12
|
||||
|
||||
for (let m = startMonth; m <= endMonth; m++) {
|
||||
monthMap.set(m % 12, { income: 0, expenses: 0 })
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const entry = line.journal_entry as unknown as {
|
||||
entry_date: string
|
||||
status: string
|
||||
user_id: string
|
||||
fiscal_period_id: string
|
||||
}
|
||||
const accountClass = parseInt(line.account_number.charAt(0))
|
||||
const entryDate = new Date(entry.entry_date)
|
||||
const month = entryDate.getMonth()
|
||||
|
||||
if (!monthMap.has(month)) {
|
||||
monthMap.set(month, { income: 0, expenses: 0 })
|
||||
}
|
||||
|
||||
const bucket = monthMap.get(month)!
|
||||
|
||||
if (accountClass === 3) {
|
||||
// Revenue accounts: credit side represents revenue
|
||||
bucket.income = Math.round((bucket.income + line.credit - line.debit) * 100) / 100
|
||||
} else if (accountClass >= 4 && accountClass <= 7) {
|
||||
// Expense accounts: debit side represents expenses
|
||||
bucket.expenses = Math.round((bucket.expenses + line.debit - line.credit) * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to sorted array
|
||||
const months: MonthlyBreakdownMonth[] = []
|
||||
const sortedMonths = Array.from(monthMap.entries()).sort((a, b) => {
|
||||
// Handle year boundaries (e.g., Nov-Dec-Jan for broken fiscal year)
|
||||
const aAdj = a[0] < startMonth ? a[0] + 12 : a[0]
|
||||
const bAdj = b[0] < startMonth ? b[0] + 12 : b[0]
|
||||
return aAdj - bAdj
|
||||
})
|
||||
|
||||
for (const [month, data] of sortedMonths) {
|
||||
months.push({
|
||||
label: MONTH_LABELS[month],
|
||||
income: data.income,
|
||||
expenses: data.expenses,
|
||||
net: Math.round((data.income - data.expenses) * 100) / 100,
|
||||
})
|
||||
}
|
||||
|
||||
return { months }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -29,12 +30,15 @@ export async function generateSIEExport(
|
||||
}
|
||||
|
||||
// Fetch all accounts
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
const accounts = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch all posted journal entries with lines
|
||||
const { data: entries } = await supabase
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { TrialBalanceRow } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -83,13 +84,16 @@ async function generateTrialBalanceManual(
|
||||
}
|
||||
|
||||
// Get account names
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_class')
|
||||
.eq('user_id', userId)
|
||||
const accounts = await fetchAllRows<{ account_number: string; account_name: string; account_class: number }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_class')
|
||||
.eq('user_id', userId)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
const accountMap = new Map<string, { name: string; class: number }>()
|
||||
for (const acc of accounts || []) {
|
||||
for (const acc of accounts) {
|
||||
accountMap.set(acc.account_number, {
|
||||
name: acc.account_name,
|
||||
class: acc.account_class,
|
||||
|
||||
+120
-400
@@ -1,25 +1,46 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type {
|
||||
VatDeclaration,
|
||||
VatDeclarationRutor,
|
||||
VatPeriodType,
|
||||
Invoice,
|
||||
Transaction,
|
||||
Receipt,
|
||||
TaxCode,
|
||||
AccountingMethod,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Calculate VAT declaration (Momsdeklaration) for a given period
|
||||
* Calculate VAT declaration (Momsdeklaration) for a given period.
|
||||
*
|
||||
* Aggregates data from:
|
||||
* - Invoices: Utgående moms (output VAT) based on moms_ruta
|
||||
* - Transactions: Ingående moms (input VAT) from categorized expenses
|
||||
* - Receipts: Ingående moms from confirmed receipts
|
||||
* Reads directly from the general ledger — sums posted journal entry lines
|
||||
* on 26xx (VAT) and 3xxx (revenue) accounts for the period. This makes the
|
||||
* momsdeklaration a pure projection from the double-entry bookkeeping ledger.
|
||||
*
|
||||
* Returns VAT rutor according to Swedish tax authority format.
|
||||
* The accounting method (accrual vs cash) is already reflected in when
|
||||
* journal entries were created by the entry generators, so no separate
|
||||
* filtering logic is needed here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Account-to-ruta mapping for the Swedish momsdeklaration.
|
||||
*
|
||||
* Output VAT (26xx): net credit balance feeds output VAT boxes.
|
||||
* Input VAT (2641/2645): net debit balance feeds ruta 48.
|
||||
* Revenue (3xxx): net credit balance feeds underlag (basis) boxes.
|
||||
*/
|
||||
const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'credit' | 'debit' }> = {
|
||||
'2611': { box: 'ruta05', side: 'credit' },
|
||||
'2621': { box: 'ruta06', side: 'credit' },
|
||||
'2631': { box: 'ruta07', side: 'credit' },
|
||||
'2641': { box: 'ruta48', side: 'debit' },
|
||||
'2645': { box: 'ruta48', side: 'debit' },
|
||||
'3001': { box: 'ruta10', side: 'credit' },
|
||||
'3002': { box: 'ruta11', side: 'credit' },
|
||||
'3003': { box: 'ruta12', side: 'credit' },
|
||||
'3305': { box: 'ruta40', side: 'credit' },
|
||||
'3308': { box: 'ruta39', side: 'credit' },
|
||||
}
|
||||
|
||||
const VAT_ACCOUNTS = Object.keys(ACCOUNT_RUTA)
|
||||
|
||||
/**
|
||||
* Calculate period start and end dates
|
||||
*/
|
||||
@@ -79,298 +100,119 @@ function round(value: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to calculate VAT declaration
|
||||
* Calculate VAT declaration from the general ledger.
|
||||
*
|
||||
* Sums posted journal entry lines on 26xx and 3xxx accounts:
|
||||
* - 2611/2621/2631 credit balance -> ruta 05/06/07 (output VAT)
|
||||
* - 2641/2645 debit balance -> ruta 48 (input VAT)
|
||||
* - 3001/3002/3003 credit balance -> ruta 10/11/12 (revenue basis)
|
||||
* - 3308/3305 credit balance -> ruta 39/40 (EU/export)
|
||||
* - ruta 49 = (05 + 06 + 07) - 48
|
||||
*
|
||||
* The accounting method parameter is accepted for backward compatibility
|
||||
* but not used — the method is already baked into journal entry timing.
|
||||
*/
|
||||
export async function calculateVatDeclaration(
|
||||
userId: string,
|
||||
periodType: VatPeriodType,
|
||||
year: number,
|
||||
period: number
|
||||
period: number,
|
||||
_accountingMethod: AccountingMethod = 'accrual'
|
||||
): Promise<VatDeclaration> {
|
||||
const supabase = await createClient()
|
||||
const { start, end } = calculatePeriodDates(periodType, year, period)
|
||||
|
||||
// Fetch invoices for the period
|
||||
const { data: invoices, error: invoicesError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.gte('invoice_date', start)
|
||||
.lte('invoice_date', end)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
// Fetch all posted journal entry lines on VAT-relevant accounts for the period
|
||||
const lines = await fetchAllRows<{
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
account_number,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entries!inner (user_id, entry_date, status)
|
||||
`)
|
||||
.in('account_number', VAT_ACCOUNTS)
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.gte('journal_entries.entry_date', start)
|
||||
.lte('journal_entries.entry_date', end)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
if (invoicesError) {
|
||||
console.error('Error fetching invoices:', invoicesError)
|
||||
// Aggregate debit/credit totals per account
|
||||
const totals = new Map<string, { debit: number; credit: number }>()
|
||||
for (const line of lines) {
|
||||
const t = totals.get(line.account_number) || { debit: 0, credit: 0 }
|
||||
t.debit += Number(line.debit_amount) || 0
|
||||
t.credit += Number(line.credit_amount) || 0
|
||||
totals.set(line.account_number, t)
|
||||
}
|
||||
|
||||
// Fetch transactions with business expenses for the period
|
||||
const { data: transactions, error: transactionsError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.gte('date', start)
|
||||
.lte('date', end)
|
||||
.eq('is_business', true)
|
||||
.lt('amount', 0) // Expenses are negative
|
||||
|
||||
if (transactionsError) {
|
||||
console.error('Error fetching transactions:', transactionsError)
|
||||
}
|
||||
|
||||
// Fetch confirmed receipts for the period
|
||||
const { data: receipts, error: receiptsError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.gte('receipt_date', start)
|
||||
.lte('receipt_date', end)
|
||||
.eq('status', 'confirmed')
|
||||
|
||||
if (receiptsError) {
|
||||
console.error('Error fetching receipts:', receiptsError)
|
||||
}
|
||||
|
||||
// Calculate invoice-based VAT (utgående moms)
|
||||
const invoiceVat = calculateInvoiceVat(invoices as Invoice[] || [])
|
||||
|
||||
// Calculate input VAT from transactions
|
||||
const transactionVat = calculateTransactionInputVat(transactions as Transaction[] || [])
|
||||
|
||||
// Calculate input VAT from receipts
|
||||
const receiptVat = calculateReceiptInputVat(receipts as Receipt[] || [])
|
||||
|
||||
// Total ingående moms (input VAT to deduct)
|
||||
const totalInputVat = round(transactionVat + receiptVat)
|
||||
|
||||
// Total utgående moms (output VAT to pay)
|
||||
const totalOutputVat = round(invoiceVat.ruta05 + invoiceVat.ruta06 + invoiceVat.ruta07)
|
||||
|
||||
// Moms att betala/återfå (VAT to pay or receive back)
|
||||
const vatToPay = round(totalOutputVat - totalInputVat)
|
||||
|
||||
// Map account balances to momsdeklaration boxes
|
||||
const rutor: VatDeclarationRutor = {
|
||||
ruta05: invoiceVat.ruta05,
|
||||
ruta06: invoiceVat.ruta06,
|
||||
ruta07: invoiceVat.ruta07,
|
||||
ruta10: invoiceVat.ruta10,
|
||||
ruta11: invoiceVat.ruta11,
|
||||
ruta12: invoiceVat.ruta12,
|
||||
ruta39: invoiceVat.ruta39,
|
||||
ruta40: invoiceVat.ruta40,
|
||||
ruta48: totalInputVat,
|
||||
ruta49: vatToPay,
|
||||
ruta05: 0, ruta06: 0, ruta07: 0,
|
||||
ruta10: 0, ruta11: 0, ruta12: 0,
|
||||
ruta39: 0, ruta40: 0,
|
||||
ruta48: 0, ruta49: 0,
|
||||
}
|
||||
|
||||
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
|
||||
const t = totals.get(account)
|
||||
if (!t) continue
|
||||
const balance = mapping.side === 'credit'
|
||||
? t.credit - t.debit
|
||||
: t.debit - t.credit
|
||||
rutor[mapping.box] = round(rutor[mapping.box] + balance)
|
||||
}
|
||||
|
||||
rutor.ruta49 = round(rutor.ruta05 + rutor.ruta06 + rutor.ruta07 - rutor.ruta48)
|
||||
|
||||
// Count journal entries by source type for metadata
|
||||
const { data: entryCounts } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('source_type')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'posted')
|
||||
.gte('entry_date', start)
|
||||
.lte('entry_date', end)
|
||||
|
||||
const invoiceSources = new Set([
|
||||
'invoice_created', 'invoice_paid', 'invoice_cash_payment', 'credit_note',
|
||||
])
|
||||
let invoiceCount = 0
|
||||
let transactionCount = 0
|
||||
for (const e of entryCounts || []) {
|
||||
if (invoiceSources.has(e.source_type)) invoiceCount++
|
||||
else if (e.source_type === 'bank_transaction') transactionCount++
|
||||
}
|
||||
|
||||
return {
|
||||
period: {
|
||||
type: periodType,
|
||||
year,
|
||||
period,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
period: { type: periodType, year, period, start, end },
|
||||
rutor,
|
||||
invoiceCount: (invoices || []).length,
|
||||
transactionCount: (transactions || []).length,
|
||||
invoiceCount,
|
||||
transactionCount,
|
||||
breakdown: {
|
||||
invoices: {
|
||||
ruta05: invoiceVat.ruta05,
|
||||
ruta06: invoiceVat.ruta06,
|
||||
ruta07: invoiceVat.ruta07,
|
||||
ruta10: invoiceVat.ruta10,
|
||||
ruta11: invoiceVat.ruta11,
|
||||
ruta12: invoiceVat.ruta12,
|
||||
ruta39: invoiceVat.ruta39,
|
||||
ruta40: invoiceVat.ruta40,
|
||||
},
|
||||
transactions: {
|
||||
ruta48: round(transactionVat),
|
||||
},
|
||||
receipts: {
|
||||
ruta48: round(receiptVat),
|
||||
ruta05: rutor.ruta05,
|
||||
ruta06: rutor.ruta06,
|
||||
ruta07: rutor.ruta07,
|
||||
ruta10: rutor.ruta10,
|
||||
ruta11: rutor.ruta11,
|
||||
ruta12: rutor.ruta12,
|
||||
ruta39: rutor.ruta39,
|
||||
ruta40: rutor.ruta40,
|
||||
},
|
||||
transactions: { ruta48: rutor.ruta48 },
|
||||
receipts: { ruta48: 0 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate VAT from invoices
|
||||
*/
|
||||
function calculateInvoiceVat(invoices: Invoice[]): {
|
||||
ruta05: number
|
||||
ruta06: number
|
||||
ruta07: number
|
||||
ruta10: number
|
||||
ruta11: number
|
||||
ruta12: number
|
||||
ruta39: number
|
||||
ruta40: number
|
||||
} {
|
||||
let ruta05 = 0 // Utgående moms 25%
|
||||
let ruta06 = 0 // Utgående moms 12%
|
||||
let ruta07 = 0 // Utgående moms 6%
|
||||
let ruta10 = 0 // Underlag 25%
|
||||
let ruta11 = 0 // Underlag 12%
|
||||
let ruta12 = 0 // Underlag 6%
|
||||
let ruta39 = 0 // EU tjänster
|
||||
let ruta40 = 0 // Export
|
||||
|
||||
for (const invoice of invoices) {
|
||||
// Use subtotal_sek if available (for foreign currency invoices), otherwise subtotal
|
||||
const subtotal = invoice.subtotal_sek ?? invoice.subtotal
|
||||
const vatAmount = invoice.vat_amount_sek ?? invoice.vat_amount
|
||||
|
||||
switch (invoice.moms_ruta) {
|
||||
case '05':
|
||||
// Standard 25% VAT
|
||||
ruta05 += vatAmount
|
||||
ruta10 += subtotal
|
||||
break
|
||||
case '06':
|
||||
// Reduced 12% VAT
|
||||
ruta06 += vatAmount
|
||||
ruta11 += subtotal
|
||||
break
|
||||
case '07':
|
||||
// Reduced 6% VAT
|
||||
ruta07 += vatAmount
|
||||
ruta12 += subtotal
|
||||
break
|
||||
case '39':
|
||||
// EU reverse charge - no VAT charged, but report the value
|
||||
ruta39 += subtotal
|
||||
break
|
||||
case '40':
|
||||
// Export outside EU - no VAT charged, but report the value
|
||||
ruta40 += subtotal
|
||||
break
|
||||
default:
|
||||
// Default to 25% if moms_ruta is not set but there's VAT
|
||||
if (vatAmount > 0) {
|
||||
ruta05 += vatAmount
|
||||
ruta10 += subtotal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ruta05: round(ruta05),
|
||||
ruta06: round(ruta06),
|
||||
ruta07: round(ruta07),
|
||||
ruta10: round(ruta10),
|
||||
ruta11: round(ruta11),
|
||||
ruta12: round(ruta12),
|
||||
ruta39: round(ruta39),
|
||||
ruta40: round(ruta40),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate input VAT from business expense transactions
|
||||
*
|
||||
* For Swedish business expenses with 25% VAT, we can deduct the VAT.
|
||||
* This is a simplified calculation - in reality, the journal entry
|
||||
* would have the exact VAT amounts.
|
||||
*/
|
||||
function calculateTransactionInputVat(transactions: Transaction[]): number {
|
||||
let inputVat = 0
|
||||
|
||||
for (const transaction of transactions) {
|
||||
// Only process business expenses (amount is negative)
|
||||
if (!transaction.is_business || transaction.amount >= 0) continue
|
||||
|
||||
// Use amount_sek if available, otherwise amount
|
||||
const expenseAmount = Math.abs(transaction.amount_sek ?? transaction.amount)
|
||||
|
||||
// Estimate VAT based on category
|
||||
// Most Swedish business expenses have 25% VAT
|
||||
// Some categories might have reduced rates or no VAT
|
||||
const vatRate = getVatRateForCategory(transaction.category)
|
||||
|
||||
if (vatRate > 0) {
|
||||
// Extract VAT from total (VAT-inclusive) amount
|
||||
// VAT = total * rate / (1 + rate)
|
||||
const vat = (expenseAmount * vatRate) / (1 + vatRate)
|
||||
inputVat += vat
|
||||
}
|
||||
}
|
||||
|
||||
return inputVat
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VAT rate for expense category
|
||||
*/
|
||||
function getVatRateForCategory(category: string | null): number {
|
||||
// Categories that typically have 25% VAT
|
||||
const standard25Categories = [
|
||||
'expense_equipment',
|
||||
'expense_software',
|
||||
'expense_office',
|
||||
'expense_marketing',
|
||||
'expense_professional_services',
|
||||
'expense_education',
|
||||
'expense_other',
|
||||
]
|
||||
|
||||
// Categories with 12% VAT (e.g., food/restaurants, but only 50% deductible for representation)
|
||||
const reduced12Categories = [
|
||||
'expense_travel', // Hotels, some transport
|
||||
]
|
||||
|
||||
// Categories with 6% VAT
|
||||
const reduced6Categories: string[] = [
|
||||
// Books, newspapers, etc.
|
||||
]
|
||||
|
||||
// No VAT deduction
|
||||
const noVatCategories = [
|
||||
'private',
|
||||
'uncategorized',
|
||||
'income_services',
|
||||
'income_products',
|
||||
'income_other',
|
||||
]
|
||||
|
||||
if (!category || noVatCategories.includes(category)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (standard25Categories.includes(category)) {
|
||||
return 0.25
|
||||
}
|
||||
|
||||
if (reduced12Categories.includes(category)) {
|
||||
return 0.12
|
||||
}
|
||||
|
||||
if (reduced6Categories.includes(category)) {
|
||||
return 0.06
|
||||
}
|
||||
|
||||
// Default to 25% for unrecognized expense categories
|
||||
return 0.25
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate input VAT from confirmed receipts
|
||||
*/
|
||||
function calculateReceiptInputVat(receipts: Receipt[]): number {
|
||||
let inputVat = 0
|
||||
|
||||
for (const receipt of receipts) {
|
||||
// Only confirmed receipts
|
||||
if (receipt.status !== 'confirmed') continue
|
||||
|
||||
// Use the extracted VAT amount if available
|
||||
if (receipt.vat_amount && receipt.vat_amount > 0) {
|
||||
inputVat += receipt.vat_amount
|
||||
}
|
||||
}
|
||||
|
||||
return inputVat
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of the VAT declaration for display
|
||||
*/
|
||||
@@ -420,125 +262,3 @@ export function formatPeriodLabel(
|
||||
return `${year}`
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tax-code-driven VAT declaration (new approach)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Calculate VAT declaration using tax codes from journal entry lines.
|
||||
*
|
||||
* This is the new, tax-code-driven approach that sums journal_entry_lines
|
||||
* grouped by tax_code, then maps via the tax_codes table to moms boxes.
|
||||
* Falls back to the legacy invoice/transaction/receipt approach for
|
||||
* lines without tax codes.
|
||||
*/
|
||||
export async function calculateVatDeclarationFromTaxCodes(
|
||||
userId: string,
|
||||
periodType: VatPeriodType,
|
||||
year: number,
|
||||
period: number
|
||||
): Promise<VatDeclaration> {
|
||||
const supabase = await createClient()
|
||||
const { start, end } = calculatePeriodDates(periodType, year, period)
|
||||
|
||||
// Fetch tax codes for this user (including system codes)
|
||||
const { data: taxCodesData } = await supabase
|
||||
.from('tax_codes')
|
||||
.select('*')
|
||||
.or(`user_id.eq.${userId},user_id.is.null`)
|
||||
|
||||
const taxCodes = (taxCodesData as TaxCode[]) || []
|
||||
const taxCodeMap = new Map<string, TaxCode>()
|
||||
for (const tc of taxCodes) {
|
||||
if (!taxCodeMap.has(tc.code) || tc.user_id) {
|
||||
taxCodeMap.set(tc.code, tc)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch posted journal entry lines with tax_code in the period
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
tax_code,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entry_id,
|
||||
journal_entries!inner (
|
||||
user_id,
|
||||
entry_date,
|
||||
status
|
||||
)
|
||||
`)
|
||||
.not('tax_code', 'is', null)
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.gte('journal_entries.entry_date', start)
|
||||
.lte('journal_entries.entry_date', end)
|
||||
|
||||
// Aggregate amounts by moms box
|
||||
const boxTotals = new Map<string, number>()
|
||||
|
||||
for (const line of lines || []) {
|
||||
if (!line.tax_code) continue
|
||||
|
||||
const taxCode = taxCodeMap.get(line.tax_code)
|
||||
if (!taxCode) continue
|
||||
|
||||
const amount = Math.abs(Number(line.debit_amount || 0) - Number(line.credit_amount || 0))
|
||||
|
||||
// Map to all relevant boxes
|
||||
for (const box of [...taxCode.moms_basis_boxes, ...taxCode.moms_tax_boxes, ...taxCode.moms_input_boxes]) {
|
||||
const current = boxTotals.get(box) || 0
|
||||
boxTotals.set(box, current + amount)
|
||||
}
|
||||
}
|
||||
|
||||
// Build rutor from box totals
|
||||
const rutor: VatDeclarationRutor = {
|
||||
ruta05: round(boxTotals.get('05') || 0),
|
||||
ruta06: round(boxTotals.get('06') || 0),
|
||||
ruta07: round(boxTotals.get('07') || 0),
|
||||
ruta10: round(boxTotals.get('10') || 0),
|
||||
ruta11: round(boxTotals.get('11') || 0),
|
||||
ruta12: round(boxTotals.get('12') || 0),
|
||||
ruta39: round(boxTotals.get('39') || 0),
|
||||
ruta40: round(boxTotals.get('40') || 0),
|
||||
ruta48: round(boxTotals.get('48') || 0),
|
||||
ruta49: 0,
|
||||
}
|
||||
|
||||
const totalOutputVat = round(rutor.ruta05 + rutor.ruta06 + rutor.ruta07)
|
||||
rutor.ruta49 = round(totalOutputVat - rutor.ruta48)
|
||||
|
||||
return {
|
||||
period: {
|
||||
type: periodType,
|
||||
year,
|
||||
period,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
rutor,
|
||||
invoiceCount: 0,
|
||||
transactionCount: (lines || []).length,
|
||||
breakdown: {
|
||||
invoices: {
|
||||
ruta05: 0,
|
||||
ruta06: 0,
|
||||
ruta07: 0,
|
||||
ruta10: 0,
|
||||
ruta11: 0,
|
||||
ruta12: 0,
|
||||
ruta39: 0,
|
||||
ruta40: 0,
|
||||
},
|
||||
transactions: {
|
||||
ruta48: 0,
|
||||
},
|
||||
receipts: {
|
||||
ruta48: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const PAGE_SIZE = 1000
|
||||
|
||||
/**
|
||||
* Fetches all rows from a Supabase query by paginating through results.
|
||||
* Overcomes PostgREST's default 1000-row limit.
|
||||
*
|
||||
* The callback receives `{ from, to }` range values — append `.range(from, to)`
|
||||
* to your query builder:
|
||||
*
|
||||
* ```ts
|
||||
* const accounts = await fetchAllRows(({ from, to }) =>
|
||||
* supabase
|
||||
* .from('chart_of_accounts')
|
||||
* .select('account_number, account_name')
|
||||
* .eq('user_id', userId)
|
||||
* .range(from, to)
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export async function fetchAllRows<T>(
|
||||
queryFn: (range: { from: number; to: number }) => PromiseLike<{
|
||||
data: T[] | null
|
||||
error: { message: string } | null
|
||||
}>
|
||||
): Promise<T[]> {
|
||||
const allRows: T[] = []
|
||||
let from = 0
|
||||
|
||||
while (true) {
|
||||
const { data, error } = await queryFn({ from, to: from + PAGE_SIZE - 1 })
|
||||
if (error) throw new Error(error.message)
|
||||
if (!data || data.length === 0) break
|
||||
allRows.push(...data)
|
||||
if (data.length < PAGE_SIZE) break
|
||||
from += PAGE_SIZE
|
||||
}
|
||||
|
||||
return allRows
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { TAX_DEADLINE_CONFIGS } from '../deadline-config'
|
||||
import type { CompanySettingsForDeadlines } from '../deadline-config'
|
||||
|
||||
function getConfig(type: string) {
|
||||
return TAX_DEADLINE_CONFIGS.find((c) => c.type === type)!
|
||||
}
|
||||
|
||||
function makeSettings(overrides: Partial<CompanySettingsForDeadlines> = {}): CompanySettingsForDeadlines {
|
||||
return {
|
||||
entity_type: 'aktiebolag',
|
||||
moms_period: 'quarterly',
|
||||
f_skatt: true,
|
||||
vat_registered: true,
|
||||
pays_salaries: false,
|
||||
fiscal_year_start_month: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('inkomstdeklaration_ab — digital filing deadlines', () => {
|
||||
const config = getConfig('inkomstdeklaration_ab')
|
||||
|
||||
it('FY end Dec (calendar year) → Aug 1 next year', () => {
|
||||
// FY ends Dec 2024, deadline Aug 1, 2025
|
||||
const settings = makeSettings({ fiscal_year_start_month: 1 }) // end month = 12
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 1, month: 7, year: 2025 }) // Aug (0-indexed)
|
||||
})
|
||||
|
||||
it('FY end Sep → Aug 1 next year', () => {
|
||||
// FY start Oct, end Sep. FY ending Sep 2024 → deadline Aug 1, 2025
|
||||
const settings = makeSettings({ fiscal_year_start_month: 10 }) // end month = 9
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 1, month: 7, year: 2025 }) // Aug
|
||||
})
|
||||
|
||||
it('FY end Oct → Aug 1 next year', () => {
|
||||
const settings = makeSettings({ fiscal_year_start_month: 11 }) // end month = 10
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 1, month: 7, year: 2025 }) // Aug
|
||||
})
|
||||
|
||||
it('FY end Jan → Dec 1 same year', () => {
|
||||
// FY start Feb, end Jan. FY ending Jan 2025 → deadline Dec 1, 2025
|
||||
const settings = makeSettings({ fiscal_year_start_month: 2 }) // end month = 1
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 1, month: 11, year: 2025 }) // Dec
|
||||
})
|
||||
|
||||
it('FY end Apr → Dec 1 same year', () => {
|
||||
const settings = makeSettings({ fiscal_year_start_month: 5 }) // end month = 4
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 1, month: 11, year: 2025 }) // Dec
|
||||
})
|
||||
|
||||
it('FY end May → Jan 15 next year', () => {
|
||||
// FY ending May 2025 → deadline Jan 15, 2026. So for year=2026:
|
||||
const settings = makeSettings({ fiscal_year_start_month: 6 }) // end month = 5
|
||||
const dates = config.generateDates(2026, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 15, month: 0, year: 2026 }) // Jan
|
||||
})
|
||||
|
||||
it('FY end Jun → Jan 15 next year', () => {
|
||||
const settings = makeSettings({ fiscal_year_start_month: 7 }) // end month = 6
|
||||
const dates = config.generateDates(2026, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 15, month: 0, year: 2026 }) // Jan
|
||||
})
|
||||
|
||||
it('FY end Jul → Apr 1 next year', () => {
|
||||
// FY ending Jul 2025 → deadline Apr 1, 2026. So for year=2026:
|
||||
const settings = makeSettings({ fiscal_year_start_month: 8 }) // end month = 7
|
||||
const dates = config.generateDates(2026, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 1, month: 3, year: 2026 }) // Apr
|
||||
})
|
||||
|
||||
it('FY end Aug → Apr 1 next year', () => {
|
||||
const settings = makeSettings({ fiscal_year_start_month: 9 }) // end month = 8
|
||||
const dates = config.generateDates(2026, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 1, month: 3, year: 2026 }) // Apr
|
||||
})
|
||||
|
||||
it('period labels are correct for calendar year', () => {
|
||||
const settings = makeSettings({ fiscal_year_start_month: 1 })
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates[0].periodLabel).toBe('2024')
|
||||
})
|
||||
|
||||
it('period labels are correct for broken fiscal year', () => {
|
||||
const settings = makeSettings({ fiscal_year_start_month: 5 }) // end month = 4
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates[0].periodLabel).toMatch(/2024\/2025|2025/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('arsredovisning — 7 months after FY end (ÅRL 8:3)', () => {
|
||||
const config = getConfig('arsredovisning')
|
||||
|
||||
it('FY end Dec (calendar year) → Jul 31 next year', () => {
|
||||
const settings = makeSettings({ fiscal_year_start_month: 1 })
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
// Dec + 7 months = July (month index 6)
|
||||
expect(dates[0]).toMatchObject({ day: 31, month: 6, year: 2025 })
|
||||
})
|
||||
|
||||
it('FY end Jun → Jan 31 next year', () => {
|
||||
// FY end Jun 2024 → +7 months = Jan 2025
|
||||
const settings = makeSettings({ fiscal_year_start_month: 7 }) // end month = 6
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 31, month: 0, year: 2025 }) // Jan 31
|
||||
})
|
||||
|
||||
it('FY end Apr → Nov 30 same year', () => {
|
||||
// FY end Apr 2025 → +7 months = Nov 2025
|
||||
const settings = makeSettings({ fiscal_year_start_month: 5 }) // end month = 4
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 30, month: 10, year: 2025 }) // Nov 30
|
||||
})
|
||||
|
||||
it('FY end Mar → Oct 31 same year', () => {
|
||||
// FY end Mar 2025 → +7 months = Oct 2025
|
||||
const settings = makeSettings({ fiscal_year_start_month: 4 }) // end month = 3
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 31, month: 9, year: 2025 }) // Oct 31
|
||||
})
|
||||
|
||||
it('FY end Aug → Mar 31 next year', () => {
|
||||
// FY end Aug 2024 → +7 months = Mar 2025
|
||||
const settings = makeSettings({ fiscal_year_start_month: 9 }) // end month = 8
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 31, month: 2, year: 2025 }) // Mar 31
|
||||
})
|
||||
|
||||
it('uses last day of deadline month (handles Feb)', () => {
|
||||
// FY end Jul 2024 → +7 months = Feb 2025
|
||||
const settings = makeSettings({ fiscal_year_start_month: 8 }) // end month = 7
|
||||
const dates = config.generateDates(2025, settings)
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0].month).toBe(1) // Feb
|
||||
expect(dates[0].day).toBe(28) // 2025 is not a leap year
|
||||
})
|
||||
})
|
||||
+84
-26
@@ -178,7 +178,7 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// Inkomstdeklaration (AB) - 1 juli (for calendar year fiscal)
|
||||
// Inkomstdeklaration (AB) — digital filing deadlines per Skatteverket lookup table
|
||||
{
|
||||
type: 'inkomstdeklaration_ab',
|
||||
titleTemplate: 'Inkomstdeklaration AB {periodLabel}',
|
||||
@@ -187,23 +187,55 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
|
||||
priority: 'critical',
|
||||
linkedReportType: null,
|
||||
generateDates: (year, settings) => {
|
||||
// For calendar year fiscal (start month = 1), due July 1st
|
||||
// For other fiscal years, this would need adjustment
|
||||
if (settings.fiscal_year_start_month === 1) {
|
||||
return [
|
||||
{ day: 1, month: 6, year, period: `${year - 1}`, periodLabel: `${year - 1}` },
|
||||
]
|
||||
// FY end month (1-indexed): e.g. start=1 → end=12, start=5 → end=4
|
||||
const fyEndMonth = settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1
|
||||
|
||||
// Skatteverket digital filing deadline lookup:
|
||||
// FY end Jan–Apr → Dec 1 same year as FY end
|
||||
// FY end May–Jun → Jan 15 year after FY end
|
||||
// FY end Jul–Aug → Apr 1 year after FY end
|
||||
// FY end Sep–Dec → Aug 1 year after FY end
|
||||
const getDeadline = (fyEndYear: number) => {
|
||||
if (fyEndMonth >= 1 && fyEndMonth <= 4) {
|
||||
return { day: 1, month: 11, year: fyEndYear } // Dec 1
|
||||
} else if (fyEndMonth >= 5 && fyEndMonth <= 6) {
|
||||
return { day: 15, month: 0, year: fyEndYear + 1 } // Jan 15
|
||||
} else if (fyEndMonth >= 7 && fyEndMonth <= 8) {
|
||||
return { day: 1, month: 3, year: fyEndYear + 1 } // Apr 1
|
||||
} else {
|
||||
return { day: 1, month: 7, year: fyEndYear + 1 } // Aug 1
|
||||
}
|
||||
}
|
||||
// For non-calendar fiscal years, calculate based on fiscal year end + 6 months
|
||||
const fiscalYearEnd = settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1
|
||||
const deadlineMonth = (fiscalYearEnd + 5) % 12 // 6 months after year end
|
||||
return [
|
||||
{ day: 1, month: deadlineMonth, year, period: `${year - 1}/${year}`, periodLabel: `${year - 1}/${year}` },
|
||||
]
|
||||
|
||||
// We need to find which FY ending produces a deadline in `year`.
|
||||
// Try FY endings in year-1 and year (both could produce deadlines in `year`).
|
||||
const results: DeadlineInstance[] = []
|
||||
for (const fyEndYear of [year - 1, year]) {
|
||||
const dl = getDeadline(fyEndYear)
|
||||
if (dl.year === year) {
|
||||
// Compute the FY start year
|
||||
const fyStartYear = fyEndMonth === 12 ? fyEndYear : fyEndYear - (fyEndMonth < settings.fiscal_year_start_month ? 0 : 0)
|
||||
const fyStart = fyEndMonth === 12 ? fyEndYear : fyEndYear
|
||||
const periodLabel = fyEndMonth === 12
|
||||
? `${fyEndYear}`
|
||||
: `${fyStart - 1}/${fyStart}`
|
||||
const period = fyEndMonth === 12
|
||||
? `${fyEndYear}`
|
||||
: `${fyStart - 1}/${fyStart}`
|
||||
results.push({
|
||||
day: dl.day,
|
||||
month: dl.month,
|
||||
year: dl.year,
|
||||
period,
|
||||
periodLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
},
|
||||
},
|
||||
|
||||
// Årsredovisning (AB) - 30 juni (6 months after fiscal year end)
|
||||
// Årsredovisning (AB) — 7 months after fiscal year end per ÅRL 8:3
|
||||
{
|
||||
type: 'arsredovisning',
|
||||
titleTemplate: 'Årsredovisning till Bolagsverket {periodLabel}',
|
||||
@@ -212,19 +244,45 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
|
||||
priority: 'critical',
|
||||
linkedReportType: null,
|
||||
generateDates: (year, settings) => {
|
||||
// For calendar year fiscal, due June 30th
|
||||
if (settings.fiscal_year_start_month === 1) {
|
||||
return [
|
||||
{ day: 30, month: 5, year, period: `${year - 1}`, periodLabel: `${year - 1}` },
|
||||
]
|
||||
// FY end month (1-indexed)
|
||||
const fyEndMonth = settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1
|
||||
|
||||
// 7 months after FY end per ÅRL 8:3
|
||||
// Deadline month (0-indexed): ((fyEndMonth - 1) + 7) % 12
|
||||
const deadlineMonth0 = ((fyEndMonth - 1) + 7) % 12
|
||||
// Last day of the deadline month
|
||||
// Determine which year the deadline falls in
|
||||
const wrapsYear = fyEndMonth > 5 // Jun+ wraps into next year
|
||||
// For calendar year (Dec end): deadline Jul 31 same year+1
|
||||
// The FY ending in `year` produces a deadline:
|
||||
const fyEndYear = year - 1 // By default we show deadline for the FY that ended in year-1
|
||||
const deadlineYear = wrapsYear ? fyEndYear + 1 + 1 : fyEndYear + 1
|
||||
// Simpler: compute from a concrete FY end date
|
||||
// FY ends: fyEndMonth (1-indexed), last day, in some year.
|
||||
// We want the deadline that falls in `year`.
|
||||
|
||||
// Try FY endings in year-1 and year
|
||||
const results: DeadlineInstance[] = []
|
||||
for (const endYr of [year - 1, year]) {
|
||||
// Deadline: 7 months after last day of fyEndMonth in endYr
|
||||
const dlMonth0 = ((fyEndMonth - 1) + 7) % 12
|
||||
const dlYear = (fyEndMonth - 1) + 7 >= 12 ? endYr + 1 : endYr
|
||||
if (dlYear === year) {
|
||||
const lastDay = new Date(dlYear, dlMonth0 + 1, 0).getDate()
|
||||
const periodLabel = fyEndMonth === 12
|
||||
? `${endYr}`
|
||||
: `${endYr - 1}/${endYr}`
|
||||
const period = periodLabel
|
||||
results.push({
|
||||
day: lastDay,
|
||||
month: dlMonth0,
|
||||
year: dlYear,
|
||||
period,
|
||||
periodLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
// For non-calendar fiscal years, 6 months after year end
|
||||
const fiscalYearEnd = settings.fiscal_year_start_month - 1 // 0-indexed month
|
||||
const deadlineMonth = (fiscalYearEnd + 6) % 12
|
||||
const deadlineYear = deadlineMonth < fiscalYearEnd ? year + 1 : year
|
||||
return [
|
||||
{ day: 30, month: deadlineMonth, year: deadlineYear, period: `${year - 1}/${year}`, periodLabel: `${year - 1}/${year}` },
|
||||
]
|
||||
return results
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ export function getCategoryDisplayName(category: string): string {
|
||||
expense_currency_exchange: 'Valutaväxling',
|
||||
expense_other: 'Övriga kostnader',
|
||||
private: 'Privat',
|
||||
uncategorized: 'Ej kategoriserad',
|
||||
uncategorized: 'Ej bokförd',
|
||||
}
|
||||
|
||||
return names[category] || category
|
||||
|
||||
@@ -30,6 +30,13 @@ vi.mock('@/lib/invoices/invoice-matching', () => ({
|
||||
getBestInvoiceMatch: (...args: unknown[]) => mockGetBestInvoiceMatch(...args),
|
||||
}))
|
||||
|
||||
const mockTryReconcileTransaction = vi.fn()
|
||||
const mockFetchUnlinkedGLLines = vi.fn()
|
||||
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
|
||||
tryReconcileTransaction: (...args: unknown[]) => mockTryReconcileTransaction(...args),
|
||||
fetchUnlinkedGLLines: (...args: unknown[]) => mockFetchUnlinkedGLLines(...args),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue-based Supabase mock
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -112,6 +119,9 @@ function makeMappingResult(overrides: Record<string, unknown> = {}) {
|
||||
describe('ingestTransactions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Default: no GL lines for reconciliation
|
||||
mockFetchUnlinkedGLLines.mockResolvedValue([])
|
||||
mockTryReconcileTransaction.mockReturnValue(null)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -426,6 +436,7 @@ describe('ingestTransactions', () => {
|
||||
expect(result).toEqual({
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
reconciled: 0,
|
||||
auto_categorized: 0,
|
||||
auto_matched_invoices: 0,
|
||||
errors: 0,
|
||||
@@ -474,4 +485,119 @@ describe('ingestTransactions', () => {
|
||||
expect(result.auto_categorized).toBe(0)
|
||||
expect(result.errors).toBe(0)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reconciliation: matched transactions skip auto-categorization
|
||||
// -----------------------------------------------------------------------
|
||||
it('reconciles transactions against GL lines and skips auto-categorization', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -500, external_id: 'ext-recon' })
|
||||
const inserted = makeTransaction({
|
||||
id: 'tx-recon',
|
||||
amount: -500,
|
||||
external_id: 'ext-recon',
|
||||
currency: 'SEK',
|
||||
})
|
||||
|
||||
const glLine = {
|
||||
line_id: 'line-1',
|
||||
journal_entry_id: 'je-1',
|
||||
debit_amount: 0,
|
||||
credit_amount: 500,
|
||||
line_description: null,
|
||||
entry_date: '2024-06-15',
|
||||
voucher_number: 1,
|
||||
voucher_series: 'A',
|
||||
entry_description: 'Test entry',
|
||||
source_type: 'import',
|
||||
}
|
||||
|
||||
// Pre-fetch returns GL lines
|
||||
mockFetchUnlinkedGLLines.mockResolvedValue([glLine])
|
||||
// tryReconcileTransaction returns a match
|
||||
mockTryReconcileTransaction.mockReturnValue({
|
||||
transaction: inserted,
|
||||
glLine,
|
||||
method: 'auto_exact',
|
||||
confidence: 0.95,
|
||||
})
|
||||
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
enqueue({ data: inserted, error: null })
|
||||
// Reconciliation update
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.reconciled).toBe(1)
|
||||
// Should NOT have attempted auto-categorization
|
||||
expect(mockEvaluateMappingRules).not.toHaveBeenCalled()
|
||||
expect(mockGetBestInvoiceMatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reconciliation: falls through when no GL matches
|
||||
// -----------------------------------------------------------------------
|
||||
it('falls through to auto-categorization when reconciliation finds no match', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -200 })
|
||||
const inserted = makeTransaction({ id: 'tx-no-recon', amount: -200 })
|
||||
|
||||
mockFetchUnlinkedGLLines.mockResolvedValue([
|
||||
{
|
||||
line_id: 'line-other',
|
||||
journal_entry_id: 'je-other',
|
||||
debit_amount: 999,
|
||||
credit_amount: 0,
|
||||
entry_date: '2024-01-01',
|
||||
voucher_number: 1,
|
||||
voucher_series: 'A',
|
||||
entry_description: 'Unrelated',
|
||||
source_type: 'import',
|
||||
line_description: null,
|
||||
},
|
||||
])
|
||||
mockTryReconcileTransaction.mockReturnValue(null)
|
||||
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.reconciled).toBe(0)
|
||||
// Should have fallen through to auto-categorization
|
||||
expect(mockEvaluateMappingRules).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reconciliation: error is non-critical
|
||||
// -----------------------------------------------------------------------
|
||||
it('continues when reconciliation throws an error', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -300 })
|
||||
const inserted = makeTransaction({ id: 'tx-recon-err', amount: -300 })
|
||||
|
||||
mockFetchUnlinkedGLLines.mockRejectedValue(new Error('RPC error'))
|
||||
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.reconciled).toBe(0)
|
||||
expect(result.errors).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
|
||||
import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -24,6 +26,7 @@ export interface RawTransaction {
|
||||
export interface IngestResult {
|
||||
imported: number
|
||||
duplicates: number
|
||||
reconciled: number
|
||||
auto_categorized: number
|
||||
auto_matched_invoices: number
|
||||
errors: number
|
||||
@@ -51,12 +54,21 @@ export async function ingestTransactions(
|
||||
const result: IngestResult = {
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
reconciled: 0,
|
||||
auto_categorized: 0,
|
||||
auto_matched_invoices: 0,
|
||||
errors: 0,
|
||||
transaction_ids: [],
|
||||
}
|
||||
|
||||
// Pre-fetch unlinked GL lines for reconciliation (non-critical)
|
||||
let glLinePool: UnlinkedGLLine[] = []
|
||||
try {
|
||||
glLinePool = await fetchUnlinkedGLLines(supabase, userId)
|
||||
} catch {
|
||||
// Non-critical — reconciliation will be skipped
|
||||
}
|
||||
|
||||
for (const raw of rawTransactions) {
|
||||
// 1. Check for duplicates via external_id
|
||||
const { data: existing } = await supabase
|
||||
@@ -100,6 +112,30 @@ export async function ingestTransactions(
|
||||
result.imported++
|
||||
result.transaction_ids.push(newTransaction.id)
|
||||
|
||||
// 2.5. Try reconciliation against pre-fetched unlinked GL lines
|
||||
if (glLinePool.length > 0) {
|
||||
try {
|
||||
const match = tryReconcileTransaction(newTransaction as Transaction, glLinePool)
|
||||
if (match) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: match.glLine.journal_entry_id,
|
||||
reconciliation_method: match.method,
|
||||
is_business: true,
|
||||
})
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
// Remove matched GL line from pool to prevent double-matching
|
||||
glLinePool = glLinePool.filter((l) => l.line_id !== match.glLine.line_id)
|
||||
result.reconciled++
|
||||
continue // Skip invoice matching and auto-categorization
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — fall through to normal flow
|
||||
}
|
||||
}
|
||||
|
||||
// 3. For income transactions, try invoice matching
|
||||
if (newTransaction.amount > 0) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user