feat(invoices): per-line percentage discount and separate fakturamarkning (#2084)

* 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>
This commit is contained in:
Mattsson
2026-08-31 15:34:06 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 84c8e1ce59
commit f216a60bf8
35 changed files with 826 additions and 51 deletions
@@ -189,6 +189,7 @@ describe('gnubok_get_invoice: execute', () => {
quantity: 2,
unit: 'tim',
unit_price: 1200,
discount_percent: 0,
line_total: 2400,
vat_rate: 25,
vat_amount: 600,
@@ -359,6 +359,7 @@ describe('gnubok_update_invoice: validation and staging', () => {
expect(result.preview.current_items).toEqual(
CURRENT_ROWS.map((row) => ({
...row,
discount_percent: 0,
deduction_type: null,
accrual_period_start: null,
accrual_period_end: null,
+37 -8
View File
@@ -59,6 +59,7 @@ import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
import { eventBus } from '@/lib/events/bus'
import { getVatRules, getPermittedVatRates, getArticleVatRateAdoptionSet } from '@/lib/invoices/vat-rules'
import { validateDeductionLines } from '@/lib/invoices/rot-rut-rules'
import { computeLineNet } from '@/lib/invoices/line-amounts'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { getBranding } from '@/lib/branding/service'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
@@ -303,6 +304,8 @@ type StagedInvoiceLineInput = {
quantity: number
unit?: string
unit_price?: number
/** Line discount 0-100 (rabatt i procent); totals are computed net of it. */
discount_percent?: number
vat_rate?: number
article_id?: string
revenue_account?: string | null
@@ -370,10 +373,21 @@ function resolveInvoiceLineFromArticle(
quantity: 0,
unit: '',
unit_price: 0,
discount_percent: 0,
vat_rate: 0,
}
}
if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${lineNo}: quantity must be positive`)
// Strict typeof: a host that skips inputSchema validation could send a
// string, which JS comparisons would coerce past a bare range check while
// hasLineDiscount (typeof === 'number') then ignores it in the totals.
if (
item.discount_percent != null &&
(typeof item.discount_percent !== 'number' ||
!(item.discount_percent >= 0 && item.discount_percent <= 100))
) {
throw new Error(`Item ${lineNo}: discount_percent must be a number between 0 and 100`)
}
if (item.article_id && !article) {
throw new Error(`Item ${lineNo}: article ${item.article_id} not found in this company. Use gnubok_list_articles to find valid IDs.`)
}
@@ -6287,6 +6301,7 @@ export const tools: McpTool[] = [
remaining_amount: { type: ['number', 'null'] },
your_reference: { type: ['string', 'null'] },
our_reference: { type: ['string', 'null'] },
invoice_marking: { type: ['string', 'null'], description: 'Fakturamärkning (buyer marking), separate from your_reference' },
notes: { type: ['string', 'null'] },
default_dimensions: { type: 'object', additionalProperties: { type: 'string' } },
editable_draft: { type: 'boolean', description: 'true when gnubok_update_invoice can edit it' },
@@ -6303,6 +6318,7 @@ export const tools: McpTool[] = [
quantity: { type: 'number' },
unit: { type: 'string' },
unit_price: { type: 'number' },
discount_percent: { type: 'number', description: 'Line discount 0-100; line_total is net of it' },
line_total: { type: 'number' },
vat_rate: { type: 'number' },
vat_amount: { type: 'number' },
@@ -6345,7 +6361,7 @@ export const tools: McpTool[] = [
const { data: invoice, error } = await supabase
.from('invoices')
.select(
'id, invoice_number, status, document_type, customer_id, invoice_date, due_date, delivery_date, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, your_reference, our_reference, notes, default_dimensions, journal_entry_id, is_self_billed, credited_invoice_id, customer:customers(name), items:invoice_items(id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions)',
'id, invoice_number, status, document_type, customer_id, invoice_date, due_date, delivery_date, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, your_reference, our_reference, invoice_marking, notes, default_dimensions, journal_entry_id, is_self_billed, credited_invoice_id, customer:customers(name), items:invoice_items(id, sort_order, line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions)',
)
.eq('id', invoiceId)
.eq('company_id', companyId)
@@ -6362,6 +6378,7 @@ export const tools: McpTool[] = [
quantity: number
unit: string
unit_price: number
discount_percent: number | null
line_total: number
vat_rate: number
vat_amount: number | null
@@ -6390,6 +6407,7 @@ export const tools: McpTool[] = [
quantity: row.quantity,
unit: row.unit,
unit_price: row.unit_price,
discount_percent: row.discount_percent ?? 0,
line_total: row.line_total,
vat_rate: row.vat_rate,
vat_amount: row.vat_amount ?? 0,
@@ -6427,6 +6445,7 @@ export const tools: McpTool[] = [
remaining_amount: invoice.remaining_amount ?? null,
your_reference: invoice.your_reference ?? null,
our_reference: invoice.our_reference ?? null,
invoice_marking: invoice.invoice_marking ?? null,
notes: invoice.notes ?? null,
default_dimensions: (invoice.default_dimensions as Record<string, string> | null) ?? {},
editable_draft: isEditableInvoiceDraft(invoice),
@@ -6456,6 +6475,7 @@ export const tools: McpTool[] = [
quantity: { type: 'number' },
unit: { type: 'string', description: 'st, tim, dag, mån' },
unit_price: { type: 'number', description: 'Price per unit excl. VAT' },
discount_percent: { type: 'number', description: 'Line discount 0-100 (rabatt); line total and VAT computed net of it' },
vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' },
article_id: {
type: 'string',
@@ -6483,6 +6503,7 @@ export const tools: McpTool[] = [
currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] },
our_reference: { type: 'string' },
your_reference: { type: 'string' },
invoice_marking: { type: 'string', description: 'Fakturamärkning (buyer marking/PO label), separate from your_reference; feeds Peppol BuyerReference.' },
notes: { type: 'string' },
payment_link_url: {
type: 'string',
@@ -6603,8 +6624,11 @@ export const tools: McpTool[] = [
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(permittedRates.map((r) => r.rate))
// Calculate per-item VAT
const subtotal = items.reduce((s, item) => s + item.quantity * item.unit_price, 0)
// Calculate per-item VAT (line totals net of any per-line discount)
const subtotal = items.reduce(
(s, item) => s + computeLineNet(item.quantity, item.unit_price, item.discount_percent),
0,
)
let vatAmount = 0
for (const item of items) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
@@ -6614,7 +6638,7 @@ export const tools: McpTool[] = [
`Allowed rates: ${permittedRates.map((r) => r.rate + '%').join(', ')}`
)
}
const lineTotal = item.quantity * item.unit_price
const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent)
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
}
const total = subtotal + vatAmount
@@ -6641,6 +6665,7 @@ export const tools: McpTool[] = [
currency,
our_reference: (args.our_reference as string) || null,
your_reference: (args.your_reference as string) || null,
invoice_marking: (args.invoice_marking as string) || null,
notes: (args.notes as string) || null,
payment_link_url: paymentLinkUrl,
},
@@ -6649,7 +6674,7 @@ export const tools: McpTool[] = [
customer_type: customer.customer_type,
items: stagedItems.map(item => ({
...item,
line_total: item.quantity * item.unit_price,
line_total: computeLineNet(item.quantity, item.unit_price, item.discount_percent),
vat_rate: item.vat_rate ?? vatRules.rate,
})),
subtotal: Math.round(subtotal * 100) / 100,
@@ -17174,6 +17199,7 @@ export const tools: McpTool[] = [
delivery_date: { type: ['string', 'null'], description: 'YYYY-MM-DD; null clears the delivery date.' },
your_reference: { type: 'string' },
our_reference: { type: 'string' },
invoice_marking: { type: 'string', description: 'Fakturamärkning (buyer marking/PO label), separate from your_reference.' },
items: {
type: 'array',
items: {
@@ -17183,6 +17209,7 @@ export const tools: McpTool[] = [
quantity: { type: 'number' },
unit: { type: 'string', description: 'st, tim, dag, mån' },
unit_price: { type: 'number', description: 'Price per unit excl. VAT' },
discount_percent: { type: 'number', description: 'Line discount 0-100 (rabatt); pass back to keep it, totals computed net of it.' },
vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' },
article_id: {
type: 'string',
@@ -17259,7 +17286,7 @@ export const tools: McpTool[] = [
}
const headerChanges: Record<string, unknown> = {}
for (const key of ['notes', 'invoice_date', 'due_date', 'delivery_date', 'your_reference', 'our_reference']) {
for (const key of ['notes', 'invoice_date', 'due_date', 'delivery_date', 'your_reference', 'our_reference', 'invoice_marking']) {
if (args[key] !== undefined) headerChanges[key] = args[key]
}
if (rawItems === undefined && args.default_dimensions === undefined && Object.keys(headerChanges).length === 0) {
@@ -17364,7 +17391,7 @@ export const tools: McpTool[] = [
`Allowed rates: ${permittedRates.map((r) => r.rate + '%').join(', ')}`
)
}
const lineTotal = item.quantity * item.unit_price
const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent)
subtotal += lineTotal
vatAmount += roundOre(lineTotal * itemRate / 100)
}
@@ -17379,6 +17406,7 @@ export const tools: McpTool[] = [
deductionLines.map((item) => ({
unit_price: item.unit_price,
quantity: item.quantity,
discount_percent: item.discount_percent ?? 0,
deduction_type: item.deduction_type ?? null,
vat_rate: item.vat_rate ?? vatRules.rate,
labor_hours: item.labor_hours ?? null,
@@ -17421,7 +17449,7 @@ export const tools: McpTool[] = [
// the property columns are not needed for the preview.
const { data: currentRows, error: currentError } = await supabase
.from('invoice_items')
.select('line_type, description, quantity, unit, unit_price, line_total, vat_rate, revenue_account, article_id, deduction_type, accrual_period_start, accrual_period_end')
.select('line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, revenue_account, article_id, deduction_type, accrual_period_start, accrual_period_end')
.eq('invoice_id', invoice.id)
.order('sort_order', { ascending: true })
if (currentError) throw dbError(currentError)
@@ -17431,6 +17459,7 @@ export const tools: McpTool[] = [
quantity: row.quantity,
unit: row.unit,
unit_price: row.unit_price,
discount_percent: row.discount_percent ?? 0,
line_total: row.line_total,
vat_rate: row.vat_rate,
revenue_account: row.revenue_account ?? null,