Two MCP-agent-reported bugs (feedback seq 254607, 261972; also reported by mail): - categorize_transaction validated vat_amount against the transaction- currency gross but posted the raw figure as SEK: a 15.87 USD override on a 79.34 USD Stripe payment booked 15.87 kr to 2611 instead of ~150.92 kr, and the entry still balanced so nothing could catch it. buildMappingResultFromCategory now resolves the same SEK value the line builder uses for the gross and books ALL VAT figures off it: the override (scaled by the settlement ratio), the auto-derived rate VAT, and reverse-charge fiktiv moms, which had the same defect for every foreign-currency transaction. SEK transactions are unchanged. The vat_amount schema now states the denomination; the override bound error names the currency. - complete_document_upload / upload_document declared matched_supplier_id as a bare string while unmatched uploads correctly return null, so strict clients failed every successful unmatched upload and tripped the caller's circuit breaker. The schema is now ['string', 'null'], matching the runtime. Catalog token ceiling 59.9K -> 59.95K per the documented ratchet protocol (prose trimmed to the floor first; headroom was ~19 tokens). Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
881 lines
34 KiB
TypeScript
881 lines
34 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase, makeTransaction } from '@/tests/helpers'
|
|
import type { CreateJournalEntryInput, MappingResult, VatJournalLine } from '@/types'
|
|
|
|
// Mock engine
|
|
vi.mock('../engine', () => ({
|
|
findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
|
|
createJournalEntry: vi.fn().mockImplementation(
|
|
async (_supabase: unknown, _companyId: string, _userId: string, input: CreateJournalEntryInput) => ({
|
|
id: 'entry-1',
|
|
...input,
|
|
lines: input.lines,
|
|
})
|
|
),
|
|
}))
|
|
|
|
// Mock currency-utils with real logic
|
|
vi.mock('../currency-utils', () => ({
|
|
resolveSekAmount: vi.fn().mockImplementation(
|
|
(amount: number, amountSek: number | null, currency: string | null, exchangeRate: number | null) => {
|
|
if (!currency || currency === 'SEK') return amount
|
|
if (amountSek != null) return Math.round(amountSek * 100) / 100
|
|
if (exchangeRate != null && exchangeRate > 0) return Math.round(amount * exchangeRate * 100) / 100
|
|
return amount
|
|
}
|
|
),
|
|
buildCurrencyMetadata: vi.fn().mockImplementation(
|
|
(currency: string | null, amountInCurrency: number | null | undefined, exchangeRate: number | null) => {
|
|
if (!currency || currency === 'SEK') return {}
|
|
return {
|
|
...(currency ? { currency } : {}),
|
|
...(amountInCurrency != null ? { amount_in_currency: amountInCurrency } : {}),
|
|
...(exchangeRate != null && exchangeRate > 0 ? { exchange_rate: exchangeRate } : {}),
|
|
}
|
|
}
|
|
),
|
|
}))
|
|
|
|
// Mock vat-entries with real logic
|
|
vi.mock('../vat-entries', () => ({
|
|
generateInputVatLine: vi.fn().mockImplementation(
|
|
(totalAmount: number, vatRate: number = 0.25) => {
|
|
if (vatRate === 0) return null
|
|
const vatAmount = Math.round((totalAmount * vatRate) / (1 + vatRate) * 100) / 100
|
|
return {
|
|
account_number: '2641',
|
|
debit_amount: vatAmount,
|
|
credit_amount: 0,
|
|
line_description: `Ingående moms ${vatRate * 100}%`,
|
|
}
|
|
}
|
|
),
|
|
generateReverseChargeLines: vi.fn().mockImplementation(
|
|
(baseAmount: number, vatRate: number = 0.25) => {
|
|
const vatAmount = Math.round(baseAmount * vatRate * 100) / 100
|
|
let outputAccount: string
|
|
switch (vatRate) {
|
|
case 0.12: outputAccount = '2624'; break
|
|
case 0.06: outputAccount = '2634'; break
|
|
default: outputAccount = '2614'; break
|
|
}
|
|
return [
|
|
{ account_number: '2645', debit_amount: vatAmount, credit_amount: 0, line_description: `Fiktiv ingående moms` },
|
|
{ account_number: outputAccount, debit_amount: 0, credit_amount: vatAmount, line_description: `Fiktiv utgående moms` },
|
|
]
|
|
}
|
|
),
|
|
extractNetAmount: vi.fn().mockImplementation(
|
|
(totalAmount: number, vatRate: number) => {
|
|
if (vatRate === 0) return totalAmount
|
|
return Math.round((totalAmount / (1 + vatRate)) * 100) / 100
|
|
}
|
|
),
|
|
extractVatAmount: vi.fn().mockImplementation(
|
|
(totalAmount: number, vatRate: number) => {
|
|
if (vatRate === 0) return 0
|
|
return Math.round((totalAmount - totalAmount / (1 + vatRate)) * 100) / 100
|
|
}
|
|
),
|
|
}))
|
|
|
|
const { createJournalEntry, findFiscalPeriod } = await import('../engine')
|
|
const mockedCreateEntry = vi.mocked(createJournalEntry)
|
|
const mockedFindFiscalPeriod = vi.mocked(findFiscalPeriod)
|
|
|
|
const { createTransactionJournalEntry, buildDomesticExpenseLines, buildTransactionEntryLines } = await import('../transaction-entries')
|
|
|
|
function makeMappingResult(overrides: Partial<MappingResult> = {}): MappingResult {
|
|
return {
|
|
rule: null,
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
risk_level: 'LOW',
|
|
confidence: 0.95,
|
|
requires_review: false,
|
|
default_private: false,
|
|
vat_lines: [],
|
|
description: 'Test mapping',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/** Balance check helper */
|
|
function assertBalanced(input: CreateJournalEntryInput) {
|
|
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
|
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
|
expect(totalDebit).toBeCloseTo(totalCredit, 2)
|
|
expect(totalDebit).toBeGreaterThan(0)
|
|
}
|
|
|
|
describe('createTransactionJournalEntry', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockedFindFiscalPeriod.mockResolvedValue('period-1')
|
|
})
|
|
|
|
// --- Validation ---
|
|
|
|
it('throws when debit_account is missing', async () => {
|
|
const tx = makeTransaction()
|
|
const mapping = makeMappingResult({ debit_account: '' })
|
|
|
|
await expect(
|
|
createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
).rejects.toThrow('Invalid mapping result')
|
|
})
|
|
|
|
it('throws when credit_account is missing', async () => {
|
|
const tx = makeTransaction()
|
|
const mapping = makeMappingResult({ credit_account: '' })
|
|
|
|
await expect(
|
|
createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
).rejects.toThrow('Invalid mapping result')
|
|
})
|
|
|
|
// --- Fiscal period ---
|
|
|
|
it('returns null when no fiscal period found and the company has no periods at all', async () => {
|
|
mockedFindFiscalPeriod.mockResolvedValue(null)
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [] }) // earliest-period lookup: nothing
|
|
const tx = makeTransaction()
|
|
const mapping = makeMappingResult()
|
|
|
|
const result = await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
expect(result).toBeNull()
|
|
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// --- Private expense ---
|
|
|
|
it('creates private expense entry for EF (2013)', async () => {
|
|
const tx = makeTransaction({ amount: -500, description: 'Lunch privat' })
|
|
const mapping = makeMappingResult({
|
|
debit_account: '2013',
|
|
credit_account: '1930',
|
|
default_private: true,
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
expect(mockedCreateEntry).toHaveBeenCalledOnce()
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
|
|
expect(input.lines).toHaveLength(2)
|
|
|
|
const debit2013 = input.lines.find(l => l.account_number === '2013')
|
|
expect(debit2013?.debit_amount).toBe(500)
|
|
expect(debit2013?.credit_amount).toBe(0)
|
|
expect(debit2013?.line_description).toMatch(/^Privat:/)
|
|
|
|
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
|
expect(credit1930?.credit_amount).toBe(500)
|
|
expect(credit1930?.debit_amount).toBe(0)
|
|
|
|
assertBalanced(input)
|
|
})
|
|
|
|
it('creates private expense entry for AB (2893)', async () => {
|
|
const tx = makeTransaction({ amount: -1200, description: 'Privat uttag' })
|
|
const mapping = makeMappingResult({
|
|
debit_account: '2893',
|
|
credit_account: '1930',
|
|
default_private: true,
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(input.lines).toHaveLength(2)
|
|
|
|
const debit2893 = input.lines.find(l => l.account_number === '2893')
|
|
expect(debit2893?.debit_amount).toBe(1200)
|
|
|
|
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
|
expect(credit1930?.credit_amount).toBe(1200)
|
|
|
|
assertBalanced(input)
|
|
})
|
|
|
|
// --- Business expense ---
|
|
|
|
it('creates business expense without VAT (2 lines)', async () => {
|
|
const tx = makeTransaction({ amount: -299, description: 'Office supplies' })
|
|
const mapping = makeMappingResult({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
vat_lines: [],
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(input.lines).toHaveLength(2)
|
|
|
|
const debit5410 = input.lines.find(l => l.account_number === '5410')
|
|
expect(debit5410?.debit_amount).toBe(299)
|
|
|
|
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
|
expect(credit1930?.credit_amount).toBe(299)
|
|
|
|
assertBalanced(input)
|
|
})
|
|
|
|
it('creates business expense with 25% input VAT (3 lines)', async () => {
|
|
const tx = makeTransaction({ amount: -1250, description: 'Software license' })
|
|
const vatLines: VatJournalLine[] = [
|
|
{ account_number: '2641', debit_amount: 250, credit_amount: 0, description: 'Ingående moms 25%' },
|
|
]
|
|
const mapping = makeMappingResult({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
vat_lines: vatLines,
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(input.lines).toHaveLength(3)
|
|
|
|
const debit2641 = input.lines.find(l => l.account_number === '2641')
|
|
expect(debit2641?.debit_amount).toBe(250)
|
|
|
|
const debit5410 = input.lines.find(l => l.account_number === '5410')
|
|
expect(debit5410?.debit_amount).toBe(1000) // 1250 - 250 VAT
|
|
|
|
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
|
expect(credit1930?.credit_amount).toBe(1250)
|
|
|
|
assertBalanced(input)
|
|
})
|
|
|
|
it('nets the expense line against an underlag VAT override below the rate amount', async () => {
|
|
// Restaurant receipt 415.80 kr incl. dricks: the document's 12% VAT is
|
|
// 42.43 kr (not rate-extraction 44.55) because dricks carries no moms.
|
|
// The expense line must absorb the difference so the entry balances.
|
|
const tx = makeTransaction({ amount: -415.80, description: 'LEONH Repr' })
|
|
const vatLines: VatJournalLine[] = [
|
|
{ account_number: '2641', debit_amount: 42.43, credit_amount: 0, description: 'Ingående moms (enligt underlag)' },
|
|
]
|
|
const mapping = makeMappingResult({
|
|
debit_account: '6071',
|
|
credit_account: '1930',
|
|
vat_lines: vatLines,
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(input.lines).toHaveLength(3)
|
|
|
|
const debit2641 = input.lines.find(l => l.account_number === '2641')
|
|
expect(debit2641?.debit_amount).toBe(42.43)
|
|
|
|
const debit6071 = input.lines.find(l => l.account_number === '6071')
|
|
expect(debit6071?.debit_amount).toBe(373.37) // 415.80 - 42.43
|
|
|
|
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
|
expect(credit1930?.credit_amount).toBe(415.80)
|
|
|
|
assertBalanced(input)
|
|
})
|
|
|
|
it('handles VAT rounding precision on expense', async () => {
|
|
const tx = makeTransaction({ amount: -997.50, description: 'Expense with rounding' })
|
|
const vatLines: VatJournalLine[] = [
|
|
{ account_number: '2641', debit_amount: 199.50, credit_amount: 0, description: 'Ingående moms 25%' },
|
|
]
|
|
const mapping = makeMappingResult({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
vat_lines: vatLines,
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
const debit5410 = input.lines.find(l => l.account_number === '5410')
|
|
// net = Math.round((997.50 - 199.50) * 100) / 100 = 798
|
|
expect(debit5410?.debit_amount).toBe(Math.round((997.50 - 199.50) * 100) / 100)
|
|
|
|
assertBalanced(input)
|
|
})
|
|
|
|
it('creates expense with EU reverse charge (2645/2614)', async () => {
|
|
const tx = makeTransaction({ amount: -5000, description: 'EU SaaS service' })
|
|
const vatLines: VatJournalLine[] = [
|
|
{ account_number: '2645', debit_amount: 1250, credit_amount: 0, description: 'Fiktiv ingående moms' },
|
|
{ account_number: '2614', debit_amount: 0, credit_amount: 1250, description: 'Fiktiv utgående moms' },
|
|
]
|
|
const mapping = makeMappingResult({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
vat_lines: vatLines,
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
|
|
const debit2645 = input.lines.find(l => l.account_number === '2645')
|
|
expect(debit2645?.debit_amount).toBe(1250)
|
|
|
|
const credit2614 = input.lines.find(l => l.account_number === '2614')
|
|
expect(credit2614?.credit_amount).toBe(1250)
|
|
|
|
// Expense: For reverse charge, no 2641 line means netAmount = absAmount - 0 = 5000
|
|
const debit5410 = input.lines.find(l => l.account_number === '5410')
|
|
expect(debit5410?.debit_amount).toBe(5000)
|
|
|
|
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
|
expect(credit1930?.credit_amount).toBe(5000)
|
|
|
|
assertBalanced(input)
|
|
})
|
|
|
|
// --- Income ---
|
|
|
|
it('creates income entry without VAT (2 lines)', async () => {
|
|
const tx = makeTransaction({ amount: 8000, description: 'Export revenue' })
|
|
const mapping = makeMappingResult({
|
|
debit_account: '1930',
|
|
credit_account: '3001',
|
|
vat_lines: [],
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(input.lines).toHaveLength(2)
|
|
|
|
const debit1930 = input.lines.find(l => l.account_number === '1930')
|
|
expect(debit1930?.debit_amount).toBe(8000)
|
|
|
|
const credit3001 = input.lines.find(l => l.account_number === '3001')
|
|
expect(credit3001?.credit_amount).toBe(8000)
|
|
|
|
assertBalanced(input)
|
|
})
|
|
|
|
it('creates income entry with output VAT', async () => {
|
|
const tx = makeTransaction({ amount: 12500, description: 'Sales income' })
|
|
const vatLines: VatJournalLine[] = [
|
|
{ account_number: '2611', debit_amount: 0, credit_amount: 2500, description: 'Utgående moms 25%' },
|
|
]
|
|
const mapping = makeMappingResult({
|
|
debit_account: '1930',
|
|
credit_account: '3001',
|
|
vat_lines: vatLines,
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
|
|
const debit1930 = input.lines.find(l => l.account_number === '1930')
|
|
expect(debit1930?.debit_amount).toBe(12500)
|
|
|
|
const credit3001 = input.lines.find(l => l.account_number === '3001')
|
|
expect(credit3001?.credit_amount).toBe(10000) // 12500 - 2500 VAT
|
|
|
|
const credit2611 = input.lines.find(l => l.account_number === '2611')
|
|
expect(credit2611?.credit_amount).toBe(2500)
|
|
|
|
assertBalanced(input)
|
|
})
|
|
|
|
it('handles VAT rounding precision on income', async () => {
|
|
const tx = makeTransaction({ amount: 333.33, description: 'Small sale' })
|
|
const vatLines: VatJournalLine[] = [
|
|
{ account_number: '2611', debit_amount: 0, credit_amount: 66.67, description: 'Utgående moms 25%' },
|
|
]
|
|
const mapping = makeMappingResult({
|
|
debit_account: '1930',
|
|
credit_account: '3001',
|
|
vat_lines: vatLines,
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
const credit3001 = input.lines.find(l => l.account_number === '3001')
|
|
// net = Math.round((333.33 - 66.67) * 100) / 100 = 266.66
|
|
expect(credit3001?.credit_amount).toBe(Math.round((333.33 - 66.67) * 100) / 100)
|
|
|
|
assertBalanced(input)
|
|
})
|
|
|
|
// --- Foreign currency ---
|
|
|
|
it('adds currency metadata to 1930 line for EUR expense', async () => {
|
|
const tx = makeTransaction({
|
|
amount: -100,
|
|
currency: 'EUR',
|
|
amount_sek: null,
|
|
exchange_rate: 11.50,
|
|
description: 'EUR purchase',
|
|
})
|
|
const mapping = makeMappingResult({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
vat_lines: [],
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
|
|
|
expect(credit1930?.currency).toBe('EUR')
|
|
expect(credit1930?.amount_in_currency).toBe(100)
|
|
expect(credit1930?.exchange_rate).toBe(11.50)
|
|
|
|
// All amounts in SEK
|
|
expect(credit1930?.credit_amount).toBe(1150) // 100 * 11.50
|
|
const debit5410 = input.lines.find(l => l.account_number === '5410')
|
|
expect(debit5410?.debit_amount).toBe(1150)
|
|
|
|
assertBalanced(input)
|
|
})
|
|
|
|
it('SEK transaction has no currency metadata', async () => {
|
|
const tx = makeTransaction({ amount: -500, currency: 'SEK' })
|
|
const mapping = makeMappingResult()
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
for (const line of input.lines) {
|
|
expect(line.currency).toBeUndefined()
|
|
expect(line.amount_in_currency).toBeUndefined()
|
|
expect(line.exchange_rate).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
// --- Metadata ---
|
|
|
|
it('sets source_type and source_id correctly', async () => {
|
|
const tx = makeTransaction({ id: 'tx-abc-123', amount: -100 })
|
|
const mapping = makeMappingResult()
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(input.source_type).toBe('bank_transaction')
|
|
expect(input.source_id).toBe('tx-abc-123')
|
|
})
|
|
|
|
it('uses transaction.date as entry_date', async () => {
|
|
const tx = makeTransaction({ date: '2024-09-15', amount: -100 })
|
|
const mapping = makeMappingResult()
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(input.entry_date).toBe('2024-09-15')
|
|
})
|
|
})
|
|
|
|
// Pre-FY clamp (issue #1825): a bank event dated before the company's first
|
|
// rakenskapsar (e.g. the aktiekapital deposit paid in before the Bolagsverket
|
|
// registration date) books into the first OPEN unlocked fiscal period with
|
|
// entry_date = period_start, and the verifikationstext carries the real
|
|
// bank-event date. Everything else keeps the old null return.
|
|
describe('createTransactionJournalEntry: pre-FY clamp', () => {
|
|
const openFirstPeriod = {
|
|
id: 'period-first',
|
|
period_start: '2026-05-12',
|
|
is_closed: false,
|
|
locked_at: null,
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockedFindFiscalPeriod.mockResolvedValue(null)
|
|
})
|
|
|
|
it('books a pre-FY date into the earliest open period on its first day', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [openFirstPeriod] })
|
|
const tx = makeTransaction({ date: '2026-03-10', amount: 25000, description: 'Insättning aktiekapital' })
|
|
const mapping = makeMappingResult({ debit_account: '1930', credit_account: '2081' })
|
|
|
|
const result = await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(mockedCreateEntry).toHaveBeenCalledOnce()
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(input.fiscal_period_id).toBe('period-first')
|
|
expect(input.entry_date).toBe('2026-05-12')
|
|
expect(input.description).toBe(
|
|
'Insättning aktiekapital · Affärshändelse 2026-03-10, bokförd på räkenskapsårets första dag'
|
|
)
|
|
// Source linkage to the bank row is untouched by the clamp.
|
|
expect(input.source_type).toBe('bank_transaction')
|
|
expect(input.source_id).toBe(tx.id)
|
|
})
|
|
|
|
it('keeps the notes AND the clamp note in the composed description', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [openFirstPeriod] })
|
|
const tx = makeTransaction({ date: '2026-03-10', amount: 25000, description: 'Insättning' })
|
|
const mapping = makeMappingResult({ debit_account: '1930', credit_account: '2081' })
|
|
|
|
await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, mapping, 'Aktiekapital enligt stiftelseurkund')
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(input.description).toBe(
|
|
'Insättning · Aktiekapital enligt stiftelseurkund · Affärshändelse 2026-03-10, bokförd på räkenskapsårets första dag'
|
|
)
|
|
})
|
|
|
|
it('does NOT clamp when the date is inside or after existing periods (interior gap / future date)', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
// Earliest period starts BEFORE the transaction date: the missing period is
|
|
// a gap or a not-yet-created later year, never a pre-FY case.
|
|
enqueue({ data: [{ ...openFirstPeriod, period_start: '2025-01-01' }] })
|
|
const tx = makeTransaction({ date: '2026-03-10', amount: -100 })
|
|
const mapping = makeMappingResult()
|
|
|
|
const result = await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
expect(result).toBeNull()
|
|
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns null when the earliest period is closed', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [{ ...openFirstPeriod, is_closed: true }] })
|
|
const tx = makeTransaction({ date: '2026-03-10', amount: -100 })
|
|
const mapping = makeMappingResult()
|
|
|
|
const result = await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
expect(result).toBeNull()
|
|
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns null when the earliest period is locked', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [{ ...openFirstPeriod, locked_at: '2026-06-01T00:00:00Z' }] })
|
|
const tx = makeTransaction({ date: '2026-03-10', amount: -100 })
|
|
const mapping = makeMappingResult()
|
|
|
|
const result = await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
expect(result).toBeNull()
|
|
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('never queries fiscal_periods when the transaction date has an open period', async () => {
|
|
mockedFindFiscalPeriod.mockResolvedValue('period-1')
|
|
const tx = makeTransaction({ date: '2026-07-01', amount: -100 })
|
|
const mapping = makeMappingResult()
|
|
|
|
// null supabase: any query would throw, proving the clamp path is dormant.
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(input.entry_date).toBe('2026-07-01')
|
|
expect(input.fiscal_period_id).toBe('period-1')
|
|
})
|
|
})
|
|
|
|
// The exported builder feeds the staged categorization preview (MCP
|
|
// preview_data.lines and the pending-operations PATCH re-derive) — these
|
|
// tests pin that what a user approves is the netted entry, not the
|
|
// gross-on-cost-account summary that used to mislead users and agents.
|
|
describe('buildTransactionEntryLines', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockedFindFiscalPeriod.mockResolvedValue('period-1')
|
|
})
|
|
|
|
it('nets the cost line against input VAT with öre precision', () => {
|
|
const tx = makeTransaction({ amount: -312.53, description: 'Mjukvaruabonnemang' })
|
|
const vatLines: VatJournalLine[] = [
|
|
{ account_number: '2641', debit_amount: 62.51, credit_amount: 0, description: 'Ingående moms (enligt underlag)' },
|
|
]
|
|
const mapping = makeMappingResult({
|
|
debit_account: '5420',
|
|
credit_account: '1930',
|
|
vat_lines: vatLines,
|
|
})
|
|
|
|
const lines = buildTransactionEntryLines(tx, mapping)
|
|
|
|
expect(lines).toHaveLength(3)
|
|
expect(lines.find(l => l.account_number === '5420')?.debit_amount).toBe(250.02) // 312.53 - 62.51
|
|
expect(lines.find(l => l.account_number === '2641')?.debit_amount).toBe(62.51)
|
|
expect(lines.find(l => l.account_number === '1930')?.credit_amount).toBe(312.53)
|
|
|
|
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
|
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
|
expect(totalDebit).toBeCloseTo(totalCredit, 2)
|
|
})
|
|
|
|
it('matches the lines createTransactionJournalEntry posts (preview == booked)', async () => {
|
|
const tx = makeTransaction({ amount: -1250, description: 'Software license' })
|
|
const mapping = makeMappingResult({
|
|
debit_account: '5420',
|
|
credit_account: '1930',
|
|
vat_lines: [
|
|
{ account_number: '2641', debit_amount: 250, credit_amount: 0, description: 'Ingående moms 25%' },
|
|
],
|
|
})
|
|
|
|
const previewLines = buildTransactionEntryLines(tx, mapping)
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(previewLines).toEqual(input.lines)
|
|
})
|
|
|
|
it('throws InvalidMappingResultError on missing accounts', () => {
|
|
const tx = makeTransaction()
|
|
expect(() => buildTransactionEntryLines(tx, makeMappingResult({ debit_account: '' })))
|
|
.toThrow('Invalid mapping result')
|
|
expect(() => buildTransactionEntryLines(tx, makeMappingResult({ credit_account: '' })))
|
|
.toThrow('Invalid mapping result')
|
|
})
|
|
|
|
it('nets SEK-denominated VAT against the SEK gross for foreign income (Stripe USD)', () => {
|
|
// MCP feedback seq 254607: vat_lines from the mapping are SEK, the gross
|
|
// resolves via amount_sek. 79.34 USD at 9.51 = 754.52 kr gross, 150.92 kr
|
|
// utgående moms; the revenue line takes the SEK net, and the entry
|
|
// balances in kronor.
|
|
const tx = makeTransaction({
|
|
amount: 79.34, currency: 'USD', amount_sek: 754.52, exchange_rate: 9.51,
|
|
description: 'STRIPE PAYOUT',
|
|
})
|
|
const mapping = makeMappingResult({
|
|
debit_account: '1930',
|
|
credit_account: '3001',
|
|
vat_lines: [
|
|
{ account_number: '2611', debit_amount: 0, credit_amount: 150.92, description: 'Utgående moms (enligt underlag)' },
|
|
],
|
|
})
|
|
|
|
const lines = buildTransactionEntryLines(tx, mapping)
|
|
|
|
expect(lines.find(l => l.account_number === '1930')?.debit_amount).toBe(754.52)
|
|
expect(lines.find(l => l.account_number === '3001')?.credit_amount).toBe(603.6) // 754.52 - 150.92
|
|
expect(lines.find(l => l.account_number === '2611')?.credit_amount).toBe(150.92)
|
|
|
|
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
|
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
|
expect(totalDebit).toBeCloseTo(totalCredit, 2)
|
|
})
|
|
})
|
|
|
|
describe('buildDomesticExpenseLines', () => {
|
|
it('25% VAT: 3 lines (expense net + 2641 + 1930)', () => {
|
|
const lines = buildDomesticExpenseLines(1250, '5410', 'Office supplies', 0.25)
|
|
|
|
expect(lines).toHaveLength(3)
|
|
|
|
const expense = lines.find(l => l.account_number === '5410')
|
|
expect(expense?.debit_amount).toBe(1000) // 1250 / 1.25
|
|
|
|
const vat = lines.find(l => l.account_number === '2641')
|
|
expect(vat?.debit_amount).toBe(250) // 1250 - 1000
|
|
|
|
const bank = lines.find(l => l.account_number === '1930')
|
|
expect(bank?.credit_amount).toBe(1250)
|
|
|
|
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
|
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
|
expect(totalDebit).toBeCloseTo(totalCredit, 2)
|
|
})
|
|
|
|
it('12% VAT: correct amounts', () => {
|
|
const lines = buildDomesticExpenseLines(1120, '5400', 'Food supplies', 0.12)
|
|
|
|
expect(lines).toHaveLength(3)
|
|
|
|
const expense = lines.find(l => l.account_number === '5400')
|
|
expect(expense?.debit_amount).toBe(1000) // 1120 / 1.12
|
|
|
|
const vat = lines.find(l => l.account_number === '2641')
|
|
expect(vat?.debit_amount).toBe(120) // 1120 - 1000
|
|
|
|
const bank = lines.find(l => l.account_number === '1930')
|
|
expect(bank?.credit_amount).toBe(1120)
|
|
|
|
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
|
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
|
expect(totalDebit).toBeCloseTo(totalCredit, 2)
|
|
})
|
|
|
|
it('vatRate=0: 2 lines, no 2641', () => {
|
|
const lines = buildDomesticExpenseLines(500, '5410', 'No VAT expense', 0)
|
|
|
|
expect(lines).toHaveLength(2)
|
|
|
|
const expense = lines.find(l => l.account_number === '5410')
|
|
expect(expense?.debit_amount).toBe(500)
|
|
|
|
const bank = lines.find(l => l.account_number === '1930')
|
|
expect(bank?.credit_amount).toBe(500)
|
|
|
|
const vatLine = lines.find(l => l.account_number === '2641')
|
|
expect(vatLine).toBeUndefined()
|
|
|
|
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
|
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
|
expect(totalDebit).toBe(totalCredit)
|
|
})
|
|
|
|
it('negative amount uses Math.abs', () => {
|
|
const lines = buildDomesticExpenseLines(-750, '5410', 'Negative test', 0)
|
|
|
|
expect(lines).toHaveLength(2)
|
|
|
|
const expense = lines.find(l => l.account_number === '5410')
|
|
expect(expense?.debit_amount).toBe(750)
|
|
|
|
const bank = lines.find(l => l.account_number === '1930')
|
|
expect(bank?.credit_amount).toBe(750)
|
|
|
|
// All amounts positive
|
|
for (const line of lines) {
|
|
expect(line.debit_amount).toBeGreaterThanOrEqual(0)
|
|
expect(line.credit_amount).toBeGreaterThanOrEqual(0)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('createTransactionJournalEntry: dimensions propagation (PR7)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockedFindFiscalPeriod.mockResolvedValue('period-1')
|
|
})
|
|
|
|
it('expense: tags ONLY the business debit line: bank and VAT lines stay untagged', async () => {
|
|
const tx = makeTransaction({ amount: -1250, description: 'Software license' })
|
|
const vatLines: VatJournalLine[] = [
|
|
{ account_number: '2641', debit_amount: 250, credit_amount: 0, description: 'Ingående moms 25%' },
|
|
]
|
|
const mapping = makeMappingResult({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
vat_lines: vatLines,
|
|
dimensions: { '1': 'KS01', '6': 'P001' },
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
|
|
const debit5410 = input.lines.find(l => l.account_number === '5410')
|
|
expect(debit5410?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
|
|
|
|
expect(input.lines.find(l => l.account_number === '2641')?.dimensions).toBeUndefined()
|
|
expect(input.lines.find(l => l.account_number === '1930')?.dimensions).toBeUndefined()
|
|
})
|
|
|
|
it('income: tags ONLY the revenue credit line: bank and output-VAT lines stay untagged', async () => {
|
|
const tx = makeTransaction({ amount: 12500, description: 'Sales income' })
|
|
const vatLines: VatJournalLine[] = [
|
|
{ account_number: '2611', debit_amount: 0, credit_amount: 2500, description: 'Utgående moms 25%' },
|
|
]
|
|
const mapping = makeMappingResult({
|
|
debit_account: '1930',
|
|
credit_account: '3001',
|
|
vat_lines: vatLines,
|
|
dimensions: { '6': 'P001' },
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
|
|
expect(input.lines.find(l => l.account_number === '3001')?.dimensions).toEqual({ '6': 'P001' })
|
|
expect(input.lines.find(l => l.account_number === '2611')?.dimensions).toBeUndefined()
|
|
expect(input.lines.find(l => l.account_number === '1930')?.dimensions).toBeUndefined()
|
|
})
|
|
|
|
it('all_lines_complete: each vat_lines[i].dimensions is used per line, with NO fallback to mappingResult.dimensions', async () => {
|
|
const tx = makeTransaction({ amount: -1250, description: 'Multi-line pattern' })
|
|
const vatLines: VatJournalLine[] = [
|
|
{ account_number: '5410', debit_amount: 1000, credit_amount: 0, description: 'Kostnad', dimensions: { '6': 'P001' } },
|
|
// No dimensions on the VAT line: must NOT inherit the categorize-level bag.
|
|
{ account_number: '2641', debit_amount: 250, credit_amount: 0, description: 'Ingående moms' },
|
|
]
|
|
const mapping = makeMappingResult({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
all_lines_complete: true,
|
|
vat_lines: vatLines,
|
|
// Would mis-tag the VAT line if any fallback existed.
|
|
dimensions: { '1': 'LEAK' },
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(input.lines).toHaveLength(3)
|
|
|
|
expect(input.lines.find(l => l.account_number === '5410')?.dimensions).toEqual({ '6': 'P001' })
|
|
expect(input.lines.find(l => l.account_number === '2641')?.dimensions).toBeUndefined()
|
|
// Settlement line stays untagged.
|
|
expect(input.lines.find(l => l.account_number === '1930')?.dimensions).toBeUndefined()
|
|
|
|
assertBalanced(input)
|
|
})
|
|
|
|
it('all_lines_complete income: per-line bags authoritative on the credit side too', async () => {
|
|
const tx = makeTransaction({ amount: 12500, description: 'Multi-line income' })
|
|
const vatLines: VatJournalLine[] = [
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 10000, description: 'Försäljning', dimensions: { '1': 'KS01' } },
|
|
{ account_number: '2611', debit_amount: 0, credit_amount: 2500, description: 'Utgående moms' },
|
|
]
|
|
const mapping = makeMappingResult({
|
|
debit_account: '1930',
|
|
credit_account: '3001',
|
|
all_lines_complete: true,
|
|
vat_lines: vatLines,
|
|
dimensions: { '1': 'LEAK' },
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
expect(input.lines.find(l => l.account_number === '3001')?.dimensions).toEqual({ '1': 'KS01' })
|
|
expect(input.lines.find(l => l.account_number === '2611')?.dimensions).toBeUndefined()
|
|
expect(input.lines.find(l => l.account_number === '1930')?.dimensions).toBeUndefined()
|
|
})
|
|
|
|
it('default_private path never tags: even when a bag is set on the mapping', async () => {
|
|
const tx = makeTransaction({ amount: -500, description: 'Lunch privat' })
|
|
const mapping = makeMappingResult({
|
|
debit_account: '2013',
|
|
credit_account: '1930',
|
|
default_private: true,
|
|
dimensions: { '1': 'KS01' },
|
|
})
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
for (const line of input.lines) {
|
|
expect(line.dimensions).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
it('no bag on the mapping → business line stays untagged', async () => {
|
|
const tx = makeTransaction({ amount: -299 })
|
|
const mapping = makeMappingResult({ debit_account: '5410', credit_account: '1930' })
|
|
|
|
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
|
|
|
const input = mockedCreateEntry.mock.calls[0][3]
|
|
for (const line of input.lines) {
|
|
expect(line.dimensions).toBeUndefined()
|
|
}
|
|
})
|
|
})
|