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:
co-authored by
Claude Fable 5
parent
84c8e1ce59
commit
f216a60bf8
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user