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

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

User request: rabatt i procent per artikelrad, and a marking field
separate from Er referens.

- invoice_items.discount_percent (0-100, default 0): line_total and
  vat_amount are stored NET of the discount. Shared exact-ore math in
  lib/invoices/line-amounts.ts (gross, discount, net) used by the web
  builder, staged-operation commit, editor preview, PDF, and Peppol.
  Undiscounted lines keep the legacy unrounded qty*price byte-identical.
- ROT/RUT deduction computes on the discounted net line total.
- invoices.invoice_marking: printed on the PDF next to the references
  and mapped to Peppol BT-10 BuyerReference (marking wins over
  your_reference; either satisfies the BT-10 requirement).
- Peppol renders the discount as a BG-27 line AllowanceCharge
  (reason code 95, MultiplierFactorNumeric, Amount, BaseAmount).
- Editor: "Lagg till rabatt" in the row menu (same reveal pattern as
  ROT/RUT), Markning row next to Er referens, forval chip, review
  dialog shows discounts and marking.
- Plumbed through v1 REST projections, MCP create/get/update invoice
  tools, pending-operations update path, and copy-invoice (discount
  copied; marking deliberately not, it is recipient-specific).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52

* fix(invoices): carry discount_percent through every deduction, credit, convert and preview path

Skeptic + CI findings on the discount/marking feature, one pass:

- generateRotRutLines and propose-send-lines now pass discount_percent
  into computeDeduction: the send/credit/cash verifikat booked 1513 on
  the GROSS line while deduction_total, the PDF and the Skatteverket
  claim carried the net, stranding the difference on 1513 and pushing
  1510 negative once the customer paid. Test pins 1513=3000/1510=7000
  for a 20%-discounted 10 000 kr ROT line.
- preview-pdf route accepts discount_percent (net totals + net-based
  deduction) and invoice_marking; the editor now sends the marking, so
  the preview equals the invoice it becomes.
- Credit notes carry discount_percent (buildCreditNoteItem, v1 credit
  route select+insert, MCP credit executor) and invoice_marking, so the
  kreditfaktura face arithmetic multiplies out and shows the Rabatt
  column (ML 17 kap 24 §).
- Proforma->invoice convert copies discount_percent + invoice_marking:
  the converted invoice previously failed Peppol LINE_TOTAL_MISMATCH
  and lost the rebate on the next builder pass.
- Editor hides the discount menu in self-billed mode (the self-billed
  wire shape has no discount; previewed net would book gross).
- MCP staging and commitCreateInvoice reject a non-number
  discount_percent (a string coerced past the range check but was
  ignored by the totals math and still stored).
- Regenerated skills/accounted-api (apiskill:check CI failure).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52

---------

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