* 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>
221 lines
8.3 KiB
TypeScript
221 lines
8.3 KiB
TypeScript
import { differenceInCalendarDays, isValid, parseISO } from 'date-fns'
|
|
import { foldText } from '@/lib/bookkeeping/account-search'
|
|
|
|
/**
|
|
* Pure derivations behind the invoice editor's snabbflöde shell:
|
|
*
|
|
* - deriveNextStep: the single dynamic "Nästa steg" line (the page's only
|
|
* ochre sentence) and the focus-routing target for an invalid submit.
|
|
* - deriveForvalChips: the Förval chip line summarizing collapsed settings,
|
|
* surfacing every value that deviates from its default so edit/copy mode
|
|
* never round-trips values the user cannot see.
|
|
* - filterArticleSuggestions: the unified row entry's autocomplete filter
|
|
* (diacritics-folded, matches name and article number, same folding as
|
|
* ArticleCombobox).
|
|
*
|
|
* Kept in a plain module (no JSX, no hooks) so the rules are unit-testable:
|
|
* the repo does not render components in tests.
|
|
*/
|
|
|
|
export interface NextStepItem {
|
|
line_type?: 'product' | 'text' | null
|
|
description?: string
|
|
quantity?: number | null
|
|
unit?: string
|
|
unit_price?: number | null
|
|
}
|
|
|
|
export type NextStepRowField = 'description' | 'quantity' | 'unit' | 'unit_price'
|
|
|
|
export type NextStep =
|
|
| { kind: 'customer' }
|
|
| { kind: 'invoice_date' }
|
|
| { kind: 'due_date' }
|
|
| { kind: 'rows_empty' }
|
|
| { kind: 'row_incomplete'; index: number; field: NextStepRowField }
|
|
| { kind: 'payment_link' }
|
|
| { kind: 'personnummer' }
|
|
| { kind: 'housing' }
|
|
| { kind: 'external_number' }
|
|
| { kind: 'received_date' }
|
|
| { kind: 'ready' }
|
|
|
|
export interface NextStepInput {
|
|
isSelfBilled: boolean
|
|
customerSelected: boolean
|
|
invoiceDate: string
|
|
dueDate: string
|
|
receivedDate: string
|
|
externalInvoiceNumber: string
|
|
items: NextStepItem[]
|
|
/** True when the payment link field carries a validation error. */
|
|
paymentLinkInvalid: boolean
|
|
/** A deduction is claimed and neither draft last4 nor kundkort covers it. */
|
|
requiresPersonnummer: boolean
|
|
personnummer: string
|
|
/**
|
|
* A ROT line exists AND a deduction amount is claimed (fastighetsbeteckning
|
|
* is then required). Derive via deriveRequiresHousing so the gate provably
|
|
* matches the ROT/RUT claim card's mount condition.
|
|
*/
|
|
requiresHousing: boolean
|
|
housingDesignation: string
|
|
}
|
|
|
|
/**
|
|
* The housing (fastighetsbeteckning) requirement behind NextStepInput. A ROT
|
|
* line alone is not enough: the claim card only mounts while a deduction
|
|
* amount is claimed (deductionTotal > 0), so a ROT-flagged line whose amount
|
|
* is still zero (transient state while typing) must not produce a housing
|
|
* step, or the next-step link would try to focus an unmounted field.
|
|
*/
|
|
export function deriveRequiresHousing(input: {
|
|
hasRotLine: boolean
|
|
deductionTotal: number
|
|
}): boolean {
|
|
return input.hasRotLine && input.deductionTotal > 0
|
|
}
|
|
|
|
/**
|
|
* Priority order (the same order the invalid-submit focus routing walks):
|
|
* customer -> dates -> first incomplete line -> payment link -> ROT/RUT claim
|
|
* fields -> self-billed extras -> ready.
|
|
*/
|
|
export function deriveNextStep(input: NextStepInput): NextStep {
|
|
if (!input.customerSelected) return { kind: 'customer' }
|
|
if (!input.invoiceDate) return { kind: 'invoice_date' }
|
|
if (!input.dueDate) return { kind: 'due_date' }
|
|
|
|
const productRows = input.items
|
|
.map((item, index) => ({ item, index }))
|
|
.filter(({ item }) => item?.line_type !== 'text')
|
|
if (productRows.length === 0) return { kind: 'rows_empty' }
|
|
for (const { item, index } of productRows) {
|
|
if (!item.description?.trim()) return { kind: 'row_incomplete', index, field: 'description' }
|
|
// Mirrors the schema: quantity >= 0.01 (NaN fails the comparison too).
|
|
if (!((item.quantity ?? 0) >= 0.01)) return { kind: 'row_incomplete', index, field: 'quantity' }
|
|
if (!item.unit?.trim()) return { kind: 'row_incomplete', index, field: 'unit' }
|
|
// Negative prices are lawful discount lines; only a non-number blocks.
|
|
if (!Number.isFinite(item.unit_price ?? 0)) {
|
|
return { kind: 'row_incomplete', index, field: 'unit_price' }
|
|
}
|
|
}
|
|
|
|
if (input.paymentLinkInvalid) return { kind: 'payment_link' }
|
|
if (input.requiresPersonnummer && !input.personnummer.trim()) return { kind: 'personnummer' }
|
|
if (input.requiresHousing && !input.housingDesignation.trim()) return { kind: 'housing' }
|
|
|
|
if (input.isSelfBilled) {
|
|
if (!input.externalInvoiceNumber.trim()) return { kind: 'external_number' }
|
|
if (!input.receivedDate) return { kind: 'received_date' }
|
|
}
|
|
return { kind: 'ready' }
|
|
}
|
|
|
|
export type ForvalChip =
|
|
| { kind: 'doc_type'; documentType: 'proforma' | 'delivery_note' }
|
|
| { kind: 'currency'; currency: string }
|
|
| { kind: 'invoice_date'; date: string }
|
|
| { kind: 'due_days'; days: number; date: string }
|
|
| { kind: 'due_date'; date: string }
|
|
| { kind: 'received'; date: string }
|
|
| { kind: 'delivery'; date: string }
|
|
| { kind: 'your_reference'; reference: string }
|
|
| { kind: 'invoice_marking'; marking: string }
|
|
| { kind: 'payment_link'; mode: 'auto' | 'manual' }
|
|
| { kind: 'ore_off' }
|
|
| { kind: 'dims'; dims: string }
|
|
|
|
export interface ForvalChipsInput {
|
|
isSelfBilled: boolean
|
|
documentType: 'invoice' | 'proforma' | 'delivery_note'
|
|
currency: string
|
|
invoiceDate: string
|
|
dueDate: string
|
|
receivedDate: string
|
|
deliveryDate: string
|
|
yourReference: string
|
|
invoiceMarking: string
|
|
paymentLink: 'auto' | 'manual' | null
|
|
oreRounding: boolean
|
|
/** Compact display of the invoice-level default dims, or null when none. */
|
|
dims: string | null
|
|
}
|
|
|
|
/**
|
|
* The chip line renders the always-relevant defaults (currency, due terms)
|
|
* plus every collapsed setting whose value deviates from its default. A
|
|
* deviating value MUST surface here: in edit/copy mode the draft may carry a
|
|
* proforma type, an EUR currency, a payment link or dimension defaults that
|
|
* would otherwise round-trip invisibly through PATCH.
|
|
*/
|
|
export function deriveForvalChips(input: ForvalChipsInput): ForvalChip[] {
|
|
const chips: ForvalChip[] = []
|
|
if (!input.isSelfBilled && input.documentType !== 'invoice') {
|
|
chips.push({ kind: 'doc_type', documentType: input.documentType })
|
|
}
|
|
chips.push({ kind: 'currency', currency: input.currency })
|
|
// The invoice date always surfaces: it silently defaults to today inside
|
|
// the collapsed panel, and especially in self-billed mode (where the
|
|
// counterparty's issue date must be transcribed) an invisible default
|
|
// registers wrong invoices (issue #1820).
|
|
if (input.invoiceDate) {
|
|
chips.push({ kind: 'invoice_date', date: input.invoiceDate })
|
|
}
|
|
if (input.dueDate) {
|
|
const days = dueDays(input.invoiceDate, input.dueDate)
|
|
if (days !== null && days >= 0) chips.push({ kind: 'due_days', days, date: input.dueDate })
|
|
else chips.push({ kind: 'due_date', date: input.dueDate })
|
|
}
|
|
if (input.isSelfBilled && input.receivedDate) {
|
|
chips.push({ kind: 'received', date: input.receivedDate })
|
|
}
|
|
if (!input.isSelfBilled && input.deliveryDate) {
|
|
chips.push({ kind: 'delivery', date: input.deliveryDate })
|
|
}
|
|
if (!input.isSelfBilled && input.yourReference.trim()) {
|
|
chips.push({ kind: 'your_reference', reference: input.yourReference.trim() })
|
|
}
|
|
if (!input.isSelfBilled && input.invoiceMarking.trim()) {
|
|
chips.push({ kind: 'invoice_marking', marking: input.invoiceMarking.trim() })
|
|
}
|
|
if (!input.isSelfBilled && input.paymentLink) {
|
|
chips.push({ kind: 'payment_link', mode: input.paymentLink })
|
|
}
|
|
if (!input.isSelfBilled && !input.oreRounding && input.currency === 'SEK') {
|
|
chips.push({ kind: 'ore_off' })
|
|
}
|
|
if (!input.isSelfBilled && input.dims) {
|
|
chips.push({ kind: 'dims', dims: input.dims })
|
|
}
|
|
return chips
|
|
}
|
|
|
|
function dueDays(invoiceDate: string, dueDate: string): number | null {
|
|
if (!invoiceDate || !dueDate) return null
|
|
const from = parseISO(invoiceDate)
|
|
const to = parseISO(dueDate)
|
|
if (!isValid(from) || !isValid(to)) return null
|
|
return differenceInCalendarDays(to, from)
|
|
}
|
|
|
|
export interface ArticleSuggestion {
|
|
id: string
|
|
article_number: string | null
|
|
name: string
|
|
}
|
|
|
|
/**
|
|
* Filter for the unified row entry: empty query browses everything, a query
|
|
* matches name and article number, diacritics-folded (same folding as
|
|
* ArticleCombobox so the two article surfaces agree on what matches).
|
|
*/
|
|
export function filterArticleSuggestions<T extends ArticleSuggestion>(
|
|
articles: T[],
|
|
query: string,
|
|
): T[] {
|
|
const q = foldText(query.trim())
|
|
if (!q) return articles
|
|
return articles.filter((a) => foldText(`${a.article_number ?? ''} ${a.name}`).includes(q))
|
|
}
|