Files
accounted/components/invoices/__tests__/invoice-editor-flow.test.ts
T
78525bd391 fix(invoices): make self-billed invoices creditable and their dates visible (#1827)
A self-billed invoice has invoice_number null by design (the counterparty's
number lives in external_invoice_number), which broke the whole credit flow:
the confirm input was disabled and compared against null, the API minted the
literal number 'KR-null', and the credit-note PDF dropped its ML 17 kap 22
reference to the original. The editor also hid fakturadatum inside the
collapsed Forval panel, so self-billed invoices silently registered with
today's date and, being immutable, could not be corrected.

- creditConfirmNumber() falls back to external_invoice_number; the credit
  page uses it for reason default, subtitle, original row, preview, confirm
  label/placeholder/disabled, mismatch check and submit gate
- createCreditNote numbers 'KR-<external>' for self-billed originals and
  refuses with typed 400 INVOICE_CREDIT_NO_NUMBER when no number exists
- mark-sent and send select external_invoice_number and fall back for the
  credit-note PDF's reference to the original
- the Forval chip line now shows the invoice date in every mode, and
  self-billed mode renders fakturadatum + mottagningsdatum uncollapsed as
  transcription fields next to the external number

Fixes #1820


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>
2026-08-24 13:18:57 +02:00

291 lines
9.9 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
deriveNextStep,
deriveForvalChips,
deriveRequiresHousing,
filterArticleSuggestions,
type NextStepInput,
type ForvalChipsInput,
} from '@/components/invoices/invoice-editor-flow'
function stepInput(overrides: Partial<NextStepInput> = {}): NextStepInput {
return {
isSelfBilled: false,
customerSelected: true,
invoiceDate: '2026-08-17',
dueDate: '2026-09-16',
receivedDate: '',
externalInvoiceNumber: '',
items: [
{ line_type: 'product', description: 'Konsulttid', quantity: 10, unit: 'tim', unit_price: 1200 },
],
paymentLinkInvalid: false,
requiresPersonnummer: false,
personnummer: '',
requiresHousing: false,
housingDesignation: '',
...overrides,
}
}
describe('deriveNextStep priority order', () => {
it('is ready for a complete invoice', () => {
expect(deriveNextStep(stepInput())).toEqual({ kind: 'ready' })
})
it('customer comes first, before everything else', () => {
expect(
deriveNextStep(stepInput({ customerSelected: false, invoiceDate: '', items: [] })),
).toEqual({ kind: 'customer' })
})
it('dates come before rows', () => {
expect(deriveNextStep(stepInput({ invoiceDate: '', items: [] }))).toEqual({
kind: 'invoice_date',
})
expect(deriveNextStep(stepInput({ dueDate: '', items: [] }))).toEqual({ kind: 'due_date' })
})
it('asks for a first row when only text rows exist', () => {
expect(
deriveNextStep(stepInput({ items: [{ line_type: 'text', description: 'Enligt offert' }] })),
).toEqual({ kind: 'rows_empty' })
})
it('flags the first incomplete row, field by field, skipping text rows', () => {
const items: NextStepInput['items'] = [
{ line_type: 'text', description: '' },
{ line_type: 'product', description: 'Ok', quantity: 1, unit: 'st', unit_price: 100 },
{ line_type: 'product', description: '', quantity: 1, unit: 'st', unit_price: 0 },
]
expect(deriveNextStep(stepInput({ items }))).toEqual({
kind: 'row_incomplete',
index: 2,
field: 'description',
})
items[2].description = 'Rad'
items[2].quantity = 0
expect(deriveNextStep(stepInput({ items }))).toEqual({
kind: 'row_incomplete',
index: 2,
field: 'quantity',
})
items[2].quantity = NaN
expect(deriveNextStep(stepInput({ items }))).toEqual({
kind: 'row_incomplete',
index: 2,
field: 'quantity',
})
items[2].quantity = 2
items[2].unit = ''
expect(deriveNextStep(stepInput({ items }))).toEqual({
kind: 'row_incomplete',
index: 2,
field: 'unit',
})
items[2].unit = 'st'
items[2].unit_price = NaN
expect(deriveNextStep(stepInput({ items }))).toEqual({
kind: 'row_incomplete',
index: 2,
field: 'unit_price',
})
})
it('allows negative and zero unit prices (discount lines)', () => {
expect(
deriveNextStep(
stepInput({
items: [
{ line_type: 'product', description: 'Rabatt', quantity: 1, unit: 'st', unit_price: -100 },
],
}),
),
).toEqual({ kind: 'ready' })
})
it('routes to the payment link after rows', () => {
expect(deriveNextStep(stepInput({ paymentLinkInvalid: true }))).toEqual({
kind: 'payment_link',
})
})
it('asks for personnummer only when neither draft nor kundkort covers it', () => {
expect(deriveNextStep(stepInput({ requiresPersonnummer: true }))).toEqual({
kind: 'personnummer',
})
expect(
deriveNextStep(stepInput({ requiresPersonnummer: true, personnummer: '19800101-1234' })),
).toEqual({ kind: 'ready' })
// Server fallback (stored last4 / kundkort) => requiresPersonnummer false.
expect(deriveNextStep(stepInput({ requiresPersonnummer: false }))).toEqual({ kind: 'ready' })
})
it('asks for fastighetsbeteckning when a ROT line exists', () => {
expect(deriveNextStep(stepInput({ requiresHousing: true }))).toEqual({ kind: 'housing' })
expect(
deriveNextStep(stepInput({ requiresHousing: true, housingDesignation: 'Berga 2:11' })),
).toEqual({ kind: 'ready' })
})
it('skips the housing step for a ROT row whose amount is still zero (transient state)', () => {
// The claim card only mounts while a deduction amount is claimed, so a
// ROT-flagged row with price 0 must not produce a housing step: the
// next-step link would try to focus an unmounted field.
const requiresHousing = deriveRequiresHousing({ hasRotLine: true, deductionTotal: 0 })
expect(requiresHousing).toBe(false)
expect(deriveNextStep(stepInput({ requiresHousing }))).toEqual({ kind: 'ready' })
})
it('requires housing once the ROT deduction carries an amount, but never for RUT alone', () => {
expect(deriveRequiresHousing({ hasRotLine: true, deductionTotal: 360 })).toBe(true)
expect(deriveRequiresHousing({ hasRotLine: false, deductionTotal: 500 })).toBe(false)
})
it('self-billed extras come last: external number then received date', () => {
expect(deriveNextStep(stepInput({ isSelfBilled: true }))).toEqual({ kind: 'external_number' })
expect(
deriveNextStep(stepInput({ isSelfBilled: true, externalInvoiceNumber: 'K-1' })),
).toEqual({ kind: 'received_date' })
expect(
deriveNextStep(
stepInput({ isSelfBilled: true, externalInvoiceNumber: 'K-1', receivedDate: '2026-08-15' }),
),
).toEqual({ kind: 'ready' })
})
})
function chipsInput(overrides: Partial<ForvalChipsInput> = {}): ForvalChipsInput {
return {
isSelfBilled: false,
documentType: 'invoice',
currency: 'SEK',
invoiceDate: '2026-08-17',
dueDate: '2026-09-16',
receivedDate: '',
deliveryDate: '',
yourReference: '',
paymentLink: null,
oreRounding: true,
dims: null,
...overrides,
}
}
describe('deriveForvalChips', () => {
it('shows currency, invoice date and due terms for an all-default invoice', () => {
expect(deriveForvalChips(chipsInput())).toEqual([
{ kind: 'currency', currency: 'SEK' },
{ kind: 'invoice_date', date: '2026-08-17' },
{ kind: 'due_days', days: 30, date: '2026-09-16' },
])
})
// Issue #1820: the invoice date silently defaulted to today inside the
// collapsed Förval panel; the chip makes the value visible in every mode.
it('surfaces the invoice date as a chip, and skips it while unset', () => {
expect(deriveForvalChips(chipsInput())).toContainEqual({
kind: 'invoice_date',
date: '2026-08-17',
})
expect(deriveForvalChips(chipsInput({ isSelfBilled: true }))).toContainEqual({
kind: 'invoice_date',
date: '2026-08-17',
})
expect(
deriveForvalChips(chipsInput({ invoiceDate: '' })).find((c) => c.kind === 'invoice_date'),
).toBeUndefined()
})
it('surfaces a deviating document type first', () => {
expect(deriveForvalChips(chipsInput({ documentType: 'proforma' }))[0]).toEqual({
kind: 'doc_type',
documentType: 'proforma',
})
})
it('falls back to a plain due date when the invoice date is missing or after', () => {
expect(deriveForvalChips(chipsInput({ invoiceDate: '' }))).toContainEqual({
kind: 'due_date',
date: '2026-09-16',
})
expect(
deriveForvalChips(chipsInput({ invoiceDate: '2026-10-01', dueDate: '2026-09-16' })),
).toContainEqual({ kind: 'due_date', date: '2026-09-16' })
})
it('surfaces every deviation an edit/copy draft may carry', () => {
const chips = deriveForvalChips(
chipsInput({
documentType: 'proforma',
currency: 'EUR',
deliveryDate: '2026-08-20',
yourReference: 'Anna',
paymentLink: 'manual',
oreRounding: false,
dims: 'KS01 · P001',
}),
)
expect(chips).toContainEqual({ kind: 'doc_type', documentType: 'proforma' })
expect(chips).toContainEqual({ kind: 'currency', currency: 'EUR' })
expect(chips).toContainEqual({ kind: 'delivery', date: '2026-08-20' })
expect(chips).toContainEqual({ kind: 'your_reference', reference: 'Anna' })
expect(chips).toContainEqual({ kind: 'payment_link', mode: 'manual' })
expect(chips).toContainEqual({ kind: 'dims', dims: 'KS01 · P001' })
// ore_off is SEK-only: an EUR invoice has no öresavrundning to disable.
expect(chips.find((c) => c.kind === 'ore_off')).toBeUndefined()
})
it('flags disabled öresavrundning on SEK invoices', () => {
expect(deriveForvalChips(chipsInput({ oreRounding: false }))).toContainEqual({
kind: 'ore_off',
})
})
it('reduces to currency, invoice date, due and received for self-billed mode', () => {
const chips = deriveForvalChips(
chipsInput({
isSelfBilled: true,
receivedDate: '2026-08-15',
documentType: 'proforma',
yourReference: 'x',
paymentLink: 'auto',
oreRounding: false,
dims: 'KS01',
}),
)
expect(chips).toEqual([
{ kind: 'currency', currency: 'SEK' },
{ kind: 'invoice_date', date: '2026-08-17' },
{ kind: 'due_days', days: 30, date: '2026-09-16' },
{ kind: 'received', date: '2026-08-15' },
])
})
})
describe('filterArticleSuggestions', () => {
const articles = [
{ id: 'a', article_number: '2', name: 'Skruvdragare' },
{ id: 'b', article_number: '10', name: 'Städning, kontor' },
{ id: 'c', article_number: null, name: 'Konsulttid' },
]
it('browses everything on an empty query', () => {
expect(filterArticleSuggestions(articles, '')).toHaveLength(3)
expect(filterArticleSuggestions(articles, ' ')).toHaveLength(3)
})
it('matches names diacritics-folded', () => {
expect(filterArticleSuggestions(articles, 'stadning')).toEqual([articles[1]])
expect(filterArticleSuggestions(articles, 'STÄD')).toEqual([articles[1]])
})
it('matches article numbers', () => {
expect(filterArticleSuggestions(articles, '10')).toEqual([articles[1]])
})
it('returns nothing when nothing matches', () => {
expect(filterArticleSuggestions(articles, 'zzz')).toEqual([])
})
})