Files
accounted/lib/bookkeeping/__tests__/invoice-entries.test.ts
T
e030393fe6 fix(rot-rut): payment-side booking, reminders and claim completeness (#1652)
* fix(rot-rut): payment-side booking, reminders and claim completeness

Follow-ups from the 2026-08-17 ROT/RUT audit (dev_docs/rot_rut_audit_2026_08_17.md).

Payment side (fakturamodellen: the customer pays total minus avdraget, the
rest is a 1513 receivable on Skatteverket):
- createInvoicePaymentJournalEntry without an explicit paymentAmount used to
  book invoice.total on 1930/1510. Every no-lines mark-paid path (MCP
  mark_invoice_as_paid, v1 API, no-body dashboard route, Stripe) settles the
  outstanding amount, so on a ROT/RUT invoice 1510 went negative by the
  deduction and 1930 was overstated; same defect for any previously part-paid
  invoice. It now books the outstanding amount (remaining_amount, else total
  minus paid_amount); a fully outstanding invoice keeps the total_sek path.
- proposePaymentLines had no deduction awareness: the payment dialog
  pre-filled D1930 total / K1510 total, which the settlement plan rejected as
  an overpayment, so a ROT/RUT invoice could not be marked paid from the UI.
  Accrual: bank + 1510 carry total minus avdrag; cash method: bank gets the
  customer share, 1513 the avdrag, revenue + moms in full. Foreign invoices
  without a booking rate refuse (1513 is a kronor receivable). Dialog passes
  deduction_total.
- Reminders and dröjsmålsränta were computed on invoice.total: a privatperson
  was dunned for the Skatteverket share and charged interest on it. New
  reminderPrincipal() = the invoice's "Att betala" (öre-rounded total minus
  avdrag) drives the processor's interest base and all three templates.

Claim completeness (HUSFL 2009:194: art av arbete + antal arbetstimmar):
- work_type and labor_hours were optional at creation but hard blockers at
  begäran-file time, when the invoice is numbered, booked and paid and cannot
  be edited. validateDeductionLines() now requires a same-kind arbetstyp and
  hours > 0 (schablontjänster exempt) on every deduction line; wired into
  validateInvoice, CreateInvoiceItemSchema (field-level issues) and the
  editor schema with inline errors under the ROT/RUT strip. Fixed the
  labor_hours register (valueAsNumber overrode setValueAs: an emptied field
  became NaN and failed validation with no visible error). The Underlag card
  now shows whenever any row is flagged, matching the payload/server predicate.

Yearly ceilings:
- COMBINED_MAX 75 000 kr: ROT + RUT share one ceiling per person (ROT capped
  at 50 000 inside it). deductionCapWarnings() carries the per-kind and the
  combined check plus optional prior-year totals; validateInvoice forwards
  them; the editor uses the same helper and fetches what the customer has
  already been granted in the invoice year (per customer, warning only).

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

* fix(rot-rut): treat remaining_amount left at DEFAULT 0 as unmaintained when booking a payment

Rows written by paths that bypass buildInvoiceWriteData (imports, sandbox
seed, legacy migrations) carry remaining_amount = 0 while unpaid; prod has
~330 such open invoices. Booking 0 would have failed the engine's positive-
amount rule, so the outstanding helper derives total - paid - deduction when
the stored value is not positive. Test.

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

* fix(rot-rut): review follow-ups on #1652

- ROT/RUT completeness moves to the invoice-level schema (CreateInvoiceSchema /
  UpdateInvoiceSchema share one refine) so it only applies to real invoices
  and skips text rows; the editor gates its mirror on the document type via
  a ref. Tests moved accordingly (CodeRabbit).
- Prior-year deduction lookup follows the PAYMENT year (paid_at, else
  invoice_date for open invoices), paginates via fetchAllRows, and clears the
  total on a failed request instead of leaving a stale one.
- rot-rut-file derives its schablon flags from SCHABLON_WORK_TYPES so the
  validator and the generator cannot drift.

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

* fix(rot-rut): pick the prior-year deductions client-side (phantom-columns ceiling)

The runtime-built .or() filter counted as an unresolvable query expression
for the no-phantom-columns guard. A customer has few deduction invoices, so
fetch them all and select the payment year in code.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 20:49:27 +02:00

1947 lines
71 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: 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('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)
})
})