diff --git a/DECISIONS.md b/DECISIONS.md
index 9f09c5f0..4df21adb 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1384,6 +1384,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and
[2026-08-31] Login/register methods come from GoTrue (/auth/v1/settings + admin customProviders) instead of app-side flags; NEXT_PUBLIC_GOOGLE_AUTH_ENABLED removed (PR #1869): the Supabase dashboard becomes the single switch, an allowlist of auth-js provider ids filters non-login entries like anonymous_users, and hosted rendering is unchanged because Google is enabled in prod GoTrue. The Vercel env var stays set for old-build rollback safety; delete it after a few deploys.
[2026-08-31] Single prominent amount is PROMOTED into editable totals.total (totalSource='prominent') instead of living in a read-only Belopp row: Emil's call, an uncorrectable load-bearing value violated the prefill-override-editors rule. Provenance keeps matching fallback-grade (discount, date guard, hunt exclusion); a user edit of TOTALT clears the stamp. Multi-amount docs keep the Belopp row: promoting one of several figures would invent a total.
[2026-08-31] Image-scan red fixed by bumping the node:22-alpine digest (alpine 3.23 to 3.24.1), not by widening the gate: the Dockerfile's apk-upgrade layer is frozen by the GHCR buildx layer cache, so a fix published after the last cache-busting change (libssl3 3.5.8-r0 for CVE-2026-14456) never reaches the published image until the FROM digest moves; the red scheduled scan is the designed alarm for exactly this bump. cron.Dockerfile gained the same apk upgrade (it had none).
+[2026-08-31] Per-line discount stores NET line_total via computeLineNet (roundOre(gross) minus roundOre(gross*d/100)) rather than round(gross*(1-d/100)): the subtracted-rounded-discount form keeps gross = discount + net exact in ore arithmetic, which the Peppol BG-27 line allowance (Amount + LineExtensionAmount = BaseAmount) and the PDF discount column both need; undiscounted lines keep the legacy unrounded qty*price so existing invoices and the Peppol LINE_TOTAL check stay byte-identical. ROT/RUT deducts on the discounted net (the customer pays that). invoice_marking is deliberately NOT copied by copy-invoice (recipient/PO-specific, same rule as your_reference) and NOT added to recurring schedules (follow-up if requested).
[2026-08-31] Own-company-as-supplier guard nulls the supplier block instead of flagging or substituting the issuer: an empty LEVERANTOR is always safe, a guessed issuer is not; BYO/agent-supplied extraction paths are deliberately exempt (explicit input, not a model misread).
[2026-08-31] gnubok-home-ok cache cookie is user-scoped (userId~host) instead of cleared on sign-out: sign-out happens client-side via supabase.auth.signOut so no server surface reliably sees it, while a value bound to the session's user makes any inherited verdict miss the cache by construction. Separator ~ because it is unreserved under encodeURIComponent AND a legal raw cookie octet, so the value round-trips identically whether or not the cookie layer percent-encodes. Old host-only cookies never match and self-heal; found via the amnas account-switch repro (two logins 9 s apart shared the verdict).
[2026-08-31] Bookkeeping digest email is per-user per-COMPANY per-day (not one aggregated mail across companies): notification_log.company_id anchors the claim, subject lines stay unambiguous, and most users have one company; consultants can opt in and get one short mail per client. Window is a fixed last-24h (cron cadence) rather than tracking last-sent state. Settings toggle stays hardcoded Swedish like the rest of the push-notifications extension UI (no next-intl wiring in extension components); revisit if that surface is ever translated.
diff --git a/app/api/invoices/[id]/convert/route.ts b/app/api/invoices/[id]/convert/route.ts
index 62cffbb9..d39b95a7 100644
--- a/app/api/invoices/[id]/convert/route.ts
+++ b/app/api/invoices/[id]/convert/route.ts
@@ -79,6 +79,8 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
reverse_charge_text: proforma.reverse_charge_text,
your_reference: proforma.your_reference,
our_reference: proforma.our_reference,
+ // Buyer routing survives conversion (Peppol BT-10 may rely on it alone).
+ invoice_marking: proforma.invoice_marking ?? null,
notes: proforma.notes,
document_type: 'invoice',
converted_from_id: id,
@@ -92,7 +94,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
return NextResponse.json({ error: getUserErrorMessage(invoiceError) }, { status: 500 })
}
- const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; dimensions?: Record }) => ({
+ const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; discount_percent?: number | null; line_total: number; dimensions?: Record }) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
@@ -100,6 +102,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
+ // The stored line_total is net of this; dropping it would make the
+ // converted invoice fail the Peppol line check and lose the rebate on
+ // the next builder pass.
+ discount_percent: item.discount_percent ?? 0,
line_total: item.line_total,
dimensions: item.dimensions ?? {},
}))
diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts
index 226be027..c2b2d688 100644
--- a/app/api/invoices/preview-pdf/route.ts
+++ b/app/api/invoices/preview-pdf/route.ts
@@ -9,6 +9,8 @@ import { contentDisposition } from '@/lib/api/content-disposition'
import type { InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { computeDeduction, computeInvoiceDeductionTotal, type DeductionType } from '@/lib/invoices/rot-rut-rules'
+import { computeLineNet } from '@/lib/invoices/line-amounts'
+import { roundOre } from '@/lib/money'
import { expandPersonnummerTo12, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
import { revealStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
import {
@@ -29,6 +31,8 @@ interface PreviewItemInput {
quantity: number
unit: string
unit_price: number
+ /** Line discount 0-100; amounts render net of it (line-amounts.ts). */
+ discount_percent?: number | null
vat_rate?: number
deduction_type?: DeductionType | null
labor_hours?: number | null
@@ -83,7 +87,8 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
}) => {
const body = await request.json()
const {
- customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes,
+ customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference,
+ invoice_marking, notes,
document_type, invoice_number, payment_link_url,
deduction_personnummer, deduction_housing_designation, deduction_apartment_number, deduction_brf_org_number,
} = body
@@ -227,15 +232,19 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
// deduction, mirroring build-invoice-write.ts so the preview states the
// same avdrag row, info box and "Att betala" as the invoice it becomes.
const invoiceItems: InvoiceItem[] = items.map((item: PreviewItemInput, index: number) => {
- const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100
+ // Net of any per-line discount, same math as build-invoice-write.ts, so
+ // the preview totals equal the invoice the form creates.
+ const discountPercent = item.discount_percent ?? 0
+ const lineTotal = roundOre(computeLineNet(item.quantity, item.unit_price, discountPercent))
const rate = zeroVat ? 0 : (item.vat_rate ?? vatRules.rate)
const deductionType = deductionsApply ? (item.deduction_type ?? null) : null
- // Same base as the write path: the line total inkl. moms at the rate the
- // line is rendered with (HUSFL 6-9 §§).
+ // Same base as the write path: the NET line total inkl. moms at the rate
+ // the line is rendered with (HUSFL 6-9 §§).
const deductionAmount = deductionType
? computeDeduction({
unit_price: item.unit_price,
quantity: item.quantity,
+ discount_percent: discountPercent,
deduction_type: deductionType,
vat_rate: rate,
})
@@ -248,6 +257,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
+ discount_percent: discountPercent,
line_total: lineTotal,
vat_rate: rate,
vat_amount: isDeliveryNote ? 0 : Math.round(lineTotal * (rate / 100) * 100) / 100,
@@ -275,6 +285,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
invoiceItems.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,
})),
@@ -314,6 +325,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
moms_ruta: vatRules.momsRuta,
your_reference: your_reference || null,
our_reference: our_reference || null,
+ invoice_marking: (typeof invoice_marking === 'string' && invoice_marking.trim()) || null,
notes: notes || null,
payment_link_url: previewPaymentLink,
reverse_charge_text: vatRules.reverseChargeText || null,
diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts
index 6b85954b..8e7984b2 100644
--- a/app/api/invoices/route.ts
+++ b/app/api/invoices/route.ts
@@ -392,6 +392,8 @@ async function createCreditNote(
reverse_charge_text: originalInvoice.reverse_charge_text,
your_reference: originalInvoice.your_reference,
our_reference: originalInvoice.our_reference,
+ // Same buyer routing on the kreditfaktura as the original.
+ invoice_marking: originalInvoice.invoice_marking ?? null,
// Positive magnitude, unlike the negated amounts above: the DB has
// CHECK (deduction_total >= 0), and every reader either recomputes the
// ROT/RUT amount from the items or skips credit notes entirely.
diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts
index 5ec47b11..500a9137 100644
--- a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts
+++ b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts
@@ -39,7 +39,7 @@ const CreditNoteRequest = z.object({
})
const ORIGINAL_INVOICE_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, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, default_dimensions'
+ '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, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, invoice_marking, notes, reverse_charge_text, credited_invoice_id, document_type, default_dimensions'
// default_dimensions stays in this projection: the inserted credit-note row is
// handed to createCreditNoteJournalEntry, which reads the bag off the row so
@@ -48,7 +48,7 @@ const CREDIT_NOTE_RESPONSE_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, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, paid_at, paid_amount, remaining_amount, default_dimensions, created_at, updated_at'
const ORIGINAL_ITEMS_COLUMNS =
- 'sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, dimensions'
+ 'sort_order, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, dimensions'
const CreditNoteCreated = z.object({
id: z.string().uuid(),
@@ -181,6 +181,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
quantity: number
unit: string
unit_price: number
+ discount_percent?: number | null
line_total: number
vat_rate?: number | null
vat_amount?: number | null
@@ -246,6 +247,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
reverse_charge_text: original.reverse_charge_text ?? null,
your_reference: original.your_reference ?? null,
our_reference: original.our_reference ?? null,
+ // Same buyer routing on the kreditfaktura as the original.
+ invoice_marking: original.invoice_marking ?? null,
notes: reason || `Krediterar faktura ${original.invoice_number ?? original.id}`,
credited_invoice_id: originalId,
status: 'sent' as const,
@@ -261,6 +264,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
quantity: -Math.abs(item.quantity),
unit: item.unit,
unit_price: item.unit_price,
+ // Carried so the kreditfaktura's face arithmetic multiplies out and the
+ // Rabatt column renders (ML 17 kap 24 §).
+ discount_percent: item.discount_percent ?? 0,
line_total: -Math.abs(item.line_total),
vat_rate: item.vat_rate ?? 0,
vat_amount: -Math.abs(item.vat_amount ?? 0),
diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx
index eae5fa17..a93197c2 100644
--- a/components/invoices/InvoiceEditor.tsx
+++ b/components/invoices/InvoiceEditor.tsx
@@ -37,8 +37,9 @@ import {
import { sortArticles } from '@/lib/articles/sort'
import ArticleCombobox from '@/components/invoices/ArticleCombobox'
import { getAmountToPay } from '@/lib/invoices/rounding'
+import { computeLineNet, hasLineDiscount } from '@/lib/invoices/line-amounts'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
-import { Loader2, X, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Package, Copy } from 'lucide-react'
+import { Loader2, X, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Package, Copy, Percent } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -259,6 +260,13 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
quantity: z.number(),
unit: z.string(),
unit_price: z.number(),
+ // Rabatt i procent per rad (⋮ menu). null = no discount.
+ discount_percent: z
+ .number()
+ .min(0, t('validation_discount_range'))
+ .max(100, t('validation_discount_range'))
+ .nullable()
+ .optional(),
vat_rate: z.number().min(0).max(25),
// Article linkage (artikelregister). Optional: free-text lines omit them.
article_id: z.string().nullable().optional(),
@@ -343,6 +351,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
document_type: z.enum(['invoice', 'proforma', 'delivery_note']),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
+ invoice_marking: z.string().optional(),
notes: z.string().optional(),
// Optional online payment link (pasted from e.g. the Stripe dashboard).
// https-only: mirrors the server-side CreateInvoiceSchema gate.
@@ -453,6 +462,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
[activeAccounts],
)
const [accountOverrideRows, setAccountOverrideRows] = useState>(new Set())
+ // Rabatt per rad: rows whose discount strip is open (⋮ menu), same
+ // lifecycle as the account override above. A stored discount also opens it.
+ const [discountRows, setDiscountRows] = useState>(new Set())
// Dimension tagging (kostnadsställe/projekt, dimensions PR7). Affordances
// render only when company_settings.dimensions_enabled: a UI-visibility
// gate; a draft that already carries bags still round-trips untouched when
@@ -529,6 +541,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
document_type: (initial.document_type ?? 'invoice') as InvoiceDocumentType,
your_reference: initial.your_reference ?? '',
our_reference: initial.our_reference ?? '',
+ invoice_marking: initial.invoice_marking ?? '',
notes: initial.notes ?? '',
payment_link_url: initial.payment_link_url ?? '',
payment_link_auto: initial.payment_link_auto ?? true,
@@ -543,6 +556,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
+ discount_percent: hasLineDiscount(item.discount_percent) ? item.discount_percent : null,
vat_rate: item.vat_rate ?? 25,
article_id: item.article_id ?? null,
revenue_account: item.revenue_account ?? null,
@@ -568,6 +582,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
document_type: 'invoice' as InvoiceDocumentType,
your_reference: '',
our_reference: copyInitial.our_reference,
+ invoice_marking: '',
notes: copyInitial.notes,
payment_link_url: '',
payment_link_auto: true,
@@ -635,6 +650,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const watchReceivedDate = watch('received_date')
const watchDeliveryDate = watch('delivery_date')
const watchYourReference = watch('your_reference')
+ const watchInvoiceMarking = watch('invoice_marking')
const watchPaymentLinkUrl = watch('payment_link_url')
const watchPaymentLinkAuto = watch('payment_link_auto')
const watchPersonnummer = watch('deduction_personnummer')
@@ -817,6 +833,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
quantity: 1,
unit: 'st',
unit_price: 0,
+ discount_percent: null,
vat_rate: vatRegistered ? vatRatePlan.defaultRate : 0,
article_id: null,
revenue_account: null,
@@ -871,6 +888,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
quantity: 0,
unit: '',
unit_price: 0,
+ discount_percent: null,
vat_rate: 0,
article_id: null,
revenue_account: null,
@@ -1101,7 +1119,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}
const subtotal = watchItems.reduce((sum, item) => {
- return sum + (item.quantity || 0) * (item.unit_price || 0)
+ return sum + computeLineNet(item.quantity || 0, item.unit_price || 0, item.discount_percent)
}, 0)
const vatRules = selectedCustomer
@@ -1134,7 +1152,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
let vatAmount = 0
for (const item of watchItems) {
const rate = vatRegistered ? (item.vat_rate ?? (vatRules?.rate || 25)) : 0
- const lineTotal = (item.quantity || 0) * (item.unit_price || 0)
+ const lineTotal = computeLineNet(item.quantity || 0, item.unit_price || 0, item.discount_percent)
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
vatAmount += lineVat
const existing = vatByRate.get(rate) || { base: 0, vat: 0 }
@@ -1226,6 +1244,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const amount = computeDeduction({
unit_price: item.unit_price || 0,
quantity: item.quantity || 0,
+ discount_percent: item.discount_percent,
deduction_type: item.deduction_type,
// Same rate resolution as the VAT totals loop above: the deduction
// base is the line total inkl. moms (HUSFL 6-9 §§).
@@ -1307,6 +1326,23 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}
}
+ // Open/close the per-line discount (⋮ menu). Closing clears the value so
+ // the row books at full price again.
+ function toggleDiscount(index: number) {
+ const isOpen = discountRows.has(index) || hasLineDiscount(watchItems[index]?.discount_percent)
+ if (isOpen) {
+ setValue(`items.${index}.discount_percent`, null, { shouldDirty: true, shouldValidate: true })
+ setDiscountRows((prev) => {
+ const next = new Set(prev)
+ next.delete(index)
+ return next
+ })
+ } else {
+ setDiscountRows((prev) => new Set(prev).add(index))
+ window.setTimeout(() => setFocus(`items.${index}.discount_percent`), 0)
+ }
+ }
+
// Open/close the optional per-item dimensions override (⋮ menu). Closing
// clears the bag so the row falls back to the invoice's default_dimensions.
function toggleItemDimensions(index: number) {
@@ -1754,6 +1790,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
items: data.items,
your_reference: data.your_reference,
our_reference: data.our_reference,
+ invoice_marking: data.invoice_marking,
notes: data.notes,
payment_link_url: data.payment_link_url,
invoice_number: numberPreview,
@@ -1882,6 +1919,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
receivedDate: watchReceivedDate || '',
deliveryDate: watchDeliveryDate || '',
yourReference: watchYourReference || '',
+ invoiceMarking: watchInvoiceMarking || '',
paymentLink: paymentLinkMode,
oreRounding,
dims: hasDimensionValues(defaultDims) ? compactDims(defaultDims) : null,
@@ -1904,6 +1942,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
return t('chip_delivery', { date: chip.date })
case 'your_reference':
return t('chip_your_reference', { reference: chip.reference })
+ case 'invoice_marking':
+ return t('chip_invoice_marking', { marking: chip.marking })
case 'payment_link':
return chip.mode === 'auto' ? t('chip_stripe_auto') : t('chip_payment_link')
case 'ore_off':
@@ -2156,7 +2196,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
)
}
- const lineTotal = (item?.quantity || 0) * (item?.unit_price || 0)
+ const lineTotal = computeLineNet(
+ item?.quantity || 0,
+ item?.unit_price || 0,
+ item?.discount_percent,
+ )
const rowErrors = errors.items?.[index]
const rowErrorMsg =
rowErrors?.description?.message ??
@@ -2166,6 +2210,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const articleStripOpen = articlePickerRows.has(index)
const accountStripOpen =
isInvoiceDoc && (accountOverrideRows.has(index) || Boolean(item?.revenue_account))
+ // Not offered for a received självfaktura: the self-billed
+ // endpoint's reduced item shape carries no discount, so a
+ // previewed rebate would silently book gross.
+ const discountStripOpen =
+ !isSelfBilled &&
+ (discountRows.has(index) || hasLineDiscount(item?.discount_percent))
const dimensionStripOpen =
dimensionsEnabled &&
isInvoiceDoc &&
@@ -2292,6 +2342,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
{t('row_menu_pick_article')}
+ {!isSelfBilled && (
+ <>
+
+ toggleDiscount(index)} className="py-2">
+
+ {discountStripOpen
+ ? t('row_menu_remove_discount')
+ : t('row_menu_add_discount')}
+
+ >
+ )}
{isInvoiceDoc && (
<>
@@ -2422,6 +2483,59 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
)}
+ {/* Rabatt strip: opened via the ⋮ menu; a stored
+ discount keeps it open in edit mode. */}
+ {discountStripOpen && (
+
+
+
+
+ {
+ if (v === '' || v == null) return null
+ const n = Number(v)
+ return Number.isFinite(n) ? n : null
+ },
+ })}
+ />
+ %
+
+ {hasLineDiscount(item?.discount_percent) && (
+
+ −{formatCurrency(
+ roundOre((item?.quantity || 0) * (item?.unit_price || 0)) - lineTotal,
+ watchCurrency,
+ )}
+
+ )}
+
+ {rowErrors?.discount_percent && (
+
+ {rowErrors.discount_percent.message}
+
+ )}
+
+ )}
+
{/* ROT/RUT-avdrag strip: only when a deduction is
active on this row (chosen via the ⋮ menu). */}
{isInvoiceDoc && item?.deduction_type && (
@@ -2482,6 +2596,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const amt = computeDeduction({
unit_price: item?.unit_price || 0,
quantity: item?.quantity || 0,
+ discount_percent: item?.discount_percent,
deduction_type: item?.deduction_type,
vat_rate: vatRegistered
? (item?.vat_rate ?? (vatRules?.rate || 25))
@@ -2924,6 +3039,23 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
/>
+ {/* Fakturamärkning: one buyer-required marking string
+ (kostnadsställe/projekt/PO), separate from Er
+ referens. Plain input, never comma-split. */}
+
+
+
+
+
+
{/* Online payment link: manual paste or the Stripe auto
toggle. Only real invoices; hidden unless the company
@@ -3212,6 +3344,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
total={total}
yourReference={pendingData?.your_reference}
ourReference={pendingData?.our_reference}
+ invoiceMarking={pendingData?.invoice_marking}
notes={pendingData?.notes}
numberPreview={numberPreview}
oreRounding={oreRounding}
diff --git a/components/invoices/InvoiceReviewContent.tsx b/components/invoices/InvoiceReviewContent.tsx
index 27cc9d4e..dbe02972 100644
--- a/components/invoices/InvoiceReviewContent.tsx
+++ b/components/invoices/InvoiceReviewContent.tsx
@@ -7,6 +7,7 @@ import { Separator } from '@/components/ui/separator'
import { formatCurrency, formatDate } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { isTextLikeLine } from '@/lib/invoices/display'
+import { computeLineNet } from '@/lib/invoices/line-amounts'
import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import type { Customer, Currency } from '@/types'
@@ -15,6 +16,8 @@ interface ReviewItem {
quantity: number
unit: string
unit_price: number
+ /** Line discount 0-100; amounts render net of it. */
+ discount_percent?: number | null
vat_rate?: number
/** 'text' rows are free-text/blank lines: description only, no amounts. */
line_type?: 'product' | 'text'
@@ -38,6 +41,7 @@ interface InvoiceReviewContentProps {
total: number
yourReference?: string
ourReference?: string
+ invoiceMarking?: string
notes?: string
/** The invoice number that will be assigned on confirm. Null when unknown
* (e.g. delivery notes use a different sequence) or unfetched. */
@@ -63,6 +67,7 @@ export function InvoiceReviewContent({
total,
yourReference,
ourReference,
+ invoiceMarking,
notes,
numberPreview,
oreRounding,
@@ -84,7 +89,7 @@ export function InvoiceReviewContent({
for (const item of items) {
if (isTextLikeLine(item)) continue
const rate = item.vat_rate ?? 0
- const lineTotal = item.quantity * item.unit_price
+ const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent)
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
}
@@ -182,12 +187,19 @@ export function InvoiceReviewContent({
{item.quantity} |
{item.unit} |
- {formatCurrency(item.unit_price, currency)} |
+
+ {formatCurrency(item.unit_price, currency)}
+ {(item.discount_percent ?? 0) > 0 && (
+
+ −{item.discount_percent}%
+
+ )}
+ |
{showVatColumn && (
{item.vat_rate ?? 0}% |
)}
- {formatCurrency(item.quantity * item.unit_price, currency)}
+ {formatCurrency(computeLineNet(item.quantity, item.unit_price, item.discount_percent), currency)}
|
)
@@ -214,11 +226,14 @@ export function InvoiceReviewContent({
)}
- {item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}
+
+ {item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}
+ {(item.discount_percent ?? 0) > 0 && <> −{item.discount_percent}%>}
+
{showVatColumn && {t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}}
- {formatCurrency(item.quantity * item.unit_price, currency)}
+ {formatCurrency(computeLineNet(item.quantity, item.unit_price, item.discount_percent), currency)}
)
@@ -260,7 +275,7 @@ export function InvoiceReviewContent({
{/* References/notes */}
- {(yourReference || ourReference || notes) && (
+ {(yourReference || ourReference || invoiceMarking || notes) && (
{yourReference && (
@@ -272,6 +287,11 @@ export function InvoiceReviewContent({
{t('our_reference')} {ourReference}
)}
+ {invoiceMarking && (
+
+ {t('invoice_marking')} {invoiceMarking}
+
+ )}
{notes &&
{t('notes_prefix', { notes })}
}
)}
diff --git a/components/invoices/__tests__/invoice-editor-flow.test.ts b/components/invoices/__tests__/invoice-editor-flow.test.ts
index 06b0e928..2ffe61c2 100644
--- a/components/invoices/__tests__/invoice-editor-flow.test.ts
+++ b/components/invoices/__tests__/invoice-editor-flow.test.ts
@@ -165,6 +165,7 @@ function chipsInput(overrides: Partial = {}): ForvalChipsInput
receivedDate: '',
deliveryDate: '',
yourReference: '',
+ invoiceMarking: '',
paymentLink: null,
oreRounding: true,
dims: null,
@@ -221,6 +222,7 @@ describe('deriveForvalChips', () => {
currency: 'EUR',
deliveryDate: '2026-08-20',
yourReference: 'Anna',
+ invoiceMarking: 'KST 4711',
paymentLink: 'manual',
oreRounding: false,
dims: 'KS01 · P001',
@@ -230,6 +232,7 @@ describe('deriveForvalChips', () => {
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.
@@ -249,6 +252,7 @@ describe('deriveForvalChips', () => {
receivedDate: '2026-08-15',
documentType: 'proforma',
yourReference: 'x',
+ invoiceMarking: 'x',
paymentLink: 'auto',
oreRounding: false,
dims: 'KS01',
diff --git a/components/invoices/invoice-editor-flow.ts b/components/invoices/invoice-editor-flow.ts
index 40c50cd3..02a00576 100644
--- a/components/invoices/invoice-editor-flow.ts
+++ b/components/invoices/invoice-editor-flow.ts
@@ -121,6 +121,7 @@ export type ForvalChip =
| { 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 }
@@ -134,6 +135,7 @@ export interface ForvalChipsInput {
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. */
@@ -174,6 +176,9 @@ export function deriveForvalChips(input: ForvalChipsInput): ForvalChip[] {
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 })
}
diff --git a/extensions/general/mcp-server/__tests__/get-invoice.test.ts b/extensions/general/mcp-server/__tests__/get-invoice.test.ts
index bd5e7974..a8da6827 100644
--- a/extensions/general/mcp-server/__tests__/get-invoice.test.ts
+++ b/extensions/general/mcp-server/__tests__/get-invoice.test.ts
@@ -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,
diff --git a/extensions/general/mcp-server/__tests__/update-invoice.test.ts b/extensions/general/mcp-server/__tests__/update-invoice.test.ts
index 1a364681..e0e39a7c 100644
--- a/extensions/general/mcp-server/__tests__/update-invoice.test.ts
+++ b/extensions/general/mcp-server/__tests__/update-invoice.test.ts
@@ -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,
diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts
index d01b2cef..ada2e5d5 100644
--- a/extensions/general/mcp-server/server.ts
+++ b/extensions/general/mcp-server/server.ts
@@ -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 | 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 = {}
- 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,
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index 10031c75..1e925998 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -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
diff --git a/lib/api/v1/invoice-columns.ts b/lib/api/v1/invoice-columns.ts
index f884b01a..1f05863f 100644
--- a/lib/api/v1/invoice-columns.ts
+++ b/lib/api/v1/invoice-columns.ts
@@ -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'
diff --git a/lib/bookkeeping/__tests__/invoice-entries.test.ts b/lib/bookkeeping/__tests__/invoice-entries.test.ts
index da85fc1b..43e4c362 100644
--- a/lib/bookkeeping/__tests__/invoice-entries.test.ts
+++ b/lib/bookkeeping/__tests__/invoice-entries.test.ts
@@ -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.
diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts
index 57a8441d..026d7645 100644
--- a/lib/bookkeeping/invoice-entries.ts
+++ b/lib/bookkeeping/invoice-entries.ts
@@ -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,
})
diff --git a/lib/bookkeeping/propose-send-lines.ts b/lib/bookkeeping/propose-send-lines.ts
index 10c9ce58..073a8090 100644
--- a/lib/bookkeeping/propose-send-lines.ts
+++ b/lib/bookkeeping/propose-send-lines.ts
@@ -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,
})
diff --git a/lib/invoices/__tests__/build-credit-note-item.test.ts b/lib/invoices/__tests__/build-credit-note-item.test.ts
index dadeba1a..b2e6357d 100644
--- a/lib/invoices/__tests__/build-credit-note-item.test.ts
+++ b/lib/invoices/__tests__/build-credit-note-item.test.ts
@@ -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)
+ })
})
diff --git a/lib/invoices/__tests__/line-amounts.test.ts b/lib/invoices/__tests__/line-amounts.test.ts
new file mode 100644
index 00000000..3d4b860f
--- /dev/null
+++ b/lib/invoices/__tests__/line-amounts.test.ts
@@ -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)
+ })
+})
diff --git a/lib/invoices/__tests__/peppol-bis-billing.test.ts b/lib/invoices/__tests__/peppol-bis-billing.test.ts
index f28016b3..7ab1afcb 100644
--- a/lib/invoices/__tests__/peppol-bis-billing.test.ts
+++ b/lib/invoices/__tests__/peppol-bis-billing.test.ts
@@ -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('KST 4711')
+ })
+
+ 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('PO-2026-17')
+ })
+
+ 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('180.00')
+ expect(result.xml).toContain('false')
+ expect(result.xml).toContain('95')
+ expect(result.xml).toContain('10')
+ expect(result.xml).toContain('20.00')
+ expect(result.xml).toContain('200.00')
+ // The undiscounted unit price stays in cac:Price (BT-146).
+ expect(result.xml).toContain('100')
+ })
+
+ 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({
diff --git a/lib/invoices/__tests__/replace-invoice-items.test.ts b/lib/invoices/__tests__/replace-invoice-items.test.ts
index 98933e0d..1c3788c5 100644
--- a/lib/invoices/__tests__/replace-invoice-items.test.ts
+++ b/lib/invoices/__tests__/replace-invoice-items.test.ts
@@ -19,6 +19,7 @@ function makeItem(overrides: Partial = {}): InvoiceWriteIte
quantity: 1,
unit: 'tim',
unit_price: 1000,
+ discount_percent: 0,
line_total: 1000,
vat_rate: 25,
vat_amount: 250,
diff --git a/lib/invoices/build-credit-note-item.ts b/lib/invoices/build-credit-note-item.ts
index 5888c76b..0f76c1e9 100644
--- a/lib/invoices/build-credit-note-item.ts
+++ b/lib/invoices/build-credit-note-item.ts
@@ -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),
diff --git a/lib/invoices/build-invoice-write.ts b/lib/invoices/build-invoice-write.ts
index 1753ea68..247deb49 100644
--- a/lib/invoices/build-invoice-write.ts
+++ b/lib/invoices/build-invoice-write.ts
@@ -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,
diff --git a/lib/invoices/copy-invoice.ts b/lib/invoices/copy-invoice.ts
index 4b972d0e..244ac49a 100644
--- a/lib/invoices/copy-invoice.ts
+++ b/lib/invoices/copy-invoice.ts
@@ -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.
diff --git a/lib/invoices/line-amounts.ts b/lib/invoices/line-amounts.ts
new file mode 100644
index 00000000..ab343ff4
--- /dev/null
+++ b/lib/invoices/line-amounts.ts
@@ -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
+}
diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx
index 4b18d534..479421b3 100644
--- a/lib/invoices/pdf-template.tsx
+++ b/lib/invoices/pdf-template.tsx
@@ -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()
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
)}
+ {/* Fakturamärkning: one buyer-required marking string, never
+ comma-split (a PO/cost-center label may contain commas). */}
+ {invoice.invoice_marking && (
+
+ {L.invoiceMarking}
+
+
+ {invoice.invoice_marking.trim()}
+
+
+
+ )}
{/* Customer */}
@@ -1003,6 +1027,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{!isDeliveryNote && (
{L.colUnitPrice}
)}
+ {!isDeliveryNote && showDiscountColumn && (
+ {L.colDiscount}
+ )}
{!isDeliveryNote && showVatColumn && (
{L.colVat}
)}
@@ -1029,6 +1056,11 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{!isDeliveryNote && (
{formatPdfCurrency(item.unit_price, invoice.currency, lang)}
)}
+ {!isDeliveryNote && showDiscountColumn && (
+
+ {(item.discount_percent ?? 0) > 0 ? `${item.discount_percent}%` : ''}
+
+ )}
{!isDeliveryNote && showVatColumn && (
{item.vat_rate ?? 0}%
)}
diff --git a/lib/invoices/peppol-bis-billing.ts b/lib/invoices/peppol-bis-billing.ts
index 14e678f9..37423c77 100644
--- a/lib/invoices/peppol-bis-billing.ts
+++ b/lib/invoices/peppol-bis-billing.ts
@@ -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):
' ',
' ',
])
- 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)
+ ? [
+ ' ',
+ ' false',
+ ' 95',
+ ' Rabatt',
+ ` ${formatDecimal(item.discount_percent as number)}`,
+ ` ${formatMoney(amounts.discount)}`,
+ ` ${formatMoney(amounts.gross)}`,
+ ' ',
+ ]
+ : []
+ return [
' ',
` ${index + 1}`,
` ${formatDecimal(item.quantity)}`,
` ${formatMoney(item.line_total)}`,
+ ...allowance,
' ',
` ${escapeXml(item.description.trim())}`,
' ',
@@ -569,7 +601,8 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice):
` ${formatDecimal(item.unit_price)}`,
' ',
' ',
- ])
+ ]
+ })
return [
'',
@@ -584,7 +617,7 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice):
' 380',
invoice.notes ? ` ${escapeXml(invoice.notes)}` : null,
' SEK',
- ` ${escapeXml(invoice.your_reference?.trim() ?? '')}`,
+ ` ${escapeXml((invoice.invoice_marking?.trim() || invoice.your_reference?.trim()) ?? '')}`,
renderParty('AccountingSupplierParty', prepared.supplier, company.f_skatt),
renderParty('AccountingCustomerParty', prepared.buyer, false),
invoice.delivery_date
diff --git a/lib/invoices/rot-rut-rules.ts b/lib/invoices/rot-rut-rules.ts
index 01ca8763..95384ff5 100644
--- a/lib/invoices/rot-rut-rules.ts
+++ b/lib/invoices/rot-rut-rules.ts
@@ -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
diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts
index 59258b59..d6775fdb 100644
--- a/lib/pending-operations/commit.ts
+++ b/lib/pending-operations/commit.ts
@@ -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
@@ -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),
diff --git a/lib/pending-operations/schemas/update-invoice.ts b/lib/pending-operations/schemas/update-invoice.ts
index 61c1e461..4bac1210 100644
--- a/lib/pending-operations/schemas/update-invoice.ts
+++ b/lib/pending-operations/schemas/update-invoice.ts
@@ -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(),
diff --git a/messages/en.json b/messages/en.json
index 878896ce..2b033409 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3813,6 +3813,7 @@
"chip_received": "Received {date}",
"chip_delivery": "Delivery date {date}",
"chip_your_reference": "Your reference: {reference}",
+ "chip_invoice_marking": "Marking: {marking}",
"chip_stripe_auto": "Stripe link created on send",
"chip_payment_link": "Payment link added",
"chip_ore_off": "No öre rounding",
@@ -3831,6 +3832,10 @@
"ready_create": "Ready to review: everything else has sensible defaults.",
"ready_edit": "Ready to save.",
"ready_self_billed": "Ready to register.",
+ "row_menu_add_discount": "Add discount",
+ "row_menu_remove_discount": "Remove discount",
+ "discount_label": "Discount",
+ "validation_discount_range": "The discount must be between 0 and 100 percent",
"row_menu_set_account": "Set posting account",
"row_menu_remove_account": "Remove posting account",
"row_menu_set_dimensions": "Set cost centre/project",
@@ -3899,6 +3904,8 @@
"your_reference_placeholder": "Customer contact person",
"our_reference_label": "Our reference",
"our_reference_placeholder": "Your name",
+ "invoice_marking_label": "Marking",
+ "invoice_marking_placeholder": "E.g. cost center or order no.",
"payment_link_label": "Payment link (optional)",
"payment_link_placeholder": "https://buy.stripe.com/…",
"payment_link_hint": "Paste a payment link created for this specific invoice (e.g. in Stripe). Make sure the amount matches and preferably limit the link to a single payment. The link is shown as a button in the invoice email and as a QR code on the PDF.",
@@ -4015,6 +4022,7 @@
"total": "Total",
"your_reference": "Your reference:",
"our_reference": "Our reference:",
+ "invoice_marking": "Marking:",
"notes_prefix": "Note: {notes}"
},
"invoice_bank_setup": {
diff --git a/messages/sv.json b/messages/sv.json
index 5c09e918..588ac569 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -3813,6 +3813,7 @@
"chip_received": "Mottagen {date}",
"chip_delivery": "Leveransdatum {date}",
"chip_your_reference": "Er referens: {reference}",
+ "chip_invoice_marking": "Märkning: {marking}",
"chip_stripe_auto": "Stripe-länk skapas vid utskick",
"chip_payment_link": "Betalningslänk inlagd",
"chip_ore_off": "Ingen öresavrundning",
@@ -3831,6 +3832,10 @@
"ready_create": "Klar att granska: allt annat har smarta förval.",
"ready_edit": "Klart att spara.",
"ready_self_billed": "Klar att registrera.",
+ "row_menu_add_discount": "Lägg till rabatt",
+ "row_menu_remove_discount": "Ta bort rabatt",
+ "discount_label": "Rabatt",
+ "validation_discount_range": "Rabatten måste vara mellan 0 och 100 procent",
"row_menu_set_account": "Ange bokföringskonto",
"row_menu_remove_account": "Ta bort bokföringskonto",
"row_menu_set_dimensions": "Ange kostnadsställe/projekt",
@@ -3899,6 +3904,8 @@
"your_reference_placeholder": "Kontaktperson hos kund",
"our_reference_label": "Vår referens",
"our_reference_placeholder": "Ditt namn",
+ "invoice_marking_label": "Märkning",
+ "invoice_marking_placeholder": "T.ex. kostnadsställe eller ordernr",
"payment_link_label": "Betalningslänk (valfritt)",
"payment_link_placeholder": "https://buy.stripe.com/…",
"payment_link_hint": "Klistra in en betalningslänk skapad för just denna faktura (t.ex. i Stripe). Kontrollera att beloppet stämmer och begränsa gärna länken till en betalning. Länken visas som en knapp i fakturamejlet och som QR-kod på PDF:en.",
@@ -4015,6 +4022,7 @@
"total": "Totalt",
"your_reference": "Er referens:",
"our_reference": "Vår referens:",
+ "invoice_marking": "Märkning:",
"notes_prefix": "Anteckning: {notes}"
},
"invoice_bank_setup": {
diff --git a/skills/accounted-api/references/invoices.md b/skills/accounted-api/references/invoices.md
index 897ba401..c1e1a604 100644
--- a/skills/accounted-api/references/invoices.md
+++ b/skills/accounted-api/references/invoices.md
@@ -110,6 +110,7 @@ Request body:
document_type?: "invoice" | "proforma" | "delivery_note",
your_reference?: string,
our_reference?: string,
+ invoice_marking?: string,
notes?: string,
payment_link_url?: string | "",
payment_link_auto?: boolean,
@@ -124,7 +125,7 @@ Request body:
external_invoice_number?: string | "",
self_billing_agreement_ref?: string,
received_date?: string | "",
- items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[]
+ items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, discount_percent?: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[]
}
```
@@ -305,7 +306,7 @@ Request body:
our_reference?: string | unknown,
notes?: string | unknown,
default_dimensions?: Record,
- items?: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[]
+ items?: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, discount_percent?: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[]
}
```
@@ -776,7 +777,7 @@ Bulk-creation endpoint. Each invoice in the request array is validated and inser
Request body:
```ts
{
- invoices: { customer_id: string, invoice_date: string, due_date: string, delivery_date?: string | "", currency: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", document_type?: "invoice" | "proforma" | "delivery_note", your_reference?: string, our_reference?: string, notes?: string, payment_link_url?: string | "", payment_link_auto?: boolean, deduction_personnummer?: string, deduction_housing_designation?: string, deduction_apartment_number?: string, deduction_brf_org_number?: string | "", save_as_draft?: boolean, ore_rounding?: boolean, default_dimensions?: Record, is_self_billed?: boolean, external_invoice_number?: string | "", self_billing_agreement_ref?: string, received_date?: string | "", items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[] }[],
+ invoices: { customer_id: string, invoice_date: string, due_date: string, delivery_date?: string | "", currency: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", document_type?: "invoice" | "proforma" | "delivery_note", your_reference?: string, our_reference?: string, invoice_marking?: string, notes?: string, payment_link_url?: string | "", payment_link_auto?: boolean, deduction_personnummer?: string, deduction_housing_designation?: string, deduction_apartment_number?: string, deduction_brf_org_number?: string | "", save_as_draft?: boolean, ore_rounding?: boolean, default_dimensions?: Record, is_self_billed?: boolean, external_invoice_number?: string | "", self_billing_agreement_ref?: string, received_date?: string | "", items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, discount_percent?: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[] }[],
all_or_nothing?: boolean
}
```
diff --git a/supabase/migrations/20260831120000_invoice_line_discount_and_marking.sql b/supabase/migrations/20260831120000_invoice_line_discount_and_marking.sql
new file mode 100644
index 00000000..cfa54299
--- /dev/null
+++ b/supabase/migrations/20260831120000_invoice_line_discount_and_marking.sql
@@ -0,0 +1,30 @@
+-- Per-line percentage discount (rabatt i procent per artikelrad) and a
+-- fakturamärkning field separate from Er referens (your_reference).
+--
+-- invoice_items.discount_percent: 0-100, default 0. The stored line_total is
+-- always the NET amount (after discount); VAT is computed on the net, so the
+-- bookkeeping generators need no change. Server code recomputes the discount
+-- via lib/invoices/line-amounts.ts and never trusts a client-sent total.
+--
+-- invoices.invoice_marking: the buyer-required marking (kostnadsstalle,
+-- project code, PO label) printed on the invoice and mapped to Peppol BT-10
+-- BuyerReference when set. Distinct from your_reference, which stays the
+-- contact person (Er referens).
+
+ALTER TABLE public.invoice_items
+ ADD COLUMN IF NOT EXISTS discount_percent NUMERIC NOT NULL DEFAULT 0;
+
+ALTER TABLE public.invoice_items DROP CONSTRAINT IF EXISTS invoice_items_discount_percent_check;
+ALTER TABLE public.invoice_items ADD CONSTRAINT invoice_items_discount_percent_check
+ CHECK (discount_percent >= 0 AND discount_percent <= 100);
+
+COMMENT ON COLUMN public.invoice_items.discount_percent IS
+ 'Percentage discount on the line (0-100). line_total and vat_amount are stored net of this discount.';
+
+ALTER TABLE public.invoices
+ ADD COLUMN IF NOT EXISTS invoice_marking TEXT;
+
+COMMENT ON COLUMN public.invoices.invoice_marking IS
+ 'Fakturamarkning: buyer-required marking (cost center/project/PO), separate from your_reference (Er referens). Feeds Peppol BT-10 BuyerReference when set.';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/types/index.ts b/types/index.ts
index 226b1490..10dc51f4 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -1267,6 +1267,11 @@ export interface Invoice {
// Reference
your_reference: string | null
our_reference: string | null
+ // Fakturamärkning: buyer-required marking (cost center, project, PO label),
+ // separate from your_reference (Er referens = contact person). Printed on
+ // the PDF and mapped to Peppol BT-10 BuyerReference when set. Optional in
+ // TS for pre-migration fixtures.
+ invoice_marking?: string | null
// Optional online payment link (pasted by the user, e.g. a Stripe Payment
// Link). Rendered as a "Betala online" button in the invoice email and as a
@@ -1431,7 +1436,12 @@ export interface InvoiceItem {
// Price
unit_price: number
- // Calculated
+ // Percentage discount on the line (0-100). line_total and vat_amount are
+ // stored NET of this discount (lib/invoices/line-amounts.ts). Optional in
+ // TS for pre-migration fixtures; treat undefined the same as 0.
+ discount_percent?: number
+
+ // Calculated (net of discount_percent)
line_total: number
// Per-line VAT
@@ -1708,6 +1718,8 @@ export interface CreateInvoiceInput {
document_type?: InvoiceDocumentType
your_reference?: string
our_reference?: string
+ /** Fakturamärkning: buyer-required marking, separate from your_reference. */
+ invoice_marking?: string
notes?: string
/** Optional https link where the customer can pay online (e.g. a Stripe Payment Link). */
payment_link_url?: string
@@ -1731,6 +1743,8 @@ export interface CreateInvoiceItemInput {
quantity: number
unit: string
unit_price: number
+ /** Percentage discount on the line (0-100). Omitted/null = 0. */
+ discount_percent?: number | null
vat_rate?: number
/** Source article (optional). Free-text lines omit it. */
article_id?: string | null