* feat(invoices): per-line percentage discount and separate fakturamarkning User request: rabatt i procent per artikelrad, and a marking field separate from Er referens. - invoice_items.discount_percent (0-100, default 0): line_total and vat_amount are stored NET of the discount. Shared exact-ore math in lib/invoices/line-amounts.ts (gross, discount, net) used by the web builder, staged-operation commit, editor preview, PDF, and Peppol. Undiscounted lines keep the legacy unrounded qty*price byte-identical. - ROT/RUT deduction computes on the discounted net line total. - invoices.invoice_marking: printed on the PDF next to the references and mapped to Peppol BT-10 BuyerReference (marking wins over your_reference; either satisfies the BT-10 requirement). - Peppol renders the discount as a BG-27 line AllowanceCharge (reason code 95, MultiplierFactorNumeric, Amount, BaseAmount). - Editor: "Lagg till rabatt" in the row menu (same reveal pattern as ROT/RUT), Markning row next to Er referens, forval chip, review dialog shows discounts and marking. - Plumbed through v1 REST projections, MCP create/get/update invoice tools, pending-operations update path, and copy-invoice (discount copied; marking deliberately not, it is recipient-specific). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 * fix(invoices): carry discount_percent through every deduction, credit, convert and preview path Skeptic + CI findings on the discount/marking feature, one pass: - generateRotRutLines and propose-send-lines now pass discount_percent into computeDeduction: the send/credit/cash verifikat booked 1513 on the GROSS line while deduction_total, the PDF and the Skatteverket claim carried the net, stranding the difference on 1513 and pushing 1510 negative once the customer paid. Test pins 1513=3000/1510=7000 for a 20%-discounted 10 000 kr ROT line. - preview-pdf route accepts discount_percent (net totals + net-based deduction) and invoice_marking; the editor now sends the marking, so the preview equals the invoice it becomes. - Credit notes carry discount_percent (buildCreditNoteItem, v1 credit route select+insert, MCP credit executor) and invoice_marking, so the kreditfaktura face arithmetic multiplies out and shows the Rabatt column (ML 17 kap 24 §). - Proforma->invoice convert copies discount_percent + invoice_marking: the converted invoice previously failed Peppol LINE_TOTAL_MISMATCH and lost the rebate on the next builder pass. - Editor hides the discount menu in self-billed mode (the self-billed wire shape has no discount; previewed net would book gross). - MCP staging and commitCreateInvoice reject a non-number discount_percent (a string coerced past the range check but was ignored by the totals math and still stored). - Regenerated skills/accounted-api (apiskill:check CI failure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
295 lines
10 KiB
TypeScript
295 lines
10 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: '',
|
|
invoiceMarking: '',
|
|
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',
|
|
invoiceMarking: 'KST 4711',
|
|
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: 'invoice_marking', marking: 'KST 4711' })
|
|
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',
|
|
invoiceMarking: '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([])
|
|
})
|
|
})
|