Files
accounted/lib/supplier-invoices/__tests__/form-payload.test.ts
T
ebbe50c0f3 feat(supplier-invoices): "Vem betalade?" control replaces the paid privately switch and books an open utlägg (#2362)
The supplier-invoice form asks who paid with the same control as the
Underlag pane (Företaget / Jag, privat / En anställd / Ingen ännu) instead
of its own switch under Förval. A person paying is an utlägg: the route
hands the invoice to registerExpenseClaim with the invoice's kontering as
the claim's lines, so the verifikat and the expense_claims row come from
the same writer as the Underlag pane, the person shows up under "Betala ut
utlägg" on Hem and the bank matcher closes the debt. Employees book on
2820 with employee_id; the owner's blank name falls back to the shared
label so Hem groups one person.

Also routes a person-paid inbox document through the core route with
inbox_item_id: the extension's convert endpoint never read
paid_with_private_funds, so the old switch was silently dropped whenever
a receipt was attached. The second entry generator, the Förval switch,
the outline "Registrera & markera som betald" button and the duplicated
owner/employee picker are removed; PayerChoiceSelect and the claimant
fields move to components/expenses so core and the extension share them.

Closes #2332


Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:04:17 +02:00

412 lines
14 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
buildSupplierInvoicePayload,
supplierInvoiceCreateUrl,
inferVatTreatment,
vatRateFromAi,
rateToPctString,
type SupplierInvoiceFormData,
type SupplierInvoiceLineItem,
type BuildSupplierInvoicePayloadOptions,
} from '../form-payload'
function makeItem(overrides: Partial<SupplierInvoiceLineItem> = {}): SupplierInvoiceLineItem {
return {
description: 'Lokalhyra augusti',
amount: 1000,
account_number: '5010',
vat_rate: 0.25,
reverse_charge_rate: 0.25,
...overrides,
}
}
function makeFormData(overrides: Partial<SupplierInvoiceFormData> = {}): SupplierInvoiceFormData {
return {
supplier_id: 'supplier-1',
supplier_invoice_number: 'F-2026-881',
invoice_date: '2026-08-01',
due_date: '2026-08-31',
delivery_date: '',
currency: 'SEK',
exchange_rate: '',
reverse_charge: false,
payment_reference: '',
notes: '',
payer: 'unpaid',
claimant_name: '',
employee_id: '',
items: [makeItem()],
...overrides,
}
}
function makeOpts(
overrides: Partial<BuildSupplierInvoicePayloadOptions> = {},
): BuildSupplierInvoicePayloadOptions {
return {
inboxItemId: null,
uploadedDocumentId: undefined,
oreRounding: true,
defaultDims: {},
canUseAccrual: true,
...overrides,
}
}
describe('inferVatTreatment', () => {
it('maps a uniform rate to the matching treatment', () => {
expect(inferVatTreatment([makeItem({ vat_rate: 0.25 })], false)).toBe('standard_25')
expect(inferVatTreatment([makeItem({ vat_rate: 0.12 })], false)).toBe('reduced_12')
expect(inferVatTreatment([makeItem({ vat_rate: 0.06 })], false)).toBe('reduced_6')
expect(inferVatTreatment([makeItem({ vat_rate: 0 })], false)).toBe('exempt')
})
it('falls back to standard_25 on mixed rates', () => {
expect(
inferVatTreatment([makeItem({ vat_rate: 0.25 }), makeItem({ vat_rate: 0.12 })], false),
).toBe('standard_25')
})
it('reverse charge wins over everything', () => {
expect(inferVatTreatment([makeItem({ vat_rate: 0.12 })], true)).toBe('reverse_charge')
})
})
describe('vatRateFromAi', () => {
it('maps integer percent to decimals, null to the 25 % default', () => {
expect(vatRateFromAi(25)).toBe(0.25)
expect(vatRateFromAi(12)).toBe(0.12)
expect(vatRateFromAi(6)).toBe(0.06)
expect(vatRateFromAi(0)).toBe(0)
expect(vatRateFromAi(null)).toBe(0.25)
expect(vatRateFromAi(undefined)).toBe(0.25)
// Anything unrecognized collapses to 0, never an illegal rate.
expect(vatRateFromAi(19)).toBe(0)
})
})
describe('rateToPctString', () => {
it('renders decimals as percent strings', () => {
expect(rateToPctString(0.25)).toBe('25')
expect(rateToPctString(0.12)).toBe('12')
expect(rateToPctString(0.06)).toBe('6')
expect(rateToPctString(0)).toBe('0')
expect(rateToPctString(0.1234)).toBe('12.34')
})
})
describe('buildSupplierInvoicePayload', () => {
it('builds the plain create payload (SEK, no extras)', () => {
const payload = buildSupplierInvoicePayload(makeFormData(), makeOpts())
expect(payload).toEqual({
supplier_id: 'supplier-1',
supplier_invoice_number: 'F-2026-881',
invoice_date: '2026-08-01',
due_date: '2026-08-31',
delivery_date: undefined,
currency: 'SEK',
exchange_rate: undefined,
vat_treatment: 'standard_25',
reverse_charge: false,
payment_reference: undefined,
notes: undefined,
paid_with_private_funds: false,
ore_rounding: true,
items: [
{
description: 'Lokalhyra augusti',
amount: 1000,
account_number: '5010',
vat_rate: 0.25,
reverse_charge_rate: undefined,
},
],
})
// Conditional keys must be absent, not undefined-valued.
expect(payload).not.toHaveProperty('document_id')
expect(payload).not.toHaveProperty('default_dimensions')
expect(payload.items[0]).not.toHaveProperty('accrual_period_start')
expect(payload.items[0]).not.toHaveProperty('dimensions')
expect(payload.items[0]).not.toHaveProperty('apply_slp')
})
it('attaches document_id only on non-inbox submits', () => {
const withDoc = buildSupplierInvoicePayload(
makeFormData(),
makeOpts({ uploadedDocumentId: 'doc-1' }),
)
expect(withDoc).toHaveProperty('document_id', 'doc-1')
const inbox = buildSupplierInvoicePayload(
makeFormData(),
makeOpts({ inboxItemId: 'item-1', uploadedDocumentId: 'doc-1' }),
)
expect(inbox).not.toHaveProperty('document_id')
})
it('privately paid: empty due_date defaults to invoice_date', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({ payer: 'owner', due_date: '' }),
makeOpts({ canUseAccrual: false }),
)
expect(payload.due_date).toBe('2026-08-01')
expect(payload.paid_with_private_funds).toBe(true)
})
it('privately paid: an explicit due_date is kept', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({ payer: 'owner', due_date: '2026-09-15' }),
makeOpts({ canUseAccrual: false }),
)
expect(payload.due_date).toBe('2026-09-15')
})
it('owner: the typed name travels trimmed, a blank name is omitted (route applies the fallback)', () => {
const named = buildSupplierInvoicePayload(
makeFormData({ payer: 'owner', claimant_name: ' Anna Ek ' }),
makeOpts({ canUseAccrual: false }),
)
expect(named).toMatchObject({ paid_with_private_funds: true, claimant_name: 'Anna Ek' })
expect(named).not.toHaveProperty('employee_id')
const blank = buildSupplierInvoicePayload(
makeFormData({ payer: 'owner', claimant_name: ' ' }),
makeOpts({ canUseAccrual: false }),
)
expect(blank).not.toHaveProperty('claimant_name')
})
it('employee: employee_id travels and the owner name never does', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({ payer: 'employee', employee_id: 'emp-1', claimant_name: 'stale' }),
makeOpts({ canUseAccrual: false }),
)
expect(payload).toMatchObject({ paid_with_private_funds: true, employee_id: 'emp-1' })
expect(payload).not.toHaveProperty('claimant_name')
})
it('company / unpaid: no payer fields, paid_with_private_funds false', () => {
for (const payer of ['company', 'unpaid'] as const) {
const payload = buildSupplierInvoicePayload(
makeFormData({ payer, employee_id: 'emp-1', claimant_name: 'Anna' }),
makeOpts(),
)
expect(payload.paid_with_private_funds).toBe(false)
expect(payload).not.toHaveProperty('employee_id')
expect(payload).not.toHaveProperty('claimant_name')
expect(payload).not.toHaveProperty('inbox_item_id')
}
})
it('privately paid inbox document: inbox_item_id travels, document_id does not', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({ payer: 'employee', employee_id: 'emp-1' }),
makeOpts({ inboxItemId: 'item-1', uploadedDocumentId: 'doc-1', canUseAccrual: false }),
)
expect(payload).toHaveProperty('inbox_item_id', 'item-1')
expect(payload).not.toHaveProperty('document_id')
const company = buildSupplierInvoicePayload(
makeFormData({ payer: 'company' }),
makeOpts({ inboxItemId: 'item-1' }),
)
expect(company).not.toHaveProperty('inbox_item_id')
})
it('reverse charge: vat_rate forced to 0, reverse_charge_rate travels with 0.25 default', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({
reverse_charge: true,
items: [
makeItem({ vat_rate: 0.25, reverse_charge_rate: 0.12 }),
makeItem({ vat_rate: 0.25, reverse_charge_rate: undefined }),
],
}),
makeOpts({ canUseAccrual: false }),
)
expect(payload.vat_treatment).toBe('reverse_charge')
expect(payload.items[0].vat_rate).toBe(0)
expect(payload.items[0].reverse_charge_rate).toBe(0.12)
expect(payload.items[1].vat_rate).toBe(0)
expect(payload.items[1].reverse_charge_rate).toBe(0.25)
})
it('non-RC: reverse_charge_rate is stripped to undefined', () => {
const payload = buildSupplierInvoicePayload(makeFormData(), makeOpts())
expect(payload.items[0].reverse_charge_rate).toBeUndefined()
})
it('accrual fields travel only when canUseAccrual and the period is complete', () => {
const item = makeItem({
accrual_period_start: '2026-08-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '1790',
})
const withAccrual = buildSupplierInvoicePayload(
makeFormData({ items: [item] }),
makeOpts({ canUseAccrual: true }),
)
expect(withAccrual.items[0]).toMatchObject({
accrual_period_start: '2026-08-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '1790',
})
// Kontantmetod / eget utlägg / RC: canUseAccrual false drops the fields.
const withoutAccrual = buildSupplierInvoicePayload(
makeFormData({ items: [item] }),
makeOpts({ canUseAccrual: false }),
)
expect(withoutAccrual.items[0]).not.toHaveProperty('accrual_period_start')
// Incomplete period (end missing) also drops the fields.
const incomplete = buildSupplierInvoicePayload(
makeFormData({ items: [makeItem({ accrual_period_start: '2026-08-01' })] }),
makeOpts({ canUseAccrual: true }),
)
expect(incomplete.items[0]).not.toHaveProperty('accrual_period_start')
})
it('empty accrual balance account is sent as undefined, not empty string', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({
items: [
makeItem({
accrual_period_start: '2026-08-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '',
}),
],
}),
makeOpts({ canUseAccrual: true }),
)
expect(payload.items[0]).toHaveProperty('accrual_period_start')
expect(payload.items[0].accrual_balance_account).toBeUndefined()
})
it('default_dimensions only when non-empty; item bags only when carrying values', () => {
const bare = buildSupplierInvoicePayload(
makeFormData({ items: [makeItem({ dimensions: {} })] }),
makeOpts(),
)
expect(bare).not.toHaveProperty('default_dimensions')
// Open-but-empty per-row panel means inherit: the bag is dropped.
expect(bare.items[0]).not.toHaveProperty('dimensions')
const dims = buildSupplierInvoicePayload(
makeFormData({ items: [makeItem({ dimensions: { '1': 'KS01' } })] }),
makeOpts({ defaultDims: { '6': 'P001' } }),
)
expect(dims).toHaveProperty('default_dimensions', { '6': 'P001' })
expect(dims.items[0]).toHaveProperty('dimensions', { '1': 'KS01' })
})
it('apply_slp travels only on 741x rows without an accrual period', () => {
const valid = buildSupplierInvoicePayload(
makeFormData({ items: [makeItem({ account_number: '7412', apply_slp: true })] }),
makeOpts(),
)
expect(valid.items[0]).toHaveProperty('apply_slp', true)
// Stale flag on a non-pension account is stripped.
const wrongAccount = buildSupplierInvoicePayload(
makeFormData({ items: [makeItem({ account_number: '5010', apply_slp: true })] }),
makeOpts(),
)
expect(wrongAccount.items[0]).not.toHaveProperty('apply_slp')
// SLP + periodisering on the same row: accrual wins, flag stripped.
const withAccrual = buildSupplierInvoicePayload(
makeFormData({
items: [
makeItem({
account_number: '7412',
apply_slp: true,
accrual_period_start: '2026-08-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '1790',
}),
],
}),
makeOpts({ canUseAccrual: true }),
)
expect(withAccrual.items[0]).not.toHaveProperty('apply_slp')
// But when the flow cannot accrue, the period is dropped and SLP survives.
const accrualBlocked = buildSupplierInvoicePayload(
makeFormData({
items: [
makeItem({
account_number: '7412',
apply_slp: true,
accrual_period_start: '2026-08-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '1790',
}),
],
}),
makeOpts({ canUseAccrual: false }),
)
expect(accrualBlocked.items[0]).not.toHaveProperty('accrual_period_start')
expect(accrualBlocked.items[0]).toHaveProperty('apply_slp', true)
})
it('FX: exchange_rate string parses to a number, empty stays undefined', () => {
const fx = buildSupplierInvoicePayload(
makeFormData({ currency: 'EUR', exchange_rate: '11.2345' }),
makeOpts(),
)
expect(fx.currency).toBe('EUR')
expect(fx.exchange_rate).toBe(11.2345)
const noFx = buildSupplierInvoicePayload(makeFormData(), makeOpts())
expect(noFx.exchange_rate).toBeUndefined()
})
it('empty-string optionals collapse to undefined', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({ delivery_date: '', payment_reference: '', notes: '' }),
makeOpts(),
)
expect(payload.delivery_date).toBeUndefined()
expect(payload.payment_reference).toBeUndefined()
expect(payload.notes).toBeUndefined()
const filled = buildSupplierInvoicePayload(
makeFormData({
delivery_date: '2026-08-05',
payment_reference: '78912345678',
notes: 'intern anteckning',
}),
makeOpts(),
)
expect(filled.delivery_date).toBe('2026-08-05')
expect(filled.payment_reference).toBe('78912345678')
expect(filled.notes).toBe('intern anteckning')
})
it('ore_rounding passes through both ways', () => {
expect(buildSupplierInvoicePayload(makeFormData(), makeOpts({ oreRounding: true })).ore_rounding).toBe(true)
expect(buildSupplierInvoicePayload(makeFormData(), makeOpts({ oreRounding: false })).ore_rounding).toBe(false)
})
})
describe('supplierInvoiceCreateUrl', () => {
it('converts through the inbox when the company pays or has paid', () => {
expect(supplierInvoiceCreateUrl('unpaid', 'item-1')).toBe(
'/api/extensions/ext/invoice-inbox/items/item-1/convert',
)
expect(supplierInvoiceCreateUrl('company', 'item-1')).toBe(
'/api/extensions/ext/invoice-inbox/items/item-1/convert',
)
})
it('books an utlägg through the core route even for an inbox item', () => {
expect(supplierInvoiceCreateUrl('owner', 'item-1')).toBe('/api/supplier-invoices')
expect(supplierInvoiceCreateUrl('employee', 'item-1')).toBe('/api/supplier-invoices')
expect(supplierInvoiceCreateUrl('unpaid', null)).toBe('/api/supplier-invoices')
})
})