Files
accounted/lib/bookkeeping/__tests__/invoice-entries.test.ts
T
MattssonandClaude Fable 5.1 2d49a81508 fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts (#2439)
* fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts

A supplier-invoice item with a negative line_total (an öresavrundning row on
3740, a rabatt row) was copied straight into debit_amount, producing a line
like "3740 debit -0.25". The entry balances arithmetically, so no trigger
fired, but the verifikat page renders only positive amounts: the row showed
empty and the visible debits (20 056,25) disagreed with the summa (20 056,00).
Prod holds 14 such lines: 12 supplier registrations in 3 companies, 1
customer invoice (3004 credit -0.50), 1 storno mirroring a bad original.

Why it occurred: the "one non-negative side per line" invariant lived
nowhere. Zod allows negative items (they are legitimate), the engine only
checked balance, and journal_entry_lines had no CHECK. Any producer that
aggregates user rows could repeat it.

What was removed or simplified: no new state. The privately-paid supplier
path already flipped negative buckets to credit; that rule is now one
helper (lib/bookkeeping/line-side.ts) shared by the supplier registration,
cash-method and privately-paid generators and by the customer-invoice
per-rate generator. The credit-note generator stops swapping sides and
takes |net|, since its inputs now arrive on the correct side.

Why this and not the proposed fix: patching only the supplier generator
leaves MCP, templates and future producers free to repeat the class, and
rejecting negative items at input would break real rabatt/avrundning rows.
So the sign is fixed at three levels: producers flip the side, the engine
refuses negative amounts before any write (JOURNAL_LINE_NEGATIVE_AMOUNT,
Swedish message), and a NOT VALID CHECK on journal_entry_lines rejects new
rows regardless of the writer. reverseEntry swaps on the net so a legacy
negative line stornos into a well-formed line before the data repair runs.

The 14 existing prod lines are repaired by a separate founder-approved SQL
(flip to the opposite column, net unchanged); VALIDATE CONSTRAINT follows
in a later migration once prod reports zero offending rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMJvTFitzKQYuv7ABUVFj9

* fix(bookkeeping): anchor foreign-currency 1510/1930 on the net of the revenue lines; flip salary buckets by side

Skeptic findings on ab119d6ed:

1. A non-SEK customer invoice with a negative row (rabatt, avrundning on a
   separate revenue account) now lands that row on the debit side, but the
   1510 (accrual) and 1930 (kontantmetod) anchors summed only credit_amount,
   so the entry was overstated by the row and threw "Verifikationen
   balanserar inte". Both anchors now use credit - debit. EUR test added for
   both paths.

2. Salary: arbetsgivaravgifter, semesteravsättning, pension and SLP buckets
   copied bucket.amount into debit_amount and the aggregated liability into
   credit_amount. A negative month (unpaid leave beyond gross) produced
   7510 D -628,40, which the engine now refuses. Buckets and liabilities go
   through debitNatural/creditNatural so a negative month books 7510 K /
   2731 D. Test added.

3. replaceOpeningBalanceEntry, the third engine write path, now runs the
   same non-negative guard as createDraftEntry and updateDraftEntry.

4. The credit-note comment claimed |net| is side-correct for every original;
   it is not for originals with a negative row (pre-existing, callers negate
   items with -Math.abs). Comment now states the actual behaviour and the
   known gap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(bookkeeping): map JOURNAL_LINE_NEGATIVE_AMOUNT to a structured 400; supplier anchors flip side when the invoice nets below zero

CodeRabbit on #2439:

- JournalLineNegativeAmountError was not registered in isBookkeepingError /
  bookkeepingErrorResponse, so the journal-entry routes would have returned
  a generic 500 instead of the structured 400 with code and details. Added,
  with a test.

- The three supplier balance anchors (2440 on registration, the payment
  account under kontantmetoden, the liability account for privately paid
  invoices) were fixed-credit lines. An invoice whose rows net below zero
  (a leverantörskreditfaktura keyed in as an invoice) produced a negative
  credit there, which the engine now refuses. The anchors go through
  creditNatural so such an invoice books 2440 D, as a supplier credit note
  would. Tests for all three paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:10:34 +02:00

2064 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getRevenueAccount, getOutputVatAccount } from '../invoice-entries'
import { roundOre } from '@/lib/money'
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 (_supabase: unknown, _companyId: string, _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,
createInvoicePaymentJournalEntry,
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', () => {
expect(getRevenueAccount('standard_25')).toBe('3001')
})
it('reduced_12 returns 3002', () => {
expect(getRevenueAccount('reduced_12')).toBe('3002')
})
it('reduced_6 returns 3003', () => {
expect(getRevenueAccount('reduced_6')).toBe('3003')
})
it('reverse_charge returns 3308', () => {
expect(getRevenueAccount('reverse_charge')).toBe('3308')
})
it('export returns 3305', () => {
expect(getRevenueAccount('export')).toBe('3305')
})
it('exempt defaults to 3100 for enskild_firma', () => {
expect(getRevenueAccount('exempt')).toBe('3100')
expect(getRevenueAccount('exempt', 'enskild_firma')).toBe('3100')
})
it('exempt returns 3004 for aktiebolag', () => {
expect(getRevenueAccount('exempt', 'aktiebolag')).toBe('3004')
})
it('entityType does not affect non-exempt treatments', () => {
expect(getRevenueAccount('standard_25', 'aktiebolag')).toBe('3001')
expect(getRevenueAccount('reduced_12', 'aktiebolag')).toBe('3002')
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: negative item rows keep one non-negative side', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('books a negative 0 % row (avrundning on 3740) as a debit, not a negative credit', async () => {
const invoice = makeInvoice({
subtotal: 999.5,
vat_amount: 250,
total: 1249.5,
vat_treatment: 'standard_25',
items: [
makeItem({ line_total: 1000, vat_rate: 25, vat_amount: 250 }),
makeItem({ id: 'item-2', description: 'Öresavrundning', unit_price: -0.5, line_total: -0.5, vat_rate: 0, vat_amount: 0, revenue_account: '3740' }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice, 'aktiebolag')
const input = mockedCreateEntry.mock.calls[0][3]
const rounding = input.lines.find((l) => l.account_number === '3740')
expect(rounding?.debit_amount).toBe(0.5)
expect(rounding?.credit_amount).toBe(0)
expect(input.lines.find((l) => l.account_number === '1510')?.debit_amount).toBe(1249.5)
for (const l of input.lines) {
expect(l.debit_amount).toBeGreaterThanOrEqual(0)
expect(l.credit_amount).toBeGreaterThanOrEqual(0)
}
const totalDebit = input.lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = input.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(roundOre(totalDebit)).toBe(roundOre(totalCredit))
})
})
describe('foreign-currency invoices with a negative row derive the debit from the NET of the credit lines', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// EUR 1000 @25 % plus a -10 rabatt row on 3740, rate 10: the receivable is
// the NET of the credit lines (12 400), not the sum of the positive ones.
const eurInvoice = () =>
makeInvoice({
currency: 'EUR',
exchange_rate: 10,
subtotal: 990,
vat_amount: 250,
total: 1240,
vat_treatment: 'standard_25',
items: [
makeItem({ line_total: 1000, vat_rate: 25, vat_amount: 250 }),
makeItem({ id: 'item-2', description: 'Rabatt', unit_price: -10, line_total: -10, vat_rate: 0, vat_amount: 0, revenue_account: '3740' }),
],
})
function expectNetBalanced(lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>, arAccount: string) {
const rabatt = lines.find((l) => l.account_number === '3740')
expect(rabatt).toMatchObject({ debit_amount: 100, credit_amount: 0 })
expect(lines.find((l) => l.account_number === arAccount)?.debit_amount).toBe(12400)
const totalDebit = lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = lines.reduce((s, l) => s + l.credit_amount, 0)
expect(roundOre(totalDebit)).toBe(roundOre(totalCredit))
for (const l of lines) {
expect(l.debit_amount).toBeGreaterThanOrEqual(0)
expect(l.credit_amount).toBeGreaterThanOrEqual(0)
}
}
it('createInvoiceJournalEntry: 1510 = 12 400, entry balances', async () => {
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', eurInvoice(), 'aktiebolag')
expectNetBalanced(mockedCreateEntry.mock.calls[0][3].lines, '1510')
})
it('createInvoiceCashEntry: 1930 = 12 400, entry balances', async () => {
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', eurInvoice(), '2024-07-01', 'aktiebolag')
expectNetBalanced(mockedCreateEntry.mock.calls[0][3].lines, '1930')
})
})
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(null as never, 'company-1', 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][3]
// 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(null as never, 'company-1', 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][3]
// 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(null as never, 'company-1', 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][3]
// 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(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
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('createInvoiceJournalEntry: per-article revenue account override', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('without an override, two 25% lines collapse into one 3001 revenue line (unchanged behaviour)', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
vat_rate: null as unknown as number,
items: [
makeItem({ description: 'A', unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150 }),
makeItem({ id: 'item-2', description: 'B', unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100 }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const rev3001 = input.lines.filter((l) => l.account_number === '3001')
expect(rev3001).toHaveLength(1)
expect(rev3001[0].credit_amount).toBe(1000)
const vat2611 = input.lines.filter((l) => l.account_number === '2611')
expect(vat2611).toHaveLength(1)
expect(vat2611[0].credit_amount).toBe(250)
})
it('splits one rate into two revenue accounts but keeps a single VAT line, balanced', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
vat_rate: null as unknown as number,
items: [
makeItem({ description: 'Goods', unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150 }), // no override → 3001
makeItem({ id: 'item-2', description: 'Consulting', unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100, revenue_account: '3041' }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '3001')?.credit_amount).toBe(600)
expect(input.lines.find((l) => l.account_number === '3041')?.credit_amount).toBe(400)
const vat = input.lines.filter((l) => l.account_number === '2611')
expect(vat).toHaveLength(1)
expect(vat[0].credit_amount).toBe(250)
const debit = input.lines.reduce((s, l) => s + l.debit_amount, 0)
const credit = input.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(debit).toBe(credit)
expect(debit).toBe(1250)
})
it('credits a selected liability account without treating the principal as revenue', async () => {
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 0,
total: 10000,
vat_treatment: 'exempt',
vat_rate: 0,
items: [
makeItem({
description: 'Återbetalningsbar deposition',
unit_price: 10000,
line_total: 10000,
vat_rate: 0,
vat_amount: 0,
revenue_account: '2897',
}),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((line) => line.account_number === '1510')?.debit_amount).toBe(10000)
expect(input.lines.find((line) => line.account_number === '2897')?.credit_amount).toBe(10000)
expect(input.lines.some((line) => line.account_number.startsWith('3'))).toBe(false)
expect(input.lines.some((line) => line.account_number.startsWith('26'))).toBe(false)
})
it('ignores a per-line override on reverse charge: revenue stays on 3308', async () => {
const invoice = makeInvoice({
subtotal: 5000,
vat_amount: 0,
total: 5000,
vat_treatment: 'reverse_charge',
vat_rate: 0,
items: [
makeItem({ unit_price: 5000, line_total: 5000, vat_rate: 0, vat_amount: 0, revenue_account: '3041' }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '3308')?.credit_amount).toBe(5000)
expect(input.lines.find((l) => l.account_number === '3041')).toBeUndefined()
})
it('absorbs rounding on the last account so a split rate still balances against 1510', async () => {
// Two 25% lines to different accounts whose individual SEK rounding would
// otherwise drift from the rate-level total (10.005 → 10.01 each = 20.02,
// but the rate total is round(20.01) = 20.01).
const invoice = makeInvoice({
subtotal: 20.01,
vat_amount: 5.0,
total: 25.01,
vat_treatment: 'standard_25',
vat_rate: null as unknown as number,
items: [
makeItem({ description: 'A', unit_price: 10.005, line_total: 10.005, vat_rate: 25, vat_amount: 2.5 }),
makeItem({ id: 'item-2', description: 'B', unit_price: 10.005, line_total: 10.005, vat_rate: 25, vat_amount: 2.5, revenue_account: '3041' }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const revSum = input.lines
.filter((l) => l.account_number === '3001' || l.account_number === '3041')
.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.round(revSum * 100) / 100).toBe(20.01)
const debit = Math.round(input.lines.reduce((s, l) => s + l.debit_amount, 0) * 100) / 100
const credit = Math.round(input.lines.reduce((s, l) => s + l.credit_amount, 0) * 100) / 100
expect(debit).toBe(credit)
expect(debit).toBe(25.01)
})
})
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(null as never, 'company-1', 'user-1', creditNote)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][3]
// 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)
})
it.each([
// Deduction base is the line total INCL. VAT (HUSFL 6-9 §§):
// rot 30% x 12 500 = 3 750, rut 50% x 12 500 = 6 250.
['rot' as const, 3750, 8750],
['rut' as const, 6250, 6250],
])('reverses the %s receivable split across 1510 and 1513', async (deductionType, taxCredit, customerCredit) => {
const creditNote = makeInvoice({
invoice_number: 'KR-1002',
subtotal: -10000,
vat_amount: -2500,
total: -12500,
vat_treatment: 'standard_25',
items: [
makeItem({
quantity: -1,
unit_price: 10000,
line_total: -10000,
vat_rate: 25,
vat_amount: -2500,
deduction_type: deductionType,
dimensions: { '6': 'P001' },
}),
],
default_dimensions: { '1': 'KS01' },
})
await createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((line) => line.account_number === '1510')?.credit_amount)
.toBe(customerCredit)
const line1513 = input.lines.find((line) => line.account_number === '1513')
expect(line1513?.credit_amount).toBe(taxCredit)
expect(line1513?.debit_amount).toBe(0)
expect(line1513?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const totalDebit = input.lines.reduce((sum, line) => sum + line.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, line) => sum + line.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(null as never, 'company-1', 'user-1', invoice, '2024-07-01')
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][3]
// 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)
})
it('cash method credits a selected liability account at payment', async () => {
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 0,
total: 10000,
vat_treatment: 'exempt',
items: [
makeItem({
description: 'Återbetalningsbar deposition',
unit_price: 10000,
line_total: 10000,
vat_rate: 0,
vat_amount: 0,
revenue_account: '2897',
}),
],
})
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((line) => line.account_number === '1930')?.debit_amount).toBe(10000)
expect(input.lines.find((line) => line.account_number === '2897')?.credit_amount).toBe(10000)
expect(input.lines.some((line) => line.account_number.startsWith('3'))).toBe(false)
})
})
describe('createInvoiceJournalEntry: EUR foreign currency', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('EUR invoice converts amounts to SEK using exchange rate', async () => {
// EUR 1,000 + EUR 250 VAT = EUR 1,250 total, rate 11.5
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
subtotal: 1000,
subtotal_sek: 11500,
vat_amount: 250,
vat_amount_sek: 2875,
total: 1250,
total_sek: 14375,
vat_treatment: 'standard_25',
items: [
makeItem({ line_total: 1000, vat_rate: 25, vat_amount: 250 }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][3]
// All amounts should be in SEK
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(14375) // 1000*11.5 + 250*11.5 = 14375
const credit3001 = input.lines.find((l) => l.account_number === '3001')
expect(credit3001?.credit_amount).toBe(11500) // 1000 * 11.5
const credit2611 = input.lines.find((l) => l.account_number === '2611')
expect(credit2611?.credit_amount).toBe(2875) // 250 * 11.5
// 1510 line should have currency metadata
expect(debit1510?.currency).toBe('EUR')
expect(debit1510?.amount_in_currency).toBe(1250)
expect(debit1510?.exchange_rate).toBe(11.5)
// 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)
})
it('EUR invoice uses total_sek when available', async () => {
// Edge case: total_sek differs slightly from computed (e.g. pre-computed at different rate)
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
subtotal: 1000,
subtotal_sek: null,
vat_amount: 0,
vat_amount_sek: null,
total: 1000,
total_sek: null,
vat_treatment: 'export',
items: [
makeItem({ line_total: 1000, vat_rate: 0, vat_amount: 0 }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
// Revenue should be computed via exchange rate
const credit3305 = input.lines.find((l) => l.account_number === '3305')
expect(credit3305?.credit_amount).toBe(11500)
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(11500)
})
it('SEK invoice still works unchanged (backward compatibility)', async () => {
const invoice = makeInvoice({
subtotal: 800,
vat_amount: 200,
total: 1000,
vat_treatment: 'standard_25',
items: [
makeItem({ line_total: 800, vat_rate: 25, vat_amount: 200 }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(1000)
// No currency metadata for SEK
expect(debit1510?.currency).toBeUndefined()
expect(debit1510?.amount_in_currency).toBeUndefined()
})
})
/**
* A foreign invoice with no exchange rate has no SEK value at item granularity
* (InvoiceItem has no *_sek column). The old fallback returned the raw foreign
* amount, and since the 1510 debit is derived from the sum of the credits on the
* FX branch, the verifikation still balanced: 1 000 EUR posted as 1 000 kr to
* 3001 and 250 kr to 2611 instead of 11 500 kr and 2 875 kr, understating ruta
* 05 and ruta 10. Nothing downstream could detect it, so the generator refuses.
*/
describe('foreign currency without an exchange rate is refused, not relabelled', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function eurInvoiceWithoutRate(overrides: Partial<Invoice> = {}): Invoice {
return makeInvoice({
currency: 'EUR',
exchange_rate: null,
subtotal: 1000,
subtotal_sek: null,
vat_amount: 250,
vat_amount_sek: null,
total: 1250,
total_sek: null,
vat_treatment: 'standard_25',
items: [makeItem({ line_total: 1000, vat_rate: 25, vat_amount: 250 })],
...overrides,
})
}
it('createInvoiceJournalEntry throws INVOICE_FX_RATE_MISSING and posts nothing', async () => {
await expect(
createInvoiceJournalEntry(null as never, 'company-1', 'user-1', eurInvoiceWithoutRate())
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING', currency: 'EUR' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('refuses a zero exchange rate the same way', async () => {
await expect(
createInvoiceJournalEntry(
null as never,
'company-1',
'user-1',
eurInvoiceWithoutRate({ exchange_rate: 0 })
)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createInvoiceCashEntry refuses too', async () => {
await expect(
createInvoiceCashEntry(
null as never,
'company-1',
'user-1',
eurInvoiceWithoutRate(),
'2024-06-20'
)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createCreditNoteJournalEntry refuses too', async () => {
const creditNote = eurInvoiceWithoutRate({
credited_invoice_id: 'inv-original',
subtotal: -1000,
vat_amount: -250,
total: -1250,
items: [makeItem({ line_total: -1000, quantity: -1, vat_rate: 25, vat_amount: -250 })],
})
await expect(
createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
// generateRotRutLines has its own conversion; 1513 is a kronor receivable on
// Skatteverket, so it must refuse on the same terms. Note that
// generatePerRateLines runs first and refuses first, so this asserts the
// outcome for a ROT invoice; the positive case below is what proves the 1513
// leg itself goes through the honest ladder rather than a private fallback.
it('a ROT invoice in EUR without a rate is refused, nothing lands on 1513', async () => {
const invoice = eurInvoiceWithoutRate({
items: [
makeItem({
line_total: 1000,
unit_price: 1000,
quantity: 1,
vat_rate: 25,
vat_amount: 250,
deduction_type: 'rot',
} as Partial<InvoiceItem>),
],
})
await expect(
createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('the ROT 1513 leg converts at the rate and the split still balances', async () => {
// 1 000 EUR labour + 25% VAT = 1 250 EUR incl. moms; ROT is 30% of the
// inkl.-moms labour = 375 EUR = 4 312,50 kr at 11,50 on 1513, so 1510
// carries 14 375 - 4 312,50 = 10 062,50 kr.
const invoice = eurInvoiceWithoutRate({
exchange_rate: 11.5,
items: [
makeItem({
line_total: 1000,
unit_price: 1000,
quantity: 1,
vat_rate: 25,
vat_amount: 250,
deduction_type: 'rot',
} as Partial<InvoiceItem>),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1513 = input.lines.find((l) => l.account_number === '1513')
expect(debit1513?.debit_amount).toBe(4312.5)
expect(input.lines.find((l) => l.account_number === '1510')?.debit_amount).toBe(10062.5)
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(Math.round(totalDebit * 100)).toBe(Math.round(totalCredit * 100))
})
it('a SEK invoice with no exchange rate is completely unaffected', async () => {
const invoice = makeInvoice({
currency: 'SEK',
exchange_rate: null,
subtotal: 1000,
vat_amount: 250,
total: 1250,
items: [makeItem({ line_total: 1000, vat_rate: 25, vat_amount: 250 })],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
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(1250)
expect(totalDebit).toBe(totalCredit)
})
it('an EUR invoice WITH a rate still books, and still balances', async () => {
const invoice = eurInvoiceWithoutRate({ exchange_rate: 11.5 })
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '3001')?.credit_amount).toBe(11500)
expect(input.lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(2875)
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(14375)
expect(totalDebit).toBe(totalCredit)
})
})
/**
* The no-items header fallback and the payment entry used to convert through
* the lenient resolveSekAmount ladder (READ-ONLY CODE ONLY per currency-utils):
* a rate-less foreign invoice booked via a caller without hydrated items posted
* its raw foreign number as kronor (1 250 EUR → 1 250 kr on 1510), balanced and
* undetectable. They now refuse with the same INVOICE_FX_RATE_MISSING code as
* the item-driven generators. Rows that DO carry *_sek or a usable rate keep
* byte-identical numbers.
*/
describe('header fallback and payment path: strict FX conversion', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function eurHeaderOnlyInvoice(overrides: Partial<Invoice> = {}): Invoice {
return makeInvoice({
currency: 'EUR',
exchange_rate: null,
subtotal: 1000,
subtotal_sek: null,
vat_amount: 250,
vat_amount_sek: null,
total: 1250,
total_sek: null,
vat_treatment: 'standard_25',
items: [],
...overrides,
})
}
it('createInvoiceJournalEntry (no items, no rate, no *_sek) refuses instead of posting 1 250 EUR as 1 250 kr', async () => {
await expect(
createInvoiceJournalEntry(null as never, 'company-1', 'user-1', eurHeaderOnlyInvoice())
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING', currency: 'EUR' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createInvoiceJournalEntry (no items) books from *_sek columns even without a rate', async () => {
// Header rows carry SEK twins; those ARE an honest SEK source, so nothing
// changes for them: 11 500 + 2 875 credits, 14 375 debit on 1510.
const invoice = eurHeaderOnlyInvoice({
subtotal_sek: 11500,
vat_amount_sek: 2875,
total_sek: 14375,
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '3001')?.credit_amount).toBe(11500)
expect(input.lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(2875)
expect(input.lines.find((l) => l.account_number === '1510')?.debit_amount).toBe(14375)
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(Math.round(totalDebit * 100)).toBe(Math.round(totalCredit * 100))
})
it('createInvoiceJournalEntry (no items) with a rate books converted, balanced lines', async () => {
// 1 000 EUR + 250 EUR moms at 11,50: 11 500 + 2 875 = 14 375 on 1510.
const invoice = eurHeaderOnlyInvoice({ exchange_rate: 11.5 })
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '3001')?.credit_amount).toBe(11500)
expect(input.lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(2875)
expect(input.lines.find((l) => l.account_number === '1510')?.debit_amount).toBe(14375)
})
it('createInvoiceCashEntry (no items, no rate) refuses too', async () => {
await expect(
createInvoiceCashEntry(null as never, 'company-1', 'user-1', eurHeaderOnlyInvoice(), '2024-07-01')
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createCreditNoteJournalEntry (no items, no rate) refuses too', async () => {
const creditNote = eurHeaderOnlyInvoice({
invoice_number: 'KR-1001',
credited_invoice_id: 'inv-original',
subtotal: -1000,
vat_amount: -250,
total: -1250,
})
await expect(
createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createInvoicePaymentJournalEntry (full, no rate, no total_sek) refuses instead of clearing 1510 with a mislabelled number', async () => {
await expect(
createInvoicePaymentJournalEntry(
null as never, 'company-1', 'user-1', eurHeaderOnlyInvoice(), '2024-07-15'
)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING', currency: 'EUR' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createInvoicePaymentJournalEntry (partial, no rate) refuses: paymentAmount is in EUR and has no SEK source', async () => {
await expect(
createInvoicePaymentJournalEntry(
null as never, 'company-1', 'user-1', eurHeaderOnlyInvoice(), '2024-07-15',
undefined, undefined, 400,
)
).rejects.toMatchObject({ code: 'INVOICE_FX_RATE_MISSING' })
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('createInvoicePaymentJournalEntry (partial, with rate) converts the partial amount: 400 EUR at 11,50 = 4 600,00', async () => {
const invoice = eurHeaderOnlyInvoice({ exchange_rate: 11.5 })
await createInvoicePaymentJournalEntry(
null as never, 'company-1', 'user-1', invoice, '2024-07-15',
undefined, undefined, 400,
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
expect(input.lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(4600)
expect(input.lines.find((l) => l.account_number === '1510')?.credit_amount).toBe(4600)
})
it('createInvoicePaymentJournalEntry keeps identical numbers when total_sek is present without a rate', async () => {
const invoice = eurHeaderOnlyInvoice({ total_sek: 11500 })
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(11500)
expect(input.lines.find((l) => l.account_number === '1510')?.credit_amount).toBe(11500)
})
it('a SEK invoice without items or rate is completely unaffected', async () => {
const invoice = makeInvoice({
currency: 'SEK',
exchange_rate: null,
subtotal: 1000,
vat_amount: 250,
total: 1250,
items: [],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
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(1250)
expect(totalDebit).toBe(totalCredit)
})
})
describe('BFL-compliant descriptions with counterparty names', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('createInvoiceJournalEntry includes customer name in description', async () => {
const invoice = makeInvoice({
items: [makeItem()],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice, 'enskild_firma', 'Foretag AB')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kundfaktura 1001, Foretag AB')
})
it('createInvoiceJournalEntry falls back without customer name', async () => {
const invoice = makeInvoice({
items: [makeItem()],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice, 'enskild_firma')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kundfaktura 1001')
})
it('createInvoicePaymentJournalEntry includes customer name', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15', undefined, 'Foretag AB')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Inbetalning kundfaktura 1001, Foretag AB')
})
it('createInvoicePaymentJournalEntry falls back without customer name', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Inbetalning kundfaktura 1001')
})
it('createCreditNoteJournalEntry includes customer name', async () => {
const creditNote = makeInvoice({
invoice_number: 'KR-1001',
subtotal: -1000,
vat_amount: -250,
total: -1250,
items: [makeItem({ quantity: -1, line_total: -1000, vat_amount: -250 })],
})
await createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote, 'enskild_firma', 'Foretag AB')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kreditfaktura KR-1001, Foretag AB')
})
it('createInvoiceCashEntry includes customer name', async () => {
const invoice = makeInvoice({
items: [makeItem()],
})
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01', 'enskild_firma', 'Foretag AB')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kontantbetalning kundfaktura 1001, Foretag AB')
})
it('createInvoiceCashEntry falls back without customer name', async () => {
const invoice = makeInvoice({
items: [makeItem()],
})
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01', 'enskild_firma')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kontantbetalning kundfaktura 1001')
})
})
describe('createInvoicePaymentJournalEntry: exchange rate difference', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('SEK payment creates simple 2-line entry', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
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(1250)
const credit1510 = input.lines.find((l) => l.account_number === '1510')
expect(credit1510?.credit_amount).toBe(1250)
})
it('EUR payment with positive exchange rate difference (gain) creates 3 lines', async () => {
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
total_sek: 11500,
})
// Gain of 200 SEK (received more than booked)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15', 200)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(3)
// Debit 1930: actual SEK received = 11500 + 200 = 11700
const debit1930 = input.lines.find((l) => l.account_number === '1930')
expect(debit1930?.debit_amount).toBe(11700)
// Credit 1510: original booked amount
const credit1510 = input.lines.find((l) => l.account_number === '1510')
expect(credit1510?.credit_amount).toBe(11500)
// Credit 3960: exchange rate gain
const credit3960 = input.lines.find((l) => l.account_number === '3960')
expect(credit3960?.credit_amount).toBe(200)
// 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)
})
it('EUR payment with negative exchange rate difference (loss) creates 3 lines', async () => {
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
total_sek: 11500,
})
// Loss of 300 SEK (received less than booked)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15', -300)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(3)
// Debit 1930: actual SEK received = 11500 + (-300) = 11200
const debit1930 = input.lines.find((l) => l.account_number === '1930')
expect(debit1930?.debit_amount).toBe(11200)
// Credit 1510: original booked amount
const credit1510 = input.lines.find((l) => l.account_number === '1510')
expect(credit1510?.credit_amount).toBe(11500)
// Debit 7960: exchange rate loss
const debit7960 = input.lines.find((l) => l.account_number === '7960')
expect(debit7960?.debit_amount).toBe(300)
// 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('paymentAccount parameter (settlement-account resolution)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('createInvoicePaymentJournalEntry defaults the bank leg to 1930 when paymentAccount is omitted', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(1250)
})
it('createInvoicePaymentJournalEntry books the bank leg to the resolved account (SEK, no FX)', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(
null as never, 'company-1', 'user-1', invoice, '2024-07-15',
undefined, undefined, undefined, '1940',
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1940')?.debit_amount).toBe(1250)
expect(input.lines.find((l) => l.account_number === '1930')).toBeUndefined()
// 1510 credit is untouched by the payment-account override.
expect(input.lines.find((l) => l.account_number === '1510')?.credit_amount).toBe(1250)
})
it('createInvoicePaymentJournalEntry books the FX-branch bank leg to the resolved account, leaving 3960/1510 untouched', async () => {
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
total_sek: 11500,
})
await createInvoicePaymentJournalEntry(
null as never, 'company-1', 'user-1', invoice, '2024-07-15',
200, undefined, undefined, '1940',
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1940')?.debit_amount).toBe(11700)
expect(input.lines.find((l) => l.account_number === '1930')).toBeUndefined()
expect(input.lines.find((l) => l.account_number === '1510')?.credit_amount).toBe(11500)
expect(input.lines.find((l) => l.account_number === '3960')?.credit_amount).toBe(200)
})
it('createInvoiceCashEntry defaults the bank leg to 1930 when paymentAccount is omitted', async () => {
const invoice = makeInvoice({ total: 1198 })
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(1198)
})
it('createInvoiceCashEntry books the bank leg to the resolved account, leaving revenue/VAT credits untouched', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 198,
total: 1198,
vat_treatment: 'standard_25',
})
await createInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, '2024-07-01',
'enskild_firma', undefined, '1940',
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1940')?.debit_amount).toBe(1198)
expect(input.lines.find((l) => l.account_number === '1930')).toBeUndefined()
expect(input.lines.find((l) => l.account_number === '3001')?.credit_amount).toBe(1000)
expect(input.lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(198)
})
})
describe('createInvoiceJournalEntry: ROT/RUT-avdrag', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('single ROT line: 10 000 kr labor → 1513 debit 3 750, 1510 debit 8 750', async () => {
// 10 000 kr labor with 25% VAT = 12 500 total. ROT = 30% of the
// inkl.-moms labor (HUSFL 6-9 §§) = 30% of 12 500 = 3 750.
// Customer owes (12 500 - 3 750) = 8 750. Skatteverket pays 3 750.
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 2500,
total: 12500,
vat_treatment: 'standard_25',
items: [
makeItem({
quantity: 1,
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
deduction_type: 'rot',
deduction_amount: 3000,
}),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][3]
// Lines: 1510 (debit 8750) + 1513 (debit 3750) + 3001 (credit 10000) + 2611 (credit 2500)
expect(input.lines).toHaveLength(4)
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(8750)
const debit1513 = input.lines.find((l) => l.account_number === '1513')
expect(debit1513?.debit_amount).toBe(3750)
expect(debit1513?.credit_amount).toBe(0)
const credit3001 = input.lines.find((l) => l.account_number === '3001')
expect(credit3001?.credit_amount).toBe(10000)
const credit2611 = input.lines.find((l) => l.account_number === '2611')
expect(credit2611?.credit_amount).toBe(2500)
// Balance: 8750 + 3750 = 12500 = 10000 + 2500
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(12500)
})
it('discounted ROT line: 1513 books the NET-based deduction, matching the stored deduction_total', async () => {
// 10 000 kr labor, 20% rabatt → net 8 000 + 25% VAT 2 000 = 10 000.
// ROT = 30% of the NET inkl.-moms labor = 30% of 10 000 = 3 000, the same
// figure build-invoice-write stores on deduction_total and the payout
// request claims. Booking the gross (3 750) would strand 750 kr on 1513
// and push kundfordringar (1510) negative when the customer pays 7 000.
const invoice = makeInvoice({
subtotal: 8000,
vat_amount: 2000,
total: 10000,
vat_treatment: 'standard_25',
deduction_total: 3000,
items: [
makeItem({
quantity: 1,
unit_price: 10000,
discount_percent: 20,
line_total: 8000,
vat_rate: 25,
vat_amount: 2000,
deduction_type: 'rot',
deduction_amount: 3000,
}),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1513 = input.lines.find((l) => l.account_number === '1513')
expect(debit1513?.debit_amount).toBe(3000)
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(7000)
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)
})
it('mixed invoice: ROT line + non-deduction line, per-item handling', async () => {
// ROT line 10 000 (deduction 30% of 12 500 inkl. moms = 3 750) +
// non-deduction materials line 4 000.
// Total 14 000 + 25% VAT = 17 500. Customer owes 13 750. Skatteverket 3 750.
const invoice = makeInvoice({
subtotal: 14000,
vat_amount: 3500,
total: 17500,
vat_treatment: 'standard_25',
items: [
makeItem({
quantity: 1,
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
deduction_type: 'rot',
deduction_amount: 3000,
}),
makeItem({
id: 'item-2',
quantity: 1,
unit_price: 4000,
line_total: 4000,
vat_rate: 25,
vat_amount: 1000,
// No deduction
}),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
// Lines: 1510 (debit 13750) + 1513 (debit 3750) + 3001 (credit 14000) + 2611 (credit 3500)
expect(input.lines).toHaveLength(4)
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(13750)
const debit1513 = input.lines.find((l) => l.account_number === '1513')
expect(debit1513?.debit_amount).toBe(3750)
// Balance: 13750 + 3750 = 17500
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(17500)
})
it('RUT line with 50% rate: 5 000 kr → 1513 debit 3 125', async () => {
// 5 000 labor with 25% VAT = 6 250 total. RUT = 50% of the inkl.-moms
// labor = 50% of 6 250 = 3 125.
const invoice = makeInvoice({
subtotal: 5000,
vat_amount: 1250,
total: 6250,
vat_treatment: 'standard_25',
items: [
makeItem({
quantity: 1,
unit_price: 5000,
line_total: 5000,
vat_rate: 25,
vat_amount: 1250,
deduction_type: 'rut',
deduction_amount: 2500,
}),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1513 = input.lines.find((l) => l.account_number === '1513')
expect(debit1513?.debit_amount).toBe(3125)
expect(debit1513?.line_description).toMatch(/RUT/)
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(3125) // 6250 - 3125
// 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)
})
it('no deduction_type → no 1513 line, normal AR debit', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
items: [
makeItem({ quantity: 1, unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250 }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1513')).toBeUndefined()
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(1250)
})
it('two ROT lines: per-line 1513 debits sum to invoice deduction total', async () => {
// 6 000 + 4 000 labor @ 25%, both ROT 30% of the inkl.-moms line:
// 30% x 7 500 + 30% x 5 000 = 2 250 + 1 500 = 3 750 total.
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 2500,
total: 12500,
vat_treatment: 'standard_25',
items: [
makeItem({
quantity: 1,
unit_price: 6000,
line_total: 6000,
vat_rate: 25,
vat_amount: 1500,
deduction_type: 'rot',
deduction_amount: 1800,
}),
makeItem({
id: 'item-2',
quantity: 1,
unit_price: 4000,
line_total: 4000,
vat_rate: 25,
vat_amount: 1000,
deduction_type: 'rot',
deduction_amount: 1200,
}),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1513Lines = input.lines.filter((l) => l.account_number === '1513')
expect(debit1513Lines).toHaveLength(2)
const total1513 = debit1513Lines.reduce((sum, l) => sum + l.debit_amount, 0)
expect(total1513).toBe(3750)
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(8750) // 12500 - 3750
// 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)
})
})
describe('dimensions propagation (PR7): createInvoiceJournalEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('1510 + VAT lines carry the invoice default; revenue lines carry the merged item-over-default bag', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({ description: 'A', unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150, dimensions: { '6': 'P001' } }),
makeItem({ id: 'item-2', description: 'B', unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100, dimensions: { '1': 'KS02' } }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.dimensions).toEqual({ '1': 'KS01' })
const vat2611 = input.lines.filter((l) => l.account_number === '2611')
expect(vat2611).toHaveLength(1)
expect(vat2611[0].dimensions).toEqual({ '1': 'KS01' })
// Same vat_rate + account, DIFFERENT bags → separate revenue lines
// (aggregation identity = account + bag).
const rev3001 = input.lines.filter((l) => l.account_number === '3001')
expect(rev3001).toHaveLength(2)
expect(rev3001[0].credit_amount).toBe(600)
expect(rev3001[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(rev3001[1].credit_amount).toBe(400)
// The item bag wins per key over the invoice default.
expect(rev3001[1].dimensions).toEqual({ '1': 'KS02' })
})
it('items with the identical merged bag still aggregate into one revenue line', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({ description: 'A', unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150, dimensions: { '6': 'P001' } }),
makeItem({ id: 'item-2', description: 'B', unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100, dimensions: { '6': 'P001' } }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const rev3001 = input.lines.filter((l) => l.account_number === '3001')
expect(rev3001).toHaveLength(1)
expect(rev3001[0].credit_amount).toBe(1000)
expect(rev3001[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
})
it('per-rate rounding remainder is absorbed by the last dimension bucket (balanced against 1510)', async () => {
// Same account, same 25% rate: split only by the dimensions bag. The
// rate-level total (20.01) is the balance anchor; independent per-bucket
// rounding would give 2 × 10.01 = 20.02, so the last bucket must absorb.
const invoice = makeInvoice({
subtotal: 20.01,
vat_amount: 5.0,
total: 25.01,
vat_treatment: 'standard_25',
vat_rate: null as unknown as number,
items: [
makeItem({ description: 'A', unit_price: 10.005, line_total: 10.005, vat_rate: 25, vat_amount: 2.5 }),
makeItem({ id: 'item-2', description: 'B', unit_price: 10.005, line_total: 10.005, vat_rate: 25, vat_amount: 2.5, dimensions: { '6': 'P001' } }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const rev3001 = input.lines.filter((l) => l.account_number === '3001')
expect(rev3001).toHaveLength(2)
expect(rev3001[0].dimensions).toBeUndefined()
expect(rev3001[1].dimensions).toEqual({ '6': 'P001' })
const revSum = rev3001.reduce((s, l) => s + l.credit_amount, 0)
expect(roundOre(revSum)).toBe(20.01)
const debit = roundOre(input.lines.reduce((s, l) => s + l.debit_amount, 0))
const credit = roundOre(input.lines.reduce((s, l) => s + l.credit_amount, 0))
expect(debit).toBe(credit)
expect(debit).toBe(25.01)
})
it('ROT 1513 line carries the item merged bag', async () => {
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 2500,
total: 12500,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({
quantity: 1,
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
deduction_type: 'rot',
deduction_amount: 3000,
dimensions: { '6': 'P001' },
}),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1513 = input.lines.find((l) => l.account_number === '1513')
expect(debit1513?.debit_amount).toBe(3750)
expect(debit1513?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
// 1510 still carries the default only.
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.dimensions).toEqual({ '1': 'KS01' })
})
it('fallback path (no items) carries the invoice default on revenue + VAT lines', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01', '6': 'P001' },
items: [],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const rev3001 = input.lines.find((l) => l.account_number === '3001')
expect(rev3001?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const vat2611 = input.lines.find((l) => l.account_number === '2611')
expect(vat2611?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
})
it('no default and no item bags → line.dimensions stays undefined', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
items: [
makeItem({ unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250 }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1510 = input.lines.find((l) => l.account_number === '1510')
// toEqual ignores undefined-valued keys: the line shape is unchanged.
expect(debit1510).toEqual({
account_number: '1510',
debit_amount: 1250,
credit_amount: 0,
line_description: 'Faktura 1001',
})
expect(debit1510?.dimensions).toBeUndefined()
expect(input.lines.find((l) => l.account_number === '3001')?.dimensions).toBeUndefined()
expect(input.lines.find((l) => l.account_number === '2611')?.dimensions).toBeUndefined()
})
})
describe('dimensions propagation (PR7): createInvoicePaymentJournalEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('SEK payment: both 1930 and 1510 carry the invoice default', async () => {
const invoice = makeInvoice({ total: 1250, default_dimensions: { '1': 'KS01', '6': 'P001' } })
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
for (const line of input.lines) {
expect(line.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
}
})
it('FX payment: the 3960 kursvinst line carries the default too', async () => {
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
total_sek: 11500,
default_dimensions: { '6': 'P001' },
})
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15', 200)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(3)
for (const account of ['1930', '1510', '3960']) {
const line = input.lines.find((l) => l.account_number === account)
expect(line?.dimensions).toEqual({ '6': 'P001' })
}
})
it('FX loss payment: the 7960 kursförlust line carries the default too', async () => {
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
total_sek: 11500,
default_dimensions: { '6': 'P001' },
})
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15', -300)
const input = mockedCreateEntry.mock.calls[0][3]
const loss7960 = input.lines.find((l) => l.account_number === '7960')
expect(loss7960?.dimensions).toEqual({ '6': 'P001' })
})
it('no default bag → payment lines carry no dimensions', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
for (const line of input.lines) {
expect(line.dimensions).toBeUndefined()
}
})
})
describe('dimensions propagation (PR7): createInvoiceCashEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('1930 carries the default; revenue carries the merged item bag; VAT carries the default', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({ unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250, dimensions: { '6': 'P001' } }),
],
})
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01')
const input = mockedCreateEntry.mock.calls[0][3]
const debit1930 = input.lines.find((l) => l.account_number === '1930')
expect(debit1930?.dimensions).toEqual({ '1': 'KS01' })
const rev3001 = input.lines.find((l) => l.account_number === '3001')
expect(rev3001?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const vat2611 = input.lines.find((l) => l.account_number === '2611')
expect(vat2611?.dimensions).toEqual({ '1': 'KS01' })
})
})
describe('dimensions propagation (PR7): createCreditNoteJournalEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('swapped lines keep the item bags; 1510 carries the credit note default', async () => {
const creditNote = makeInvoice({
invoice_number: 'KR-1001',
subtotal: -1000,
vat_amount: -198,
total: -1198,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({ quantity: -1, unit_price: 600, line_total: -600, vat_rate: 25, vat_amount: -150, dimensions: { '6': 'P001' } }),
makeItem({ id: 'item-2', quantity: -1, unit_price: 400, line_total: -400, vat_rate: 12, vat_amount: -48 }),
],
})
await createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote)
const input = mockedCreateEntry.mock.calls[0][3]
// The reversed (debit) revenue lines keep the merged item bags.
const debit3001 = input.lines.find((l) => l.account_number === '3001')
expect(debit3001?.debit_amount).toBe(600)
expect(debit3001?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const debit3002 = input.lines.find((l) => l.account_number === '3002')
expect(debit3002?.dimensions).toEqual({ '1': 'KS01' })
// VAT lines carry the default only.
expect(input.lines.find((l) => l.account_number === '2611')?.dimensions).toEqual({ '1': 'KS01' })
expect(input.lines.find((l) => l.account_number === '2621')?.dimensions).toEqual({ '1': 'KS01' })
// 1510 carries the credit note's own default bag.
const credit1510 = input.lines.find((l) => l.account_number === '1510')
expect(credit1510?.credit_amount).toBe(1198)
expect(credit1510?.dimensions).toEqual({ '1': 'KS01' })
})
})
describe('createInvoiceCashEntry: ROT/RUT-avdrag', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('cash method ROT: 1930 debit reduced by deduction, 1513 carries the rest', async () => {
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 2500,
total: 12500,
vat_treatment: 'standard_25',
items: [
makeItem({
quantity: 1,
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
deduction_type: 'rot',
deduction_amount: 3000,
}),
],
})
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01')
const input = mockedCreateEntry.mock.calls[0][3]
const debit1930 = input.lines.find((l) => l.account_number === '1930')
expect(debit1930?.debit_amount).toBe(8750)
const debit1513 = input.lines.find((l) => l.account_number === '1513')
expect(debit1513?.debit_amount).toBe(3750)
// 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)
})
it('cash method ROT with a non-default paymentAccount: bank leg moves, 1513 stays fixed', async () => {
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 2500,
total: 12500,
vat_treatment: 'standard_25',
items: [
makeItem({
quantity: 1,
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
deduction_type: 'rot',
deduction_amount: 3000,
}),
],
})
await createInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, '2024-07-01',
'enskild_firma', undefined, '1940',
)
const input = mockedCreateEntry.mock.calls[0][3]
// The bank leg follows the resolved paymentAccount, still reduced by the deduction.
const debit1940 = input.lines.find((l) => l.account_number === '1940')
expect(debit1940?.debit_amount).toBe(8750)
expect(input.lines.find((l) => l.account_number === '1930')).toBeUndefined()
// The ROT/RUT receivable from Skatteverket is never the bank leg, so it
// must stay on 1513 regardless of paymentAccount.
const debit1513NonDefault = input.lines.find((l) => l.account_number === '1513')
expect(debit1513NonDefault?.debit_amount).toBe(3750)
// 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)
})
})
describe('createInvoicePaymentJournalEntry: settles the outstanding amount, not the total', () => {
beforeEach(() => {
mockedCreateEntry.mockClear()
})
it('ROT/RUT invoice without paymentAmount clears 1510 by total minus the deduction (1513 keeps the rest)', async () => {
// 10 000 + 25 % = 12 500; ROT 30 % of labor incl. moms = 3 750 on 1513 at
// issue, so 1510 only ever carried 8 750. The no-lines mark-paid path used
// to book 12 500 here: 1510 went to -3 750 and 1930 was overstated.
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 2500,
total: 12500,
deduction_total: 3750,
paid_amount: 0,
remaining_amount: 8750,
} as Partial<Invoice>)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
const bank = input.lines.find((l) => l.account_number === '1930')!
const ar = input.lines.find((l) => l.account_number === '1510')!
expect(bank.debit_amount).toBe(8750)
expect(ar.credit_amount).toBe(8750)
expect(input.description).toMatch(/^Inbetalning kundfaktura/)
})
it('previously part-paid invoice without paymentAmount clears only the remainder', async () => {
const invoice = makeInvoice({
total: 1250,
paid_amount: 500,
remaining_amount: 750,
} as Partial<Invoice>)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1930')!.debit_amount).toBe(750)
expect(input.lines.find((l) => l.account_number === '1510')!.credit_amount).toBe(750)
})
it('remaining_amount left at the DEFAULT 0 (import / seed paths) is treated as unmaintained', async () => {
const invoice = makeInvoice({
total: 12500,
deduction_total: 3750,
paid_amount: null,
remaining_amount: 0,
} as Partial<Invoice>)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1510')!.credit_amount).toBe(8750)
})
it('legacy row without remaining_amount derives it from total minus paid_amount', async () => {
const invoice = makeInvoice({ total: 1250, paid_amount: 250 } as Partial<Invoice>)
;(invoice as unknown as { remaining_amount?: number }).remaining_amount = undefined
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1510')!.credit_amount).toBe(1000)
})
it('fully outstanding invoice still books the full total (unchanged path)', async () => {
const invoice = makeInvoice({ total: 1250, paid_amount: 0, remaining_amount: 1250 } as Partial<Invoice>)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1510')!.credit_amount).toBe(1250)
})
})