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
+11 -1
View File
@@ -14,6 +14,7 @@ import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import { computeLineNet } from '@/lib/invoices/line-amounts'
import {
DEDUCTION_LINE_ERRORS,
HOUSEWORK_TYPE_VALUES,
@@ -403,6 +404,10 @@ export const CreateInvoiceItemSchema = z
quantity: z.number(),
unit: z.string(),
unit_price: z.number(),
// Percentage discount on the line (rabatt i procent per artikelrad).
// line_total and vat_amount are computed NET of this server-side
// (lib/invoices/line-amounts.ts); the client never sends a total.
discount_percent: z.number().min(0).max(100).nullable().optional(),
vat_rate: z.number().min(0).max(100).optional(),
// Article linkage. `article_id` ties the line to a catalog article (text
// rows omit it). `revenue_account` is the legacy wire name for the optional
@@ -459,7 +464,8 @@ export const CreateInvoiceItemSchema = z
message: 'ROT/RUT-rader kan inte periodiseras',
})
}
if (item.quantity * item.unit_price <= 0) {
// Net of any line discount: a 100 % rebated row has nothing to defer.
if (computeLineNet(item.quantity, item.unit_price, item.discount_percent) <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_start'],
@@ -520,6 +526,10 @@ const CreateInvoiceBaseSchema = z.object({
document_type: InvoiceDocumentTypeSchema.optional(),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
// Fakturamärkning: buyer-required marking (kostnadsställe/projekt/PO),
// separate from your_reference. Printed on the PDF and mapped to Peppol
// BT-10 BuyerReference when set.
invoice_marking: z.string().max(200).optional(),
notes: z.string().optional(),
// Optional online payment link (manual MVP): the user pastes a link created
// in their PSP dashboard (e.g. a Stripe Payment Link). https-only because the
+3 -3
View File
@@ -12,7 +12,7 @@
*/
export const INVOICE_FULL_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, ore_rounding, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, ore_rounding, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, invoice_marking, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
/**
* Projection for the v1 PDF download route. Narrower than INVOICE_FULL_COLUMNS
@@ -31,8 +31,8 @@ export const INVOICE_FULL_COLUMNS =
export const INVOICE_PDF_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, document_type, ' +
'currency, subtotal, vat_amount, total, ore_rounding, vat_treatment, vat_rate, moms_ruta, ' +
'reverse_charge_text, your_reference, our_reference, notes, credited_invoice_id, ' +
'reverse_charge_text, your_reference, our_reference, invoice_marking, notes, credited_invoice_id, ' +
'paid_amount, remaining_amount, deduction_total, deduction_personnummer_last4'
export const INVOICE_ITEM_FULL_COLUMNS =
'id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, deduction_amount, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, dimensions, created_at'
'id, sort_order, line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, deduction_amount, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, dimensions, created_at'
@@ -1316,6 +1316,44 @@ describe('createInvoiceJournalEntry: ROT/RUT-avdrag', () => {
expect(totalDebit).toBe(12500)
})
it('discounted ROT line: 1513 books the NET-based deduction, matching the stored deduction_total', async () => {
// 10 000 kr labor, 20% rabatt → net 8 000 + 25% VAT 2 000 = 10 000.
// ROT = 30% of the NET inkl.-moms labor = 30% of 10 000 = 3 000, the same
// figure build-invoice-write stores on deduction_total and the payout
// request claims. Booking the gross (3 750) would strand 750 kr on 1513
// and push kundfordringar (1510) negative when the customer pays 7 000.
const invoice = makeInvoice({
subtotal: 8000,
vat_amount: 2000,
total: 10000,
vat_treatment: 'standard_25',
deduction_total: 3000,
items: [
makeItem({
quantity: 1,
unit_price: 10000,
discount_percent: 20,
line_total: 8000,
vat_rate: 25,
vat_amount: 2000,
deduction_type: 'rot',
deduction_amount: 3000,
}),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1513 = input.lines.find((l) => l.account_number === '1513')
expect(debit1513?.debit_amount).toBe(3000)
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(7000)
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit)
})
it('mixed invoice: ROT line + non-deduction line, per-item handling', async () => {
// ROT line 10 000 (deduction 30% of 12 500 inkl. moms = 3 750) +
// non-deduction materials line 4 000.
+4
View File
@@ -331,6 +331,10 @@ function generateRotRutLines(
const amount = computeDeduction({
unit_price: side === 'credit' ? Math.abs(item.unit_price) : item.unit_price,
quantity: side === 'credit' ? Math.abs(item.quantity) : item.quantity,
// The deduction base is the NET line total (rabatt reduces what the
// customer pays); omitting this books 1513 on the gross while the
// stored deduction_total and the Skatteverket claim carry the net.
discount_percent: item.discount_percent ?? 0,
deduction_type: item.deduction_type,
vat_rate: item.vat_rate,
})
+3
View File
@@ -245,6 +245,9 @@ function buildSendLines(
const deduction = computeDeduction({
unit_price: item.unit_price,
quantity: item.quantity,
// Net of any line discount: must match the stored deduction_total or
// the proposed 1513/1510 split cannot clear.
discount_percent: item.discount_percent ?? 0,
deduction_type: item.deduction_type,
vat_rate: item.vat_rate,
})
@@ -52,4 +52,19 @@ describe('buildCreditNoteItem', () => {
dimensions: { '6': 'P001' },
})
})
it('carries discount_percent so the kreditfaktura face arithmetic multiplies out', () => {
// Original: 2 x 1000 with 10% rabatt → net 1800. The credit row must keep
// the discount, or -2 x 1000 next to Summa -1800 prints with no visible
// prisnedsättning (ML 17 kap 24 §) and violates the stored net invariant.
const result = buildCreditNoteItem('credit-1', item({ discount_percent: 10, line_total: 1800, vat_amount: 450 }))
expect(result).toMatchObject({
quantity: -2,
discount_percent: 10,
line_total: -1800,
vat_amount: -450,
})
// Legacy rows without the column default to 0.
expect(buildCreditNoteItem('credit-1', item()).discount_percent).toBe(0)
})
})
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase, makeCustomer } from '@/tests/helpers'
import { computeLineAmounts, computeLineNet, hasLineDiscount } from '@/lib/invoices/line-amounts'
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
describe('computeLineAmounts', () => {
it('passes qty * price through untouched when no discount applies', () => {
// Legacy parity: existing invoices store the unrounded product.
expect(computeLineAmounts(3, 33.333)).toEqual({
gross: 3 * 33.333,
discount: 0,
net: 3 * 33.333,
})
expect(computeLineAmounts(2, 100, null)).toEqual({ gross: 200, discount: 0, net: 200 })
expect(computeLineAmounts(2, 100, 0)).toEqual({ gross: 200, discount: 0, net: 200 })
})
it('computes discount and net in exact ore arithmetic', () => {
expect(computeLineAmounts(2, 100, 10)).toEqual({ gross: 200, discount: 20, net: 180 })
// 1 * 99.99 at 33%: gross 99.99, discount round(32.9967) = 33.00, net 66.99.
expect(computeLineAmounts(1, 99.99, 33)).toEqual({ gross: 99.99, discount: 33, net: 66.99 })
// gross - discount is always exact: net + discount reconstructs gross.
const amounts = computeLineAmounts(7, 123.45, 12.5)
expect(amounts.net + amounts.discount).toBeCloseTo(amounts.gross, 10)
})
it('handles a 100% discount as a zero net line', () => {
expect(computeLineAmounts(4, 250, 100)).toEqual({ gross: 1000, discount: 1000, net: 0 })
expect(computeLineNet(4, 250, 100)).toBe(0)
})
it('hasLineDiscount treats null/undefined/0 as no discount', () => {
expect(hasLineDiscount(undefined)).toBe(false)
expect(hasLineDiscount(null)).toBe(false)
expect(hasLineDiscount(0)).toBe(false)
expect(hasLineDiscount(0.5)).toBe(true)
})
})
describe('computeDeduction with a line discount', () => {
it('deducts on the net line total (what the customer pays)', () => {
// 10 tim * 1000 = 10 000, 10% rabatt -> 9 000 net, incl VAT 11 250,
// ROT 30% = 3 375 (vs 3 750 undiscounted).
expect(
computeDeduction({
unit_price: 1000,
quantity: 10,
discount_percent: 10,
deduction_type: 'rot',
vat_rate: 25,
}),
).toBe(3375)
expect(
computeDeduction({ unit_price: 1000, quantity: 10, deduction_type: 'rot', vat_rate: 25 }),
).toBe(3750)
})
})
describe('buildInvoiceWriteData with per-line discount and invoice_marking', () => {
const baseHeader = {
customer_id: 'customer-1',
invoice_date: '2026-06-15',
due_date: '2026-07-15',
currency: 'SEK' as const,
}
it('stores net line totals, VAT on the net, and the discount on the row', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({ customer_type: 'swedish_business' })
const result = await buildInvoiceWriteData({
supabase: supabase as unknown as SupabaseClient,
companyId: 'company-1',
customer,
documentType: 'invoice',
input: {
...baseHeader,
items: [
{ description: 'Konsult', quantity: 10, unit: 'tim', unit_price: 1000, vat_rate: 25, discount_percent: 10 },
{ description: 'Resa', quantity: 1, unit: 'st', unit_price: 500, vat_rate: 25 },
],
},
})
expect(result.ok).toBe(true)
if (!result.ok) return
// 10 000 - 10% = 9 000 net + 500 undiscounted.
expect(result.invoiceFields.subtotal).toBe(9500)
expect(result.invoiceFields.vat_amount).toBe(2375)
expect(result.invoiceFields.total).toBe(11875)
expect(result.items[0]).toMatchObject({
discount_percent: 10,
line_total: 9000,
vat_amount: 2250,
unit_price: 1000,
})
expect(result.items[1]).toMatchObject({ discount_percent: 0, line_total: 500 })
})
it('maps invoice_marking to a concrete trimmed value, null when absent or blank', async () => {
const customer = makeCustomer({ customer_type: 'swedish_business' })
const items = [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 100, vat_rate: 25 }]
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const withMarking = await buildInvoiceWriteData({
supabase: supabase as unknown as SupabaseClient,
companyId: 'company-1',
customer,
documentType: 'invoice',
input: { ...baseHeader, invoice_marking: ' KST 4711 ', items },
})
expect(withMarking.ok).toBe(true)
if (!withMarking.ok) return
expect(withMarking.invoiceFields.invoice_marking).toBe('KST 4711')
// Absent/blank input must produce an explicit null (supabase-js drops
// undefined keys, and a draft edit that cleared the field relies on NULL
// actually being written).
const { supabase: supabase2, enqueue: enqueue2 } = createQueuedMockSupabase()
enqueue2({ data: { vat_registered: true }, error: null })
const withoutMarking = await buildInvoiceWriteData({
supabase: supabase2 as unknown as SupabaseClient,
companyId: 'company-1',
customer,
documentType: 'invoice',
input: { ...baseHeader, invoice_marking: ' ', items },
})
expect(withoutMarking.ok).toBe(true)
if (!withoutMarking.ok) return
expect(withoutMarking.invoiceFields.invoice_marking).toBeNull()
})
it('computes the ROT deduction on the discounted line total', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { vat_registered: true }, error: null })
const customer = makeCustomer({ customer_type: 'individual' })
const result = await buildInvoiceWriteData({
supabase: supabase as unknown as SupabaseClient,
companyId: 'company-1',
customer,
documentType: 'invoice',
input: {
...baseHeader,
deduction_personnummer: '199001019802',
deduction_housing_designation: 'Testbrand 1:1',
items: [
{
description: 'Renovering arbete',
quantity: 10,
unit: 'tim',
unit_price: 1000,
vat_rate: 25,
discount_percent: 10,
deduction_type: 'rot',
work_type: 'BYGG',
labor_hours: 10,
},
],
},
})
expect(result.ok).toBe(true)
if (!result.ok) return
// Net 9 000 excl VAT -> 11 250 incl VAT -> ROT 30% = 3 375.
expect(result.items[0].deduction_amount).toBe(3375)
expect(result.invoiceFields.deduction_total).toBe(3375)
})
})
@@ -259,6 +259,71 @@ describe('generatePeppolBisBillingInvoice', () => {
)
})
it('prefers invoice_marking over your_reference for BT-10 BuyerReference', () => {
const input = makeValidInput()
input.invoice = makeInvoice({ ...input.invoice, invoice_marking: 'KST 4711' })
const result = generatePeppolBisBillingInvoice(input)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.xml).toContain('<cbc:BuyerReference>KST 4711</cbc:BuyerReference>')
})
it('accepts a marking-only invoice (no your_reference) as buyer reference', () => {
const input = makeValidInput()
input.invoice = makeInvoice({
...input.invoice,
your_reference: null,
invoice_marking: 'PO-2026-17',
})
const result = generatePeppolBisBillingInvoice(input)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.xml).toContain('<cbc:BuyerReference>PO-2026-17</cbc:BuyerReference>')
})
it('renders a per-line discount as a BG-27 allowance with net LineExtensionAmount', () => {
const input = makeValidInput()
// 2 × 100 = 200 gross, 10% discount = 20, net 180, VAT 25% on net = 45.
input.items = [
makeItem({ discount_percent: 10, line_total: 180, vat_amount: 45 }),
]
input.invoice = makeInvoice({
...input.invoice,
subtotal: 180,
vat_amount: 45,
total: 225,
remaining_amount: 225,
})
const result = generatePeppolBisBillingInvoice(input)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.xml).toContain('<cbc:LineExtensionAmount currencyID="SEK">180.00</cbc:LineExtensionAmount>')
expect(result.xml).toContain('<cbc:ChargeIndicator>false</cbc:ChargeIndicator>')
expect(result.xml).toContain('<cbc:AllowanceChargeReasonCode>95</cbc:AllowanceChargeReasonCode>')
expect(result.xml).toContain('<cbc:MultiplierFactorNumeric>10</cbc:MultiplierFactorNumeric>')
expect(result.xml).toContain('<cbc:Amount currencyID="SEK">20.00</cbc:Amount>')
expect(result.xml).toContain('<cbc:BaseAmount currencyID="SEK">200.00</cbc:BaseAmount>')
// The undiscounted unit price stays in cac:Price (BT-146).
expect(result.xml).toContain('<cbc:PriceAmount currencyID="SEK">100</cbc:PriceAmount>')
})
it('rejects a discounted line whose stored total is not net of the discount', () => {
const input = makeValidInput()
input.items = [makeItem({ discount_percent: 10, line_total: 200, vat_amount: 50 })]
const result = generatePeppolBisBillingInvoice(input)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.issues.map(({ code }) => code)).toContain('LINE_TOTAL_MISMATCH')
})
it('rejects credit notes and self-billed invoices in the generation layer', () => {
for (const invoice of [
makeInvoice({
@@ -19,6 +19,7 @@ function makeItem(overrides: Partial<InvoiceWriteItemRow> = {}): InvoiceWriteIte
quantity: 1,
unit: 'tim',
unit_price: 1000,
discount_percent: 0,
line_total: 1000,
vat_rate: 25,
vat_amount: 250,
+4
View File
@@ -9,6 +9,10 @@ export function buildCreditNoteItem(invoiceId: string, item: InvoiceItem) {
quantity: -Math.abs(item.quantity),
unit: item.unit,
unit_price: item.unit_price,
// Carried so the kreditfaktura's face arithmetic still multiplies out
// (antal x a-pris - rabatt = summa) and the PDF shows the same Rabatt
// column the original did (ML 17 kap 24 §: prisnedsattningen ska framga).
discount_percent: item.discount_percent ?? 0,
line_total: -Math.abs(item.line_total),
vat_rate: item.vat_rate ?? 0,
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
+23 -3
View File
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import type { Currency, Customer, InvoiceDocumentType } from '@/types'
import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules'
import { isBalanceSheetAccount } from '@/lib/invoices/posting-account'
import { computeLineNet } from '@/lib/invoices/line-amounts'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import {
@@ -46,6 +47,9 @@ export interface InvoiceWriteItemInput {
quantity: number
unit: string
unit_price: number
/** Percentage discount on the line (0-100). Omitted/null = 0; line_total
* and vat_amount are computed NET of it (lib/invoices/line-amounts.ts). */
discount_percent?: number | null
vat_rate?: number
article_id?: string | null
revenue_account?: string | null
@@ -70,6 +74,8 @@ export interface InvoiceWriteInput {
currency: Currency
your_reference?: string
our_reference?: string
/** Fakturamärkning: buyer-required marking, separate from your_reference. */
invoice_marking?: string
notes?: string
/** Optional https payment link (schema-validated). Omitted/empty → null. */
payment_link_url?: string
@@ -111,6 +117,7 @@ export type InvoiceWriteFields = {
reverse_charge_text: string | null
your_reference: string | null | undefined
our_reference: string | null | undefined
invoice_marking: string | null
notes: string | null | undefined
payment_link_url: string | null
payment_link_auto: boolean
@@ -129,6 +136,7 @@ export type InvoiceWriteItemRow = {
quantity: number
unit: string
unit_price: number
discount_percent: number
line_total: number
vat_rate: number
vat_amount: number
@@ -224,8 +232,12 @@ export async function buildInvoiceWriteData(params: {
}
// Free-text rows carry no amounts and are excluded from totals + VAT.
// Line totals are net of any per-line discount (rabatt i procent).
const subtotal = items.reduce(
(sum, item) => (item.line_type === 'text' ? sum : sum + item.quantity * item.unit_price),
(sum, item) =>
item.line_type === 'text'
? sum
: sum + computeLineNet(item.quantity, item.unit_price, item.discount_percent),
0,
)
@@ -260,7 +272,7 @@ export async function buildInvoiceWriteData(params: {
details: { account: item.revenue_account, vatRate: itemRate },
}
}
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
}
}
@@ -330,6 +342,7 @@ export async function buildInvoiceWriteData(params: {
const validateInput = items.map((item) => ({
unit_price: item.unit_price,
quantity: item.quantity,
discount_percent: item.discount_percent ?? 0,
deduction_type: item.deduction_type ?? null,
// The deduction base is arbetskostnaden inkl. moms (HUSFL 6-9 §§), so
// the validator and total need the same per-line rate the item rows
@@ -513,6 +526,9 @@ export async function buildInvoiceWriteData(params: {
reverse_charge_text: notVatRegistered ? null : (headerRules.reverseChargeText || null),
your_reference: input.your_reference,
our_reference: input.our_reference,
// Always a concrete value so a draft edit that cleared the field NULLs
// the column (supabase-js drops undefined keys).
invoice_marking: input.invoice_marking?.trim() || null,
notes: input.notes,
// Always a concrete value (never undefined) so a draft edit that cleared
// the field actually NULLs the column: supabase-js drops undefined keys.
@@ -542,6 +558,7 @@ export async function buildInvoiceWriteData(params: {
quantity: 0,
unit: '',
unit_price: 0,
discount_percent: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
@@ -561,7 +578,8 @@ export async function buildInvoiceWriteData(params: {
}
}
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const discountPercent = item.discount_percent ?? 0
const lineTotal = computeLineNet(item.quantity, item.unit_price, discountPercent)
const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100
// ROT/RUT deduction is recomputed server-side so a tampered client can't
// expand the 1513 receivable beyond the rules. Non-invoice document types
@@ -571,6 +589,7 @@ export async function buildInvoiceWriteData(params: {
? computeDeduction({
unit_price: item.unit_price,
quantity: item.quantity,
discount_percent: discountPercent,
deduction_type: deductionType,
vat_rate: itemRate,
})
@@ -582,6 +601,7 @@ export async function buildInvoiceWriteData(params: {
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
discount_percent: discountPercent,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
+3
View File
@@ -22,6 +22,7 @@ export interface InvoiceCopyItem {
quantity: number
unit: string
unit_price: number
discount_percent: number
vat_rate: number
article_id: null
revenue_account: string | null
@@ -85,6 +86,8 @@ export function buildInvoiceCopyInitial(source: InvoiceCopySource): InvoiceCopyI
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
// Agreed price reduction is reusable commercial content, like the price.
discount_percent: item.discount_percent ?? 0,
vat_rate: item.vat_rate ?? 25,
// A copied line keeps the frozen description and price, but is not
// linked to a possibly changed or archived article preset.
+60
View File
@@ -0,0 +1,60 @@
import { roundOre } from '@/lib/money'
/**
* Shared per-line amount math for invoice items with an optional percentage
* discount (rabatt i procent per artikelrad).
*
* The formula set is deliberately exact in öre so every surface (editor
* preview, build-invoice-write, staged-operation commit, PDF, Peppol BG-27
* line allowance) agrees to the öre:
*
* gross = roundOre(quantity * unit_price)
* discount = roundOre(gross * discount_percent / 100)
* net = roundOre(gross - discount)
*
* `net` is what is stored as invoice_items.line_total and what VAT is
* computed on (the discount reduces the beskattningsunderlag, ML 8 kap 13 §).
* Because `discount` is rounded before the subtraction, gross - discount is
* exact 2-decimal arithmetic and the UBL line check
* LineExtensionAmount = base - allowance holds without a tolerance.
*
* A line with no discount keeps the legacy unrounded `quantity * unit_price`
* as its net so existing invoices, stored line_totals and the Peppol
* LINE_TOTAL_MISMATCH check stay byte-identical.
*/
export interface LineAmounts {
/** Line amount before discount (rounded to öre when a discount applies). */
gross: number
/** Discount amount in invoice currency (0 when no discount). */
discount: number
/** Line amount after discount: what line_total stores and VAT applies to. */
net: number
}
/** True when the value is a discount that actually changes the line. */
export function hasLineDiscount(discountPercent: number | null | undefined): boolean {
return typeof discountPercent === 'number' && discountPercent > 0
}
export function computeLineAmounts(
quantity: number,
unitPrice: number,
discountPercent?: number | null,
): LineAmounts {
const raw = (quantity || 0) * (unitPrice || 0)
if (!hasLineDiscount(discountPercent)) {
return { gross: raw, discount: 0, net: raw }
}
const gross = roundOre(raw)
const discount = roundOre((gross * (discountPercent as number)) / 100)
return { gross, discount, net: roundOre(gross - discount) }
}
/** Convenience: the net line total (what invoice_items.line_total stores). */
export function computeLineNet(
quantity: number,
unitPrice: number,
discountPercent?: number | null,
): number {
return computeLineAmounts(quantity, unitPrice, discountPercent).net
}
+32
View File
@@ -55,6 +55,7 @@ const LABELS = {
deliveryDate: 'Leveransdatum:',
yourReference: 'Er referens:',
ourReference: 'Vår referens:',
invoiceMarking: 'Märkning:',
// Customer box
custNo: 'Kundnr:',
orgNo: 'Org.nr:',
@@ -64,6 +65,7 @@ const LABELS = {
colQty: 'Antal',
colUnit: 'Enhet',
colUnitPrice: 'à-pris',
colDiscount: 'Rabatt',
colVat: 'Moms',
colTotal: 'Summa',
// Totals
@@ -136,6 +138,7 @@ const LABELS = {
deliveryDate: 'Delivery date:',
yourReference: 'Your reference:',
ourReference: 'Our reference:',
invoiceMarking: 'Buyer reference:',
custNo: 'Customer no.:',
orgNo: 'Reg. no.:',
vat: 'VAT:',
@@ -143,6 +146,7 @@ const LABELS = {
colQty: 'Qty',
colUnit: 'Unit',
colUnitPrice: 'Unit price',
colDiscount: 'Discount',
colVat: 'VAT',
colTotal: 'Amount',
subtotal: 'Subtotal:',
@@ -367,6 +371,10 @@ function createStyles(branding?: InvoiceBranding) {
flex: 1.5,
textAlign: 'right',
},
colDiscount: {
flex: 1,
textAlign: 'right',
},
colVat: {
flex: 1,
textAlign: 'right',
@@ -785,6 +793,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
? new Set(billableItems.map((item) => item.vat_rate))
: new Set<number>()
const showVatColumn = hasPerLineVat && uniqueRates.size > 1
// Rabatt column only when some line actually carries a discount: the
// stored line_total is already net, so the column documents the reduction
// (ML 17 kap 24 § p.10: prisnedsättning ska framgå av fakturan).
const showDiscountColumn = billableItems.some((item) => (item.discount_percent ?? 0) > 0)
// Calculate per-rate VAT breakdown for totals
const vatByRate = hasPerLineVat
@@ -950,6 +962,18 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
</View>
)}
{/* Fakturamärkning: one buyer-required marking string, never
comma-split (a PO/cost-center label may contain commas). */}
{invoice.invoice_marking && (
<View style={{ marginBottom: 4 }}>
<Text style={styles.label}>{L.invoiceMarking}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 2 }}>
<Text style={{ backgroundColor: '#f0f0f0', borderRadius: 3, paddingHorizontal: 6, paddingVertical: 2, fontSize: 9, fontWeight: 'bold' }}>
{invoice.invoice_marking.trim()}
</Text>
</View>
</View>
)}
</View>
{/* Customer */}
@@ -1003,6 +1027,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{!isDeliveryNote && (
<Text style={[styles.colPrice, styles.tableHeaderText]}>{L.colUnitPrice}</Text>
)}
{!isDeliveryNote && showDiscountColumn && (
<Text style={[styles.colDiscount, styles.tableHeaderText]}>{L.colDiscount}</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={[styles.colVat, styles.tableHeaderText]}>{L.colVat}</Text>
)}
@@ -1029,6 +1056,11 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{!isDeliveryNote && (
<Text style={styles.colPrice}>{formatPdfCurrency(item.unit_price, invoice.currency, lang)}</Text>
)}
{!isDeliveryNote && showDiscountColumn && (
<Text style={styles.colDiscount}>
{(item.discount_percent ?? 0) > 0 ? `${item.discount_percent}%` : ''}
</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
)}
+42 -9
View File
@@ -4,6 +4,7 @@ import {
validatePlusgiroNumber,
} from '@/lib/bankgiro/luhn'
import { isSaneDateString, normalizeOrgNumber } from '@/lib/invariants'
import { computeLineAmounts, hasLineDiscount } from '@/lib/invoices/line-amounts'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { equalOre, roundOre } from '@/lib/money'
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types'
@@ -270,11 +271,13 @@ function prepareInvoice(input: PeppolInvoiceInput):
'Invoice, due, and delivery dates must be valid dates.',
))
}
if (!hasText(invoice.your_reference)) {
// BT-10 BuyerReference: fakturamärkning wins when set (SFTI convention:
// the buyer's routing/marking string), else Er referens.
if (!hasText(invoice.invoice_marking) && !hasText(invoice.your_reference)) {
issues.push(validationIssue(
'BUYER_REFERENCE_REQUIRED', 'invoice.your_reference',
'Er referens krävs för Peppol när inköpsordernummer saknas.',
'Buyer reference is required for Peppol when no purchase order reference is available.',
'Märkning eller Er referens krävs för Peppol när inköpsordernummer saknas.',
'A marking or buyer reference is required for Peppol when no purchase order reference is available.',
))
}
if ((invoice.deduction_total ?? 0) !== 0) {
@@ -396,11 +399,22 @@ function prepareInvoice(input: PeppolInvoiceInput):
`The VAT rate on invoice line ${index + 1} must be 6, 12, or 25 percent.`,
))
}
if (!equalMoney(item.line_total, roundMoney(item.quantity * item.unit_price))) {
// Net of any line discount: line_total must equal (qty × price) − rabatt,
// the same exact öre arithmetic the write path stores
// (lib/invoices/line-amounts.ts) and the BG-27 allowance below renders.
const expectedAmounts = computeLineAmounts(item.quantity, item.unit_price, item.discount_percent)
if (!equalMoney(item.line_total, roundMoney(expectedAmounts.net))) {
issues.push(validationIssue(
'LINE_TOTAL_MISMATCH', `${lineField}.line_total`,
`Beloppet på fakturarad ${index + 1} stämmer inte med antal gånger pris.`,
`The amount on invoice line ${index + 1} does not equal quantity times price.`,
`Beloppet på fakturarad ${index + 1} stämmer inte med antal gånger pris minus rabatt.`,
`The amount on invoice line ${index + 1} does not equal quantity times price less discount.`,
))
}
if (item.discount_percent !== undefined && (item.discount_percent < 0 || item.discount_percent > 100)) {
issues.push(validationIssue(
'LINE_DISCOUNT_INVALID', `${lineField}.discount_percent`,
`Rabatten på fakturarad ${index + 1} måste vara mellan 0 och 100 procent.`,
`The discount on invoice line ${index + 1} must be between 0 and 100 percent.`,
))
}
if (!equalMoney(item.vat_amount, roundMoney(item.line_total * item.vat_rate / 100))) {
@@ -552,11 +566,29 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice):
' </cac:TaxCategory>',
' </cac:TaxSubtotal>',
])
const invoiceLines = prepared.productItems.flatMap((item, index) => [
const invoiceLines = prepared.productItems.flatMap((item, index) => {
// Line discount as a BG-27 allowance: LineExtensionAmount stays the net
// line_total and the allowance documents base − amount = net exactly
// (the amounts come from the same öre arithmetic as the stored total).
const amounts = computeLineAmounts(item.quantity, item.unit_price, item.discount_percent)
const allowance = hasLineDiscount(item.discount_percent)
? [
' <cac:AllowanceCharge>',
' <cbc:ChargeIndicator>false</cbc:ChargeIndicator>',
' <cbc:AllowanceChargeReasonCode>95</cbc:AllowanceChargeReasonCode>',
' <cbc:AllowanceChargeReason>Rabatt</cbc:AllowanceChargeReason>',
` <cbc:MultiplierFactorNumeric>${formatDecimal(item.discount_percent as number)}</cbc:MultiplierFactorNumeric>`,
` <cbc:Amount currencyID="SEK">${formatMoney(amounts.discount)}</cbc:Amount>`,
` <cbc:BaseAmount currencyID="SEK">${formatMoney(amounts.gross)}</cbc:BaseAmount>`,
' </cac:AllowanceCharge>',
]
: []
return [
' <cac:InvoiceLine>',
` <cbc:ID>${index + 1}</cbc:ID>`,
` <cbc:InvoicedQuantity unitCode="${UNIT_CODES[item.unit]}">${formatDecimal(item.quantity)}</cbc:InvoicedQuantity>`,
` <cbc:LineExtensionAmount currencyID="SEK">${formatMoney(item.line_total)}</cbc:LineExtensionAmount>`,
...allowance,
' <cac:Item>',
` <cbc:Name>${escapeXml(item.description.trim())}</cbc:Name>`,
' <cac:ClassifiedTaxCategory>',
@@ -569,7 +601,8 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice):
` <cbc:PriceAmount currencyID="SEK">${formatDecimal(item.unit_price)}</cbc:PriceAmount>`,
' </cac:Price>',
' </cac:InvoiceLine>',
])
]
})
return [
'<?xml version="1.0" encoding="UTF-8"?>',
@@ -584,7 +617,7 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice):
' <cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>',
invoice.notes ? ` <cbc:Note>${escapeXml(invoice.notes)}</cbc:Note>` : null,
' <cbc:DocumentCurrencyCode>SEK</cbc:DocumentCurrencyCode>',
` <cbc:BuyerReference>${escapeXml(invoice.your_reference?.trim() ?? '')}</cbc:BuyerReference>`,
` <cbc:BuyerReference>${escapeXml((invoice.invoice_marking?.trim() || invoice.your_reference?.trim()) ?? '')}</cbc:BuyerReference>`,
renderParty('AccountingSupplierParty', prepared.supplier, company.f_skatt),
renderParty('AccountingCustomerParty', prepared.buyer, false),
invoice.delivery_date
+9 -1
View File
@@ -1,4 +1,5 @@
import { roundOre } from '@/lib/money'
import { computeLineNet } from '@/lib/invoices/line-amounts'
/**
* ROT/RUT-avdrag rules.
@@ -211,6 +212,12 @@ export interface ItemForDeduction {
unit_price: number
/** Quantity. Same field as invoice_items.quantity. */
quantity: number
/**
* Percentage discount on the line (0-100), invoice_items.discount_percent.
* The deduction base is the amount the customer actually pays, so a
* discounted line deducts on the NET line total. Omitted/null = 0.
*/
discount_percent?: number | null
/** 'rot' | 'rut' | null. Drives whether the deduction kicks in at all. */
deduction_type?: DeductionType | null
/**
@@ -242,7 +249,8 @@ export interface ItemForDeduction {
*/
export function computeDeduction(item: ItemForDeduction): number {
if (!item.deduction_type) return 0
const lineTotal = item.unit_price * item.quantity
// Net of any line discount: the deduction follows what the customer pays.
const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent)
if (lineTotal <= 0) return 0
const rate = item.vat_rate ?? 0
const lineVat = rate > 0 ? Math.round(lineTotal * rate / 100 * 100) / 100 : 0
+28 -5
View File
@@ -168,6 +168,7 @@ import {
type InvoiceWriteInput,
type InvoiceWriteItemInput,
} from '@/lib/invoices/build-invoice-write'
import { computeLineNet } from '@/lib/invoices/line-amounts'
import { deleteDraftInvoice } from '@/lib/invoices/delete-draft-invoice'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items'
@@ -1631,6 +1632,7 @@ async function commitCreateInvoice(
const customerId = params.customer_id as string
const items = params.items as Array<{
description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number
discount_percent?: number | null
article_id?: string | null; revenue_account?: string | null
line_type?: 'product' | 'text'
dimensions?: Record<string, string>
@@ -1674,7 +1676,12 @@ async function commitCreateInvoice(
const notVatRegistered = vatSettings?.vat_registered === false
if (notVatRegistered) for (const item of items) item.vat_rate = 0
const subtotal = billableItems.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
// Line totals net of any per-line discount, same math as the web path
// (lib/invoices/line-amounts.ts).
const subtotal = billableItems.reduce(
(sum, item) => sum + computeLineNet(item.quantity, item.unit_price, item.discount_percent),
0,
)
let vatAmount = 0
for (const item of billableItems) {
@@ -1682,7 +1689,14 @@ async function commitCreateInvoice(
if (!allowedRates.has(itemRate)) {
return { error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp`, status: 400 }
}
const lineTotal = item.quantity * item.unit_price
// Strict typeof: staged params are JSON a tampered client could shape;
// a string would coerce past a bare range check but be ignored by the
// number-typed totals math, then land in the NUMERIC column anyway.
const discountPercent = item.discount_percent ?? 0
if (typeof discountPercent !== 'number' || !(discountPercent >= 0 && discountPercent <= 100)) {
return { error: 'Rabatten per rad måste vara mellan 0 och 100 procent', status: 400 }
}
const lineTotal = computeLineNet(item.quantity, item.unit_price, discountPercent)
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
}
@@ -1824,6 +1838,7 @@ async function commitCreateInvoice(
reverse_charge_text: notVatRegistered ? null : (vatRules.reverseChargeText || null),
our_reference: (params.our_reference as string) || null,
your_reference: (params.your_reference as string) || null,
invoice_marking: (params.invoice_marking as string) || null,
notes: (params.notes as string) || null,
payment_link_url: paymentLinkUrl,
default_dimensions: defaultDimensions ?? {},
@@ -1846,6 +1861,7 @@ async function commitCreateInvoice(
quantity: 0,
unit: '',
unit_price: 0,
discount_percent: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
@@ -1855,7 +1871,8 @@ async function commitCreateInvoice(
}
}
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const discountPercent = item.discount_percent ?? 0
const lineTotal = computeLineNet(item.quantity, item.unit_price, discountPercent)
const itemVat = Math.round(lineTotal * itemRate / 100 * 100) / 100
return {
invoice_id: invoice.id,
@@ -1865,6 +1882,7 @@ async function commitCreateInvoice(
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
discount_percent: discountPercent,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
@@ -1942,7 +1960,7 @@ async function commitUpdateInvoice(
const { data: existing, error: fetchError } = await supabase
.from('invoices')
.select(
'id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, customer_id, document_type, invoice_date, due_date, delivery_date, currency, your_reference, our_reference, notes, payment_link_url, payment_link_auto, ore_rounding, default_dimensions, deduction_personnummer_encrypted, deduction_personnummer_last4',
'id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, customer_id, document_type, invoice_date, due_date, delivery_date, currency, your_reference, our_reference, invoice_marking, notes, payment_link_url, payment_link_auto, ore_rounding, default_dimensions, deduction_personnummer_encrypted, deduction_personnummer_last4',
)
.eq('id', invoiceId)
.eq('company_id', companyId)
@@ -2003,7 +2021,7 @@ async function commitUpdateInvoice(
const { data: itemRows, error: itemsFetchError } = await supabase
.from('invoice_items')
.select(
'line_type, description, quantity, unit, unit_price, vat_rate, 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',
'line_type, description, quantity, unit, unit_price, discount_percent, vat_rate, 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('invoice_id', invoiceId)
.order('sort_order', { ascending: true })
@@ -2028,6 +2046,7 @@ async function commitUpdateInvoice(
currency: existing.currency as Currency,
your_reference: changes.your_reference ?? existing.your_reference ?? undefined,
our_reference: changes.our_reference ?? existing.our_reference ?? undefined,
invoice_marking: changes.invoice_marking ?? existing.invoice_marking ?? undefined,
notes: changes.notes ?? existing.notes ?? undefined,
// Not editable through this operation: fed back so the builder echoes the
// stored values instead of clearing them.
@@ -4553,6 +4572,7 @@ async function commitCreditInvoice(
reverse_charge_text: original.reverse_charge_text,
your_reference: original.your_reference,
our_reference: original.our_reference,
invoice_marking: original.invoice_marking ?? null,
notes: reason || `Krediterar faktura ${original.invoice_number}`,
credited_invoice_id: id,
// Dimensions PR7: copy so the reversal nets against the same cells.
@@ -4573,6 +4593,7 @@ async function commitCreditInvoice(
quantity: number
unit: string
unit_price: number
discount_percent?: number | null
line_total: number
vat_rate?: number
vat_amount?: number
@@ -4587,6 +4608,8 @@ async function commitCreditInvoice(
quantity: -Math.abs(item.quantity),
unit: item.unit,
unit_price: item.unit_price,
// Kreditfakturans face arithmetic must multiply out like the original's.
discount_percent: item.discount_percent ?? 0,
line_total: -Math.abs(item.line_total),
vat_rate: item.vat_rate ?? 0,
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
@@ -23,6 +23,8 @@ const InvoiceChangesSchema = z
delivery_date: isoDate.nullable().optional(),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
// Fakturamärkning: buyer-required marking, separate from your_reference.
invoice_marking: z.string().max(200).optional(),
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required').optional(),
// Replaces the whole bag; {} clears all tags.
default_dimensions: DimensionsBagSchema.optional(),