A whole-krona payment (Bankgiro/Swish/kort) of an öre-bearing invoice left the invoice hanging: the match_batch_allocate RPC (samlingsbetalning dialog + MCP path) had no öre handling, so a sub-krona overshoot was rejected as BATCH_OVERSHOOT and a sub-krona shortfall parked the invoice in partially_paid forever. Invoices already stuck that way had no exit: the mark-paid dialog proposed clearing the full total (rejected with MATCH_AMOUNT_EXCEEDS_REMAINING) and the route refused partially_paid outright. Fix, both halves: 1. New migration 20260824120000 replaces match_batch_allocate with the same öresavrundning band every single-payment path already uses (ORE_ROUNDING_SETTLEMENT_MAX = 1.00 kr, lib/money.ts): overshoot rejected only at >= 1 kr; a 0 < |remaining - allocation| < 1 kr diff clears the FULL remaining off 1510/2440, books the residual to 3740 with correct polarity per side, records the full remaining as paid and flips the status to paid. >= 1 kr diffs keep today's behaviour. 2. proposePaymentLines is remaining-aware: a partially_paid SEK accrual invoice gets a proposal clearing the actual remaining, and a sub-krona remaining gets a bank-less Dr 3740 / Cr 1510 write-off so one click closes a stuck invoice. The mark-paid route and the invoice-detail button now accept partially_paid (the settle layer's CAS guard always did). Forward-only: already-stuck invoices are not auto-repaired; they are closed via the new dialog proposal. Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
713 lines
24 KiB
TypeScript
713 lines
24 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { proposePaymentLines } from '../propose-payment-lines'
|
|
import type { InvoiceItem, VatTreatment } from '@/types'
|
|
|
|
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
|
return {
|
|
id: 'item-1',
|
|
invoice_id: 'inv-1',
|
|
description: 'Konsulttjänst',
|
|
quantity: 1,
|
|
unit: 'st',
|
|
unit_price: 10000,
|
|
line_total: 10000,
|
|
vat_rate: 25,
|
|
vat_amount: 2500,
|
|
sort_order: 0,
|
|
created_at: '2025-01-01',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeInvoiceInput(overrides: Partial<{
|
|
invoice_number: string
|
|
total: number
|
|
total_sek: number | null
|
|
subtotal: number
|
|
subtotal_sek: number | null
|
|
vat_amount: number
|
|
vat_amount_sek: number | null
|
|
currency: string
|
|
exchange_rate: number | null
|
|
vat_treatment: VatTreatment
|
|
items: InvoiceItem[]
|
|
}> = {}) {
|
|
return {
|
|
invoice_number: '2025-001',
|
|
total: 12500,
|
|
total_sek: null,
|
|
subtotal: 10000,
|
|
subtotal_sek: null,
|
|
vat_amount: 2500,
|
|
vat_amount_sek: null,
|
|
currency: 'SEK',
|
|
exchange_rate: null,
|
|
vat_treatment: 'standard_25' as VatTreatment,
|
|
items: [makeItem()],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('proposePaymentLines', () => {
|
|
describe('accrual method', () => {
|
|
it('SEK invoice → 2 lines (debit payment account, credit 1510)', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput(),
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines).toHaveLength(2)
|
|
expect(lines[0]).toEqual({
|
|
account_number: '1930',
|
|
debit_amount: '12500',
|
|
credit_amount: '',
|
|
line_description: 'Betalning faktura 2025-001',
|
|
})
|
|
expect(lines[1]).toEqual({
|
|
account_number: '1510',
|
|
debit_amount: '',
|
|
credit_amount: '12500',
|
|
line_description: 'Betalning faktura 2025-001',
|
|
})
|
|
})
|
|
|
|
it('custom bank account (1920) → debit goes to 1920', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput(),
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
paymentAccount: '1920',
|
|
})
|
|
|
|
expect(lines).toHaveLength(2)
|
|
expect(lines[0].account_number).toBe('1920')
|
|
expect(lines[1].account_number).toBe('1510')
|
|
})
|
|
|
|
it('foreign currency with exchange rate gain → 3 lines', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput({
|
|
total: 1000,
|
|
total_sek: 10000,
|
|
currency: 'EUR',
|
|
exchange_rate: 10,
|
|
}),
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
exchangeRateDifference: 500,
|
|
})
|
|
|
|
expect(lines).toHaveLength(3)
|
|
// Bank: actual received = 10000 + 500 = 10500
|
|
expect(lines[0].account_number).toBe('1930')
|
|
expect(lines[0].debit_amount).toBe('10500')
|
|
// Clear receivable at booked amount
|
|
expect(lines[1].account_number).toBe('1510')
|
|
expect(lines[1].credit_amount).toBe('10000')
|
|
// Exchange gain
|
|
expect(lines[2].account_number).toBe('3960')
|
|
expect(lines[2].credit_amount).toBe('500')
|
|
})
|
|
|
|
it('foreign currency with exchange rate loss → 3 lines with 7960 debit', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput({
|
|
total: 1000,
|
|
total_sek: 10000,
|
|
currency: 'EUR',
|
|
exchange_rate: 10,
|
|
}),
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
exchangeRateDifference: -300,
|
|
})
|
|
|
|
expect(lines).toHaveLength(3)
|
|
expect(lines[0].debit_amount).toBe('9700')
|
|
expect(lines[2].account_number).toBe('7960')
|
|
expect(lines[2].debit_amount).toBe('300')
|
|
})
|
|
})
|
|
|
|
describe('cash method', () => {
|
|
it('single VAT rate → debit 1930, credit 3001, credit 2611', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput(),
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines).toHaveLength(3)
|
|
expect(lines[0]).toEqual({
|
|
account_number: '1930',
|
|
debit_amount: '12500',
|
|
credit_amount: '',
|
|
line_description: 'Betalning faktura 2025-001',
|
|
})
|
|
expect(lines[1]).toEqual({
|
|
account_number: '3001',
|
|
debit_amount: '',
|
|
credit_amount: '10000',
|
|
line_description: 'Försäljning faktura 2025-001',
|
|
})
|
|
expect(lines[2]).toEqual({
|
|
account_number: '2611',
|
|
debit_amount: '',
|
|
credit_amount: '2500',
|
|
line_description: 'Utgående moms 25%',
|
|
})
|
|
})
|
|
|
|
it('mixed VAT rates → multiple credit lines', () => {
|
|
const items = [
|
|
makeItem({ id: 'i1', vat_rate: 25, line_total: 8000, vat_amount: 2000, unit_price: 8000 }),
|
|
makeItem({ id: 'i2', vat_rate: 12, line_total: 2000, vat_amount: 240, unit_price: 2000 }),
|
|
]
|
|
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput({
|
|
total: 12240,
|
|
subtotal: 10000,
|
|
vat_amount: 2240,
|
|
items,
|
|
}),
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
// 1 debit + 2 revenue + 2 VAT = 5 lines
|
|
expect(lines).toHaveLength(5)
|
|
expect(lines[0].account_number).toBe('1930')
|
|
|
|
// Find the revenue/VAT lines by account
|
|
const accounts = lines.slice(1).map((l) => l.account_number)
|
|
expect(accounts).toContain('3001') // 25% revenue
|
|
expect(accounts).toContain('2611') // 25% VAT
|
|
expect(accounts).toContain('3002') // 12% revenue
|
|
expect(accounts).toContain('2621') // 12% VAT
|
|
})
|
|
|
|
it('defaults payment account to 1930', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput(),
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines[0].account_number).toBe('1930')
|
|
})
|
|
|
|
it('uses custom payment account', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput(),
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
paymentAccount: '1910',
|
|
})
|
|
|
|
expect(lines[0].account_number).toBe('1910')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('proposePaymentLines: öresavrundning (3740)', () => {
|
|
it('accrual: rounded-up total → bank leg at "Att betala", 3740 credit carries the residual', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput({
|
|
total: 1234.75,
|
|
subtotal: 987.8,
|
|
vat_amount: 246.95,
|
|
items: [makeItem({ line_total: 987.8, vat_amount: 246.95, unit_price: 987.8 })],
|
|
}),
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
companyOreRounding: true,
|
|
})
|
|
|
|
expect(lines).toHaveLength(3)
|
|
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '1235' })
|
|
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '1234.75' })
|
|
expect(lines[2]).toEqual({
|
|
account_number: '3740',
|
|
debit_amount: '',
|
|
credit_amount: '0.25',
|
|
line_description: 'Öresavrundning',
|
|
})
|
|
})
|
|
|
|
it('accrual: rounded-down total → 3740 debit', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput({ total: 1234.25 }),
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
companyOreRounding: true,
|
|
})
|
|
|
|
expect(lines).toHaveLength(3)
|
|
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '1234' })
|
|
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '1234.25' })
|
|
expect(lines[2]).toMatchObject({ account_number: '3740', debit_amount: '0.25', credit_amount: '' })
|
|
})
|
|
|
|
it('cash: bank leg is the rounded amount and 3740 balances the exact revenue + VAT credits', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput({
|
|
total: 1234.75,
|
|
subtotal: 987.8,
|
|
vat_amount: 246.95,
|
|
items: [makeItem({ line_total: 987.8, vat_amount: 246.95, unit_price: 987.8 })],
|
|
}),
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
companyOreRounding: true,
|
|
})
|
|
|
|
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '1235' })
|
|
const last = lines[lines.length - 1]
|
|
expect(last).toMatchObject({ account_number: '3740', credit_amount: '0.25' })
|
|
const debit = lines.reduce((s, l) => s + (parseFloat(l.debit_amount) || 0), 0)
|
|
const credit = lines.reduce((s, l) => s + (parseFloat(l.credit_amount) || 0), 0)
|
|
expect(Math.round((debit - credit) * 100)).toBe(0)
|
|
})
|
|
|
|
it('company setting off and no invoice override → unchanged 2-line proposal', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput({ total: 1234.75 }),
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
companyOreRounding: false,
|
|
})
|
|
|
|
expect(lines).toHaveLength(2)
|
|
expect(lines[0].debit_amount).toBe('1234.75')
|
|
})
|
|
|
|
it('per-invoice override wins over the company setting', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: { ...makeInvoiceInput({ total: 1234.75 }), ore_rounding: true },
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
companyOreRounding: false,
|
|
})
|
|
|
|
expect(lines).toHaveLength(3)
|
|
expect(lines[2].account_number).toBe('3740')
|
|
})
|
|
|
|
it('whole-krona total → no 3740 line even when rounding is on', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput({ total: 12500 }),
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
companyOreRounding: true,
|
|
})
|
|
|
|
expect(lines).toHaveLength(2)
|
|
})
|
|
|
|
it('non-SEK invoice → rounding never applies', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput({
|
|
total: 1000.4,
|
|
total_sek: 10004,
|
|
currency: 'EUR',
|
|
exchange_rate: 10,
|
|
}),
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
companyOreRounding: true,
|
|
})
|
|
|
|
expect(lines.every((l) => l.account_number !== '3740')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('proposePaymentLines: remaining-aware partials (#1717)', () => {
|
|
it('sub-krona remaining → bank-less öre write-off (Dr 3740 / Cr 1510)', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: {
|
|
...makeInvoiceInput({ total: 12500.4 }),
|
|
paid_amount: 12500,
|
|
remaining_amount: 0.4,
|
|
},
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines).toEqual([
|
|
{
|
|
account_number: '3740',
|
|
debit_amount: '0.4',
|
|
credit_amount: '',
|
|
line_description: 'Öresavrundning',
|
|
},
|
|
{
|
|
account_number: '1510',
|
|
debit_amount: '',
|
|
credit_amount: '0.4',
|
|
line_description: 'Betalning faktura 2025-001',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('remaining >= 1 kr → clears the remaining, not the total', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: {
|
|
...makeInvoiceInput({ total: 12500 }),
|
|
paid_amount: 5000,
|
|
remaining_amount: 7500,
|
|
},
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
paymentAccount: '1920',
|
|
})
|
|
|
|
expect(lines).toHaveLength(2)
|
|
expect(lines[0]).toMatchObject({ account_number: '1920', debit_amount: '7500' })
|
|
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '7500' })
|
|
})
|
|
|
|
it('derives remaining from paid_amount when remaining_amount is absent', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: { ...makeInvoiceInput({ total: 12500.4 }), paid_amount: 12500 },
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines[0]).toMatchObject({ account_number: '3740', debit_amount: '0.4' })
|
|
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '0.4' })
|
|
})
|
|
|
|
it('fully unpaid invoice keeps the fresh proposal byte-identical (incl. öresavrundning)', () => {
|
|
const fresh = proposePaymentLines({
|
|
invoice: makeInvoiceInput({ total: 1234.75 }),
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
companyOreRounding: true,
|
|
})
|
|
const withZeroPaid = proposePaymentLines({
|
|
invoice: {
|
|
...makeInvoiceInput({ total: 1234.75 }),
|
|
paid_amount: 0,
|
|
remaining_amount: 1234.75,
|
|
},
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
companyOreRounding: true,
|
|
})
|
|
|
|
expect(withZeroPaid).toEqual(fresh)
|
|
expect(withZeroPaid[2]).toMatchObject({ account_number: '3740' })
|
|
})
|
|
|
|
it('cash method partial keeps the legacy full-invoice proposal', () => {
|
|
// The server refuses cash partial completion (cashPartialBlockReason), so
|
|
// a remaining-based cash proposal would only dress up a rejected booking.
|
|
const lines = proposePaymentLines({
|
|
invoice: {
|
|
...makeInvoiceInput(),
|
|
paid_amount: 5000,
|
|
remaining_amount: 7500,
|
|
},
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '12500' })
|
|
})
|
|
|
|
it('foreign-currency partial keeps the legacy proposal', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: {
|
|
...makeInvoiceInput({
|
|
total: 1000,
|
|
total_sek: 10000,
|
|
currency: 'EUR',
|
|
exchange_rate: 10,
|
|
}),
|
|
paid_amount: 500,
|
|
remaining_amount: 500,
|
|
},
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '10000' })
|
|
expect(lines.every((l) => l.account_number !== '3740')).toBe(true)
|
|
})
|
|
|
|
it('ROT/RUT invoice with a partial keeps the deduction-aware proposal', () => {
|
|
// The outstanding remainder on a ROT/RUT invoice is Skatteverket's share
|
|
// on 1513, settled by the ROT/RUT payout flow: never proposed as a 1510
|
|
// clearing here.
|
|
const lines = proposePaymentLines({
|
|
invoice: {
|
|
...makeInvoiceInput(),
|
|
deduction_total: 3750,
|
|
paid_amount: 8750,
|
|
remaining_amount: 3750,
|
|
},
|
|
accountingMethod: 'accrual',
|
|
entityType: 'aktiebolag',
|
|
})
|
|
|
|
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '8750' })
|
|
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '8750' })
|
|
})
|
|
|
|
it('write-off lines carry the invoice default dimensions', () => {
|
|
const bag = { '6': 'P001' }
|
|
const lines = proposePaymentLines({
|
|
invoice: {
|
|
...makeInvoiceInput({ total: 1000.25 }),
|
|
paid_amount: 1000,
|
|
remaining_amount: 0.25,
|
|
default_dimensions: bag,
|
|
},
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines).toHaveLength(2)
|
|
for (const line of lines) {
|
|
expect(line.dimensions).toEqual(bag)
|
|
expect(line.dimensions).not.toBe(bag)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('proposePaymentLines: dimensions propagation (PR7)', () => {
|
|
const bag = { '1': 'KS01', '6': 'P001' }
|
|
|
|
it('accrual: every proposed line carries a copy of the invoice default bag', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: { ...makeInvoiceInput(), default_dimensions: bag },
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines).toHaveLength(2)
|
|
for (const line of lines) {
|
|
expect(line.dimensions).toEqual(bag)
|
|
// A copy, not the shared reference: editing one line must not mutate
|
|
// the invoice bag or a sibling line.
|
|
expect(line.dimensions).not.toBe(bag)
|
|
}
|
|
expect(lines[0].dimensions).not.toBe(lines[1].dimensions)
|
|
})
|
|
|
|
it('accrual with FX difference: the 3960 line carries the bag too', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: {
|
|
...makeInvoiceInput({
|
|
total: 1000,
|
|
total_sek: 10000,
|
|
currency: 'EUR',
|
|
exchange_rate: 10,
|
|
}),
|
|
default_dimensions: bag,
|
|
},
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
exchangeRateDifference: 500,
|
|
})
|
|
|
|
expect(lines).toHaveLength(3)
|
|
expect(lines[2].account_number).toBe('3960')
|
|
for (const line of lines) {
|
|
expect(line.dimensions).toEqual(bag)
|
|
}
|
|
})
|
|
|
|
it('cash: payment, revenue and VAT lines all carry the bag', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: { ...makeInvoiceInput(), default_dimensions: bag },
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines).toHaveLength(3)
|
|
expect(lines.map((l) => l.account_number)).toEqual(['1930', '3001', '2611'])
|
|
for (const line of lines) {
|
|
expect(line.dimensions).toEqual(bag)
|
|
}
|
|
})
|
|
|
|
it('absent or empty bag → no dimensions key on any line', () => {
|
|
const withoutBag = proposePaymentLines({
|
|
invoice: makeInvoiceInput(),
|
|
accountingMethod: 'accrual',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
for (const line of withoutBag) {
|
|
expect('dimensions' in line).toBe(false)
|
|
}
|
|
|
|
const withEmptyBag = proposePaymentLines({
|
|
invoice: { ...makeInvoiceInput(), default_dimensions: {} },
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
for (const line of withEmptyBag) {
|
|
expect('dimensions' in line).toBe(false)
|
|
}
|
|
})
|
|
})
|
|
|
|
/**
|
|
* The cash-method proposal IS the entry: PaymentBookingDialog pre-fills its
|
|
* editable grid from these lines and submits them. A foreign invoice with no
|
|
* exchange rate has no SEK value at item granularity, so pre-filling the raw
|
|
* foreign numbers would post 1 000 kr of revenue and 250 kr of moms where 11 500
|
|
* kr and 2 875 kr belong: balanced, undetectable, and an understated ruta 05/10.
|
|
* The dialog resolves the proposal inside a try/catch, so throwing surfaces as a
|
|
* translated toast (INVOICE_FX_RATE_MISSING) rather than a crash.
|
|
*/
|
|
describe('proposePaymentLines: foreign currency without an exchange rate', () => {
|
|
it('cash method refuses with INVOICE_FX_RATE_MISSING', () => {
|
|
expect(() =>
|
|
proposePaymentLines({
|
|
invoice: makeInvoiceInput({ currency: 'EUR', exchange_rate: null }),
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
).toThrowError(expect.objectContaining({ code: 'INVOICE_FX_RATE_MISSING', currency: 'EUR' }))
|
|
})
|
|
|
|
it('cash method refuses a zero rate the same way', () => {
|
|
expect(() =>
|
|
proposePaymentLines({
|
|
invoice: makeInvoiceInput({ currency: 'EUR', exchange_rate: 0 }),
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
).toThrowError(expect.objectContaining({ code: 'INVOICE_FX_RATE_MISSING' }))
|
|
})
|
|
|
|
it('cash method with a rate proposes converted, balanced lines', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput({
|
|
currency: 'EUR',
|
|
exchange_rate: 11.5,
|
|
subtotal: 1000,
|
|
vat_amount: 250,
|
|
total: 1250,
|
|
items: [makeItem({ line_total: 1000, unit_price: 1000, vat_rate: 25, vat_amount: 250 })],
|
|
}),
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines.find((l) => l.account_number === '3001')?.credit_amount).toBe('11500')
|
|
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe('2875')
|
|
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('14375')
|
|
|
|
const debit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
|
|
const credit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
|
expect(Math.round(debit * 100)).toBe(Math.round(credit * 100))
|
|
})
|
|
|
|
it('SEK cash method without a rate is unaffected', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput({ currency: 'SEK', exchange_rate: null }),
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('12500')
|
|
const debit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
|
|
const credit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
|
expect(Math.round(debit * 100)).toBe(Math.round(credit * 100))
|
|
})
|
|
|
|
// The invoice-level fallback (no items) reads subtotal_sek / vat_amount_sek and
|
|
// is deliberately left lenient: those rows CAN be expressed in kronor.
|
|
it('cash method still proposes from invoice-level *_sek when there are no items', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: makeInvoiceInput({
|
|
currency: 'EUR',
|
|
exchange_rate: null,
|
|
items: [],
|
|
subtotal: 1000,
|
|
subtotal_sek: 11500,
|
|
vat_amount: 250,
|
|
vat_amount_sek: 2875,
|
|
total: 1250,
|
|
total_sek: 14375,
|
|
}),
|
|
accountingMethod: 'cash',
|
|
entityType: 'enskild_firma',
|
|
})
|
|
|
|
expect(lines.find((l) => l.account_number === '3001')?.credit_amount).toBe('11500')
|
|
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe('2875')
|
|
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('14375')
|
|
})
|
|
})
|
|
|
|
describe('proposePaymentLines: ROT/RUT-avdrag (fakturamodellen)', () => {
|
|
// 10 000 labor + 25 % = 12 500; ROT 30 % of labor incl. moms = 3 750. The
|
|
// customer pays 8 750; 3 750 is a receivable on Skatteverket (1513).
|
|
const rotInvoice = () => ({ ...makeInvoiceInput(), deduction_total: 3750 })
|
|
|
|
it('accrual: bank and 1510 legs carry the customer share (total minus avdrag)', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: rotInvoice(),
|
|
accountingMethod: 'accrual',
|
|
entityType: 'aktiebolag',
|
|
})
|
|
expect(lines).toHaveLength(2)
|
|
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '8750' })
|
|
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '8750' })
|
|
})
|
|
|
|
it('accrual + öresavrundning: bank leg is the rounded "Att betala", 3740 carries the öre', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: { ...makeInvoiceInput({ total: 12500.4 }), deduction_total: 3750 },
|
|
accountingMethod: 'accrual',
|
|
entityType: 'aktiebolag',
|
|
companyOreRounding: true,
|
|
})
|
|
// Att betala on the PDF: round(12 500,40) - 3 750 = 8 750; 1510 clears
|
|
// 8 750,40; the 0,40 lands on 3740 (same polarity as the bank match).
|
|
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('8750')
|
|
expect(lines.find((l) => l.account_number === '1510')?.credit_amount).toBe('8750.4')
|
|
expect(lines.find((l) => l.account_number === '3740')?.debit_amount).toBe('0.4')
|
|
})
|
|
|
|
it('cash method: bank gets the customer share, 1513 the avdrag, revenue + moms in full', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: rotInvoice(),
|
|
accountingMethod: 'cash',
|
|
entityType: 'aktiebolag',
|
|
})
|
|
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('8750')
|
|
expect(lines.find((l) => l.account_number === '1513')?.debit_amount).toBe('3750')
|
|
expect(lines.find((l) => l.account_number === '3001')?.credit_amount).toBe('10000')
|
|
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe('2500')
|
|
const debits = lines.reduce((s, l) => s + (parseFloat(l.debit_amount) || 0), 0)
|
|
const credits = lines.reduce((s, l) => s + (parseFloat(l.credit_amount) || 0), 0)
|
|
expect(Math.round((debits - credits) * 100)).toBe(0)
|
|
})
|
|
|
|
it('foreign invoice with a deduction and no booking rate refuses (1513 is a kronor receivable)', () => {
|
|
expect(() =>
|
|
proposePaymentLines({
|
|
invoice: { ...makeInvoiceInput({ currency: 'EUR', total_sek: 137500, exchange_rate: null }), deduction_total: 375 },
|
|
accountingMethod: 'accrual',
|
|
entityType: 'aktiebolag',
|
|
}),
|
|
).toThrow()
|
|
})
|
|
|
|
it('no deduction: unchanged full-total proposal', () => {
|
|
const lines = proposePaymentLines({
|
|
invoice: { ...makeInvoiceInput(), deduction_total: 0 },
|
|
accountingMethod: 'accrual',
|
|
entityType: 'aktiebolag',
|
|
})
|
|
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '12500' })
|
|
})
|
|
})
|