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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-31 15:34:06 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 84c8e1ce59
commit f216a60bf8
35 changed files with 826 additions and 51 deletions
+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] 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] 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] 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] 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] 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. [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, reverse_charge_text: proforma.reverse_charge_text,
your_reference: proforma.your_reference, your_reference: proforma.your_reference,
our_reference: proforma.our_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, notes: proforma.notes,
document_type: 'invoice', document_type: 'invoice',
converted_from_id: id, converted_from_id: id,
@@ -92,7 +94,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
return NextResponse.json({ error: getUserErrorMessage(invoiceError) }, { status: 500 }) 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, invoice_id: invoice.id,
sort_order: item.sort_order, sort_order: item.sort_order,
line_type: item.line_type ?? 'product', line_type: item.line_type ?? 'product',
@@ -100,6 +102,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
quantity: item.quantity, quantity: item.quantity,
unit: item.unit, unit: item.unit,
unit_price: item.unit_price, 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, line_total: item.line_total,
dimensions: item.dimensions ?? {}, 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 type { InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { computeDeduction, computeInvoiceDeductionTotal, type DeductionType } from '@/lib/invoices/rot-rut-rules' 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 { expandPersonnummerTo12, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
import { revealStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number' import { revealStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
import { import {
@@ -29,6 +31,8 @@ interface PreviewItemInput {
quantity: number quantity: number
unit: string unit: string
unit_price: number unit_price: number
/** Line discount 0-100; amounts render net of it (line-amounts.ts). */
discount_percent?: number | null
vat_rate?: number vat_rate?: number
deduction_type?: DeductionType | null deduction_type?: DeductionType | null
labor_hours?: number | null labor_hours?: number | null
@@ -83,7 +87,8 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
}) => { }) => {
const body = await request.json() const body = await request.json()
const { 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, document_type, invoice_number, payment_link_url,
deduction_personnummer, deduction_housing_designation, deduction_apartment_number, deduction_brf_org_number, deduction_personnummer, deduction_housing_designation, deduction_apartment_number, deduction_brf_org_number,
} = body } = 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 // deduction, mirroring build-invoice-write.ts so the preview states the
// same avdrag row, info box and "Att betala" as the invoice it becomes. // same avdrag row, info box and "Att betala" as the invoice it becomes.
const invoiceItems: InvoiceItem[] = items.map((item: PreviewItemInput, index: number) => { 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 rate = zeroVat ? 0 : (item.vat_rate ?? vatRules.rate)
const deductionType = deductionsApply ? (item.deduction_type ?? null) : null const deductionType = deductionsApply ? (item.deduction_type ?? null) : null
// Same base as the write path: the line total inkl. moms at the rate the // Same base as the write path: the NET line total inkl. moms at the rate
// line is rendered with (HUSFL 6-9 §§). // the line is rendered with (HUSFL 6-9 §§).
const deductionAmount = deductionType const deductionAmount = deductionType
? computeDeduction({ ? computeDeduction({
unit_price: item.unit_price, unit_price: item.unit_price,
quantity: item.quantity, quantity: item.quantity,
discount_percent: discountPercent,
deduction_type: deductionType, deduction_type: deductionType,
vat_rate: rate, vat_rate: rate,
}) })
@@ -248,6 +257,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
quantity: item.quantity, quantity: item.quantity,
unit: item.unit, unit: item.unit,
unit_price: item.unit_price, unit_price: item.unit_price,
discount_percent: discountPercent,
line_total: lineTotal, line_total: lineTotal,
vat_rate: rate, vat_rate: rate,
vat_amount: isDeliveryNote ? 0 : Math.round(lineTotal * (rate / 100) * 100) / 100, 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) => ({ invoiceItems.map((item) => ({
unit_price: item.unit_price, unit_price: item.unit_price,
quantity: item.quantity, quantity: item.quantity,
discount_percent: item.discount_percent ?? 0,
deduction_type: item.deduction_type ?? null, deduction_type: item.deduction_type ?? null,
vat_rate: item.vat_rate, vat_rate: item.vat_rate,
})), })),
@@ -314,6 +325,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
moms_ruta: vatRules.momsRuta, moms_ruta: vatRules.momsRuta,
your_reference: your_reference || null, your_reference: your_reference || null,
our_reference: our_reference || null, our_reference: our_reference || null,
invoice_marking: (typeof invoice_marking === 'string' && invoice_marking.trim()) || null,
notes: notes || null, notes: notes || null,
payment_link_url: previewPaymentLink, payment_link_url: previewPaymentLink,
reverse_charge_text: vatRules.reverseChargeText || null, reverse_charge_text: vatRules.reverseChargeText || null,
+2
View File
@@ -392,6 +392,8 @@ async function createCreditNote(
reverse_charge_text: originalInvoice.reverse_charge_text, reverse_charge_text: originalInvoice.reverse_charge_text,
your_reference: originalInvoice.your_reference, your_reference: originalInvoice.your_reference,
our_reference: originalInvoice.our_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 // Positive magnitude, unlike the negated amounts above: the DB has
// CHECK (deduction_total >= 0), and every reader either recomputes the // CHECK (deduction_total >= 0), and every reader either recomputes the
// ROT/RUT amount from the items or skips credit notes entirely. // ROT/RUT amount from the items or skips credit notes entirely.
@@ -39,7 +39,7 @@ const CreditNoteRequest = z.object({
}) })
const ORIGINAL_INVOICE_COLUMNS = 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 // default_dimensions stays in this projection: the inserted credit-note row is
// handed to createCreditNoteJournalEntry, which reads the bag off the row so // 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' '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 = 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({ const CreditNoteCreated = z.object({
id: z.string().uuid(), id: z.string().uuid(),
@@ -181,6 +181,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
quantity: number quantity: number
unit: string unit: string
unit_price: number unit_price: number
discount_percent?: number | null
line_total: number line_total: number
vat_rate?: number | null vat_rate?: number | null
vat_amount?: 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, reverse_charge_text: original.reverse_charge_text ?? null,
your_reference: original.your_reference ?? null, your_reference: original.your_reference ?? null,
our_reference: original.our_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}`, notes: reason || `Krediterar faktura ${original.invoice_number ?? original.id}`,
credited_invoice_id: originalId, credited_invoice_id: originalId,
status: 'sent' as const, status: 'sent' as const,
@@ -261,6 +264,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
quantity: -Math.abs(item.quantity), quantity: -Math.abs(item.quantity),
unit: item.unit, unit: item.unit,
unit_price: item.unit_price, 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), line_total: -Math.abs(item.line_total),
vat_rate: item.vat_rate ?? 0, vat_rate: item.vat_rate ?? 0,
vat_amount: -Math.abs(item.vat_amount ?? 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 { sortArticles } from '@/lib/articles/sort'
import ArticleCombobox from '@/components/invoices/ArticleCombobox' import ArticleCombobox from '@/components/invoices/ArticleCombobox'
import { getAmountToPay } from '@/lib/invoices/rounding' 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 { 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@@ -259,6 +260,13 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
quantity: z.number(), quantity: z.number(),
unit: z.string(), unit: z.string(),
unit_price: z.number(), 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), vat_rate: z.number().min(0).max(25),
// Article linkage (artikelregister). Optional: free-text lines omit them. // Article linkage (artikelregister). Optional: free-text lines omit them.
article_id: z.string().nullable().optional(), 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']), document_type: z.enum(['invoice', 'proforma', 'delivery_note']),
your_reference: z.string().optional(), your_reference: z.string().optional(),
our_reference: z.string().optional(), our_reference: z.string().optional(),
invoice_marking: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
// Optional online payment link (pasted from e.g. the Stripe dashboard). // Optional online payment link (pasted from e.g. the Stripe dashboard).
// https-only: mirrors the server-side CreateInvoiceSchema gate. // https-only: mirrors the server-side CreateInvoiceSchema gate.
@@ -453,6 +462,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
[activeAccounts], [activeAccounts],
) )
const [accountOverrideRows, setAccountOverrideRows] = useState<Set<number>>(new Set()) 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 // Dimension tagging (kostnadsställe/projekt, dimensions PR7). Affordances
// render only when company_settings.dimensions_enabled: a UI-visibility // render only when company_settings.dimensions_enabled: a UI-visibility
// gate; a draft that already carries bags still round-trips untouched when // 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, document_type: (initial.document_type ?? 'invoice') as InvoiceDocumentType,
your_reference: initial.your_reference ?? '', your_reference: initial.your_reference ?? '',
our_reference: initial.our_reference ?? '', our_reference: initial.our_reference ?? '',
invoice_marking: initial.invoice_marking ?? '',
notes: initial.notes ?? '', notes: initial.notes ?? '',
payment_link_url: initial.payment_link_url ?? '', payment_link_url: initial.payment_link_url ?? '',
payment_link_auto: initial.payment_link_auto ?? true, payment_link_auto: initial.payment_link_auto ?? true,
@@ -543,6 +556,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
quantity: item.quantity, quantity: item.quantity,
unit: item.unit, unit: item.unit,
unit_price: item.unit_price, unit_price: item.unit_price,
discount_percent: hasLineDiscount(item.discount_percent) ? item.discount_percent : null,
vat_rate: item.vat_rate ?? 25, vat_rate: item.vat_rate ?? 25,
article_id: item.article_id ?? null, article_id: item.article_id ?? null,
revenue_account: item.revenue_account ?? null, revenue_account: item.revenue_account ?? null,
@@ -568,6 +582,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
document_type: 'invoice' as InvoiceDocumentType, document_type: 'invoice' as InvoiceDocumentType,
your_reference: '', your_reference: '',
our_reference: copyInitial.our_reference, our_reference: copyInitial.our_reference,
invoice_marking: '',
notes: copyInitial.notes, notes: copyInitial.notes,
payment_link_url: '', payment_link_url: '',
payment_link_auto: true, payment_link_auto: true,
@@ -635,6 +650,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const watchReceivedDate = watch('received_date') const watchReceivedDate = watch('received_date')
const watchDeliveryDate = watch('delivery_date') const watchDeliveryDate = watch('delivery_date')
const watchYourReference = watch('your_reference') const watchYourReference = watch('your_reference')
const watchInvoiceMarking = watch('invoice_marking')
const watchPaymentLinkUrl = watch('payment_link_url') const watchPaymentLinkUrl = watch('payment_link_url')
const watchPaymentLinkAuto = watch('payment_link_auto') const watchPaymentLinkAuto = watch('payment_link_auto')
const watchPersonnummer = watch('deduction_personnummer') const watchPersonnummer = watch('deduction_personnummer')
@@ -817,6 +833,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
quantity: 1, quantity: 1,
unit: 'st', unit: 'st',
unit_price: 0, unit_price: 0,
discount_percent: null,
vat_rate: vatRegistered ? vatRatePlan.defaultRate : 0, vat_rate: vatRegistered ? vatRatePlan.defaultRate : 0,
article_id: null, article_id: null,
revenue_account: null, revenue_account: null,
@@ -871,6 +888,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
quantity: 0, quantity: 0,
unit: '', unit: '',
unit_price: 0, unit_price: 0,
discount_percent: null,
vat_rate: 0, vat_rate: 0,
article_id: null, article_id: null,
revenue_account: null, revenue_account: null,
@@ -1101,7 +1119,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
} }
const subtotal = watchItems.reduce((sum, item) => { 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) }, 0)
const vatRules = selectedCustomer const vatRules = selectedCustomer
@@ -1134,7 +1152,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
let vatAmount = 0 let vatAmount = 0
for (const item of watchItems) { for (const item of watchItems) {
const rate = vatRegistered ? (item.vat_rate ?? (vatRules?.rate || 25)) : 0 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 const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
vatAmount += lineVat vatAmount += lineVat
const existing = vatByRate.get(rate) || { base: 0, vat: 0 } const existing = vatByRate.get(rate) || { base: 0, vat: 0 }
@@ -1226,6 +1244,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const amount = computeDeduction({ const amount = computeDeduction({
unit_price: item.unit_price || 0, unit_price: item.unit_price || 0,
quantity: item.quantity || 0, quantity: item.quantity || 0,
discount_percent: item.discount_percent,
deduction_type: item.deduction_type, deduction_type: item.deduction_type,
// Same rate resolution as the VAT totals loop above: the deduction // Same rate resolution as the VAT totals loop above: the deduction
// base is the line total inkl. moms (HUSFL 6-9 §§). // 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 // Open/close the optional per-item dimensions override (⋮ menu). Closing
// clears the bag so the row falls back to the invoice's default_dimensions. // clears the bag so the row falls back to the invoice's default_dimensions.
function toggleItemDimensions(index: number) { function toggleItemDimensions(index: number) {
@@ -1754,6 +1790,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
items: data.items, items: data.items,
your_reference: data.your_reference, your_reference: data.your_reference,
our_reference: data.our_reference, our_reference: data.our_reference,
invoice_marking: data.invoice_marking,
notes: data.notes, notes: data.notes,
payment_link_url: data.payment_link_url, payment_link_url: data.payment_link_url,
invoice_number: numberPreview, invoice_number: numberPreview,
@@ -1882,6 +1919,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
receivedDate: watchReceivedDate || '', receivedDate: watchReceivedDate || '',
deliveryDate: watchDeliveryDate || '', deliveryDate: watchDeliveryDate || '',
yourReference: watchYourReference || '', yourReference: watchYourReference || '',
invoiceMarking: watchInvoiceMarking || '',
paymentLink: paymentLinkMode, paymentLink: paymentLinkMode,
oreRounding, oreRounding,
dims: hasDimensionValues(defaultDims) ? compactDims(defaultDims) : null, 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 }) return t('chip_delivery', { date: chip.date })
case 'your_reference': case 'your_reference':
return t('chip_your_reference', { reference: chip.reference }) return t('chip_your_reference', { reference: chip.reference })
case 'invoice_marking':
return t('chip_invoice_marking', { marking: chip.marking })
case 'payment_link': case 'payment_link':
return chip.mode === 'auto' ? t('chip_stripe_auto') : t('chip_payment_link') return chip.mode === 'auto' ? t('chip_stripe_auto') : t('chip_payment_link')
case 'ore_off': 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 rowErrors = errors.items?.[index]
const rowErrorMsg = const rowErrorMsg =
rowErrors?.description?.message ?? rowErrors?.description?.message ??
@@ -2166,6 +2210,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const articleStripOpen = articlePickerRows.has(index) const articleStripOpen = articlePickerRows.has(index)
const accountStripOpen = const accountStripOpen =
isInvoiceDoc && (accountOverrideRows.has(index) || Boolean(item?.revenue_account)) 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 = const dimensionStripOpen =
dimensionsEnabled && dimensionsEnabled &&
isInvoiceDoc && isInvoiceDoc &&
@@ -2292,6 +2342,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
<Package className="h-4 w-4" /> <Package className="h-4 w-4" />
{t('row_menu_pick_article')} {t('row_menu_pick_article')}
</DropdownMenuItem> </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 && ( {isInvoiceDoc && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -2422,6 +2483,59 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</div> </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 {/* ROT/RUT-avdrag strip: only when a deduction is
active on this row (chosen via the ⋮ menu). */} active on this row (chosen via the ⋮ menu). */}
{isInvoiceDoc && item?.deduction_type && ( {isInvoiceDoc && item?.deduction_type && (
@@ -2482,6 +2596,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const amt = computeDeduction({ const amt = computeDeduction({
unit_price: item?.unit_price || 0, unit_price: item?.unit_price || 0,
quantity: item?.quantity || 0, quantity: item?.quantity || 0,
discount_percent: item?.discount_percent,
deduction_type: item?.deduction_type, deduction_type: item?.deduction_type,
vat_rate: vatRegistered vat_rate: vatRegistered
? (item?.vat_rate ?? (vatRules?.rate || 25)) ? (item?.vat_rate ?? (vatRules?.rate || 25))
@@ -2924,6 +3039,23 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
/> />
</div> </div>
</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 {/* Online payment link: manual paste or the Stripe auto
toggle. Only real invoices; hidden unless the company toggle. Only real invoices; hidden unless the company
@@ -3212,6 +3344,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
total={total} total={total}
yourReference={pendingData?.your_reference} yourReference={pendingData?.your_reference}
ourReference={pendingData?.our_reference} ourReference={pendingData?.our_reference}
invoiceMarking={pendingData?.invoice_marking}
notes={pendingData?.notes} notes={pendingData?.notes}
numberPreview={numberPreview} numberPreview={numberPreview}
oreRounding={oreRounding} oreRounding={oreRounding}
+26 -6
View File
@@ -7,6 +7,7 @@ import { Separator } from '@/components/ui/separator'
import { formatCurrency, formatDate } from '@/lib/utils' import { formatCurrency, formatDate } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding' import { getDisplayTotal } from '@/lib/invoices/rounding'
import { isTextLikeLine } from '@/lib/invoices/display' import { isTextLikeLine } from '@/lib/invoices/display'
import { computeLineNet } from '@/lib/invoices/line-amounts'
import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions' import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import type { Customer, Currency } from '@/types' import type { Customer, Currency } from '@/types'
@@ -15,6 +16,8 @@ interface ReviewItem {
quantity: number quantity: number
unit: string unit: string
unit_price: number unit_price: number
/** Line discount 0-100; amounts render net of it. */
discount_percent?: number | null
vat_rate?: number vat_rate?: number
/** 'text' rows are free-text/blank lines: description only, no amounts. */ /** 'text' rows are free-text/blank lines: description only, no amounts. */
line_type?: 'product' | 'text' line_type?: 'product' | 'text'
@@ -38,6 +41,7 @@ interface InvoiceReviewContentProps {
total: number total: number
yourReference?: string yourReference?: string
ourReference?: string ourReference?: string
invoiceMarking?: string
notes?: string notes?: string
/** The invoice number that will be assigned on confirm. Null when unknown /** The invoice number that will be assigned on confirm. Null when unknown
* (e.g. delivery notes use a different sequence) or unfetched. */ * (e.g. delivery notes use a different sequence) or unfetched. */
@@ -63,6 +67,7 @@ export function InvoiceReviewContent({
total, total,
yourReference, yourReference,
ourReference, ourReference,
invoiceMarking,
notes, notes,
numberPreview, numberPreview,
oreRounding, oreRounding,
@@ -84,7 +89,7 @@ export function InvoiceReviewContent({
for (const item of items) { for (const item of items) {
if (isTextLikeLine(item)) continue if (isTextLikeLine(item)) continue
const rate = item.vat_rate ?? 0 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 const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat) vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
} }
@@ -182,12 +187,19 @@ export function InvoiceReviewContent({
</td> </td>
<td className="py-2 text-right">{item.quantity}</td> <td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-center">{item.unit}</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 && ( {showVatColumn && (
<td className="py-2 text-right">{item.vat_rate ?? 0}%</td> <td className="py-2 text-right">{item.vat_rate ?? 0}%</td>
)} )}
<td className="py-2 text-right"> <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> </td>
</tr> </tr>
) )
@@ -214,11 +226,14 @@ export function InvoiceReviewContent({
</p> </p>
)} )}
<div className="flex items-center justify-between text-muted-foreground"> <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>} {showVatColumn && <span className="text-xs">{t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}</span>}
</div> </div>
<p className="text-right font-medium"> <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> </p>
</div> </div>
) )
@@ -260,7 +275,7 @@ export function InvoiceReviewContent({
</div> </div>
{/* References/notes */} {/* References/notes */}
{(yourReference || ourReference || notes) && ( {(yourReference || ourReference || invoiceMarking || notes) && (
<div className="border-t pt-3 space-y-2 text-sm text-muted-foreground"> <div className="border-t pt-3 space-y-2 text-sm text-muted-foreground">
{yourReference && ( {yourReference && (
<p> <p>
@@ -272,6 +287,11 @@ export function InvoiceReviewContent({
<span>{t('our_reference')}</span> {ourReference} <span>{t('our_reference')}</span> {ourReference}
</p> </p>
)} )}
{invoiceMarking && (
<p>
<span>{t('invoice_marking')}</span> {invoiceMarking}
</p>
)}
{notes && <p>{t('notes_prefix', { notes })}</p>} {notes && <p>{t('notes_prefix', { notes })}</p>}
</div> </div>
)} )}
@@ -165,6 +165,7 @@ function chipsInput(overrides: Partial<ForvalChipsInput> = {}): ForvalChipsInput
receivedDate: '', receivedDate: '',
deliveryDate: '', deliveryDate: '',
yourReference: '', yourReference: '',
invoiceMarking: '',
paymentLink: null, paymentLink: null,
oreRounding: true, oreRounding: true,
dims: null, dims: null,
@@ -221,6 +222,7 @@ describe('deriveForvalChips', () => {
currency: 'EUR', currency: 'EUR',
deliveryDate: '2026-08-20', deliveryDate: '2026-08-20',
yourReference: 'Anna', yourReference: 'Anna',
invoiceMarking: 'KST 4711',
paymentLink: 'manual', paymentLink: 'manual',
oreRounding: false, oreRounding: false,
dims: 'KS01 · P001', dims: 'KS01 · P001',
@@ -230,6 +232,7 @@ describe('deriveForvalChips', () => {
expect(chips).toContainEqual({ kind: 'currency', currency: 'EUR' }) expect(chips).toContainEqual({ kind: 'currency', currency: 'EUR' })
expect(chips).toContainEqual({ kind: 'delivery', date: '2026-08-20' }) expect(chips).toContainEqual({ kind: 'delivery', date: '2026-08-20' })
expect(chips).toContainEqual({ kind: 'your_reference', reference: 'Anna' }) 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: 'payment_link', mode: 'manual' })
expect(chips).toContainEqual({ kind: 'dims', dims: 'KS01 · P001' }) expect(chips).toContainEqual({ kind: 'dims', dims: 'KS01 · P001' })
// ore_off is SEK-only: an EUR invoice has no öresavrundning to disable. // ore_off is SEK-only: an EUR invoice has no öresavrundning to disable.
@@ -249,6 +252,7 @@ describe('deriveForvalChips', () => {
receivedDate: '2026-08-15', receivedDate: '2026-08-15',
documentType: 'proforma', documentType: 'proforma',
yourReference: 'x', yourReference: 'x',
invoiceMarking: 'x',
paymentLink: 'auto', paymentLink: 'auto',
oreRounding: false, oreRounding: false,
dims: 'KS01', dims: 'KS01',
@@ -121,6 +121,7 @@ export type ForvalChip =
| { kind: 'received'; date: string } | { kind: 'received'; date: string }
| { kind: 'delivery'; date: string } | { kind: 'delivery'; date: string }
| { kind: 'your_reference'; reference: string } | { kind: 'your_reference'; reference: string }
| { kind: 'invoice_marking'; marking: string }
| { kind: 'payment_link'; mode: 'auto' | 'manual' } | { kind: 'payment_link'; mode: 'auto' | 'manual' }
| { kind: 'ore_off' } | { kind: 'ore_off' }
| { kind: 'dims'; dims: string } | { kind: 'dims'; dims: string }
@@ -134,6 +135,7 @@ export interface ForvalChipsInput {
receivedDate: string receivedDate: string
deliveryDate: string deliveryDate: string
yourReference: string yourReference: string
invoiceMarking: string
paymentLink: 'auto' | 'manual' | null paymentLink: 'auto' | 'manual' | null
oreRounding: boolean oreRounding: boolean
/** Compact display of the invoice-level default dims, or null when none. */ /** 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()) { if (!input.isSelfBilled && input.yourReference.trim()) {
chips.push({ kind: 'your_reference', reference: 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) { if (!input.isSelfBilled && input.paymentLink) {
chips.push({ kind: 'payment_link', mode: input.paymentLink }) chips.push({ kind: 'payment_link', mode: input.paymentLink })
} }
@@ -189,6 +189,7 @@ describe('gnubok_get_invoice: execute', () => {
quantity: 2, quantity: 2,
unit: 'tim', unit: 'tim',
unit_price: 1200, unit_price: 1200,
discount_percent: 0,
line_total: 2400, line_total: 2400,
vat_rate: 25, vat_rate: 25,
vat_amount: 600, vat_amount: 600,
@@ -359,6 +359,7 @@ describe('gnubok_update_invoice: validation and staging', () => {
expect(result.preview.current_items).toEqual( expect(result.preview.current_items).toEqual(
CURRENT_ROWS.map((row) => ({ CURRENT_ROWS.map((row) => ({
...row, ...row,
discount_percent: 0,
deduction_type: null, deduction_type: null,
accrual_period_start: null, accrual_period_start: null,
accrual_period_end: 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 { eventBus } from '@/lib/events/bus'
import { getVatRules, getPermittedVatRates, getArticleVatRateAdoptionSet } from '@/lib/invoices/vat-rules' import { getVatRules, getPermittedVatRates, getArticleVatRateAdoptionSet } from '@/lib/invoices/vat-rules'
import { validateDeductionLines } from '@/lib/invoices/rot-rut-rules' import { validateDeductionLines } from '@/lib/invoices/rot-rut-rules'
import { computeLineNet } from '@/lib/invoices/line-amounts'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { getBranding } from '@/lib/branding/service' import { getBranding } from '@/lib/branding/service'
import { generateIncomeStatement } from '@/lib/reports/income-statement' import { generateIncomeStatement } from '@/lib/reports/income-statement'
@@ -303,6 +304,8 @@ type StagedInvoiceLineInput = {
quantity: number quantity: number
unit?: string unit?: string
unit_price?: number unit_price?: number
/** Line discount 0-100 (rabatt i procent); totals are computed net of it. */
discount_percent?: number
vat_rate?: number vat_rate?: number
article_id?: string article_id?: string
revenue_account?: string | null revenue_account?: string | null
@@ -370,10 +373,21 @@ function resolveInvoiceLineFromArticle(
quantity: 0, quantity: 0,
unit: '', unit: '',
unit_price: 0, unit_price: 0,
discount_percent: 0,
vat_rate: 0, vat_rate: 0,
} }
} }
if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${lineNo}: quantity must be positive`) 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) { 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.`) 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'] }, remaining_amount: { type: ['number', 'null'] },
your_reference: { type: ['string', 'null'] }, your_reference: { type: ['string', 'null'] },
our_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'] }, notes: { type: ['string', 'null'] },
default_dimensions: { type: 'object', additionalProperties: { type: 'string' } }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' } },
editable_draft: { type: 'boolean', description: 'true when gnubok_update_invoice can edit it' }, editable_draft: { type: 'boolean', description: 'true when gnubok_update_invoice can edit it' },
@@ -6303,6 +6318,7 @@ export const tools: McpTool[] = [
quantity: { type: 'number' }, quantity: { type: 'number' },
unit: { type: 'string' }, unit: { type: 'string' },
unit_price: { type: 'number' }, unit_price: { type: 'number' },
discount_percent: { type: 'number', description: 'Line discount 0-100; line_total is net of it' },
line_total: { type: 'number' }, line_total: { type: 'number' },
vat_rate: { type: 'number' }, vat_rate: { type: 'number' },
vat_amount: { type: 'number' }, vat_amount: { type: 'number' },
@@ -6345,7 +6361,7 @@ export const tools: McpTool[] = [
const { data: invoice, error } = await supabase const { data: invoice, error } = await supabase
.from('invoices') .from('invoices')
.select( .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('id', invoiceId)
.eq('company_id', companyId) .eq('company_id', companyId)
@@ -6362,6 +6378,7 @@ export const tools: McpTool[] = [
quantity: number quantity: number
unit: string unit: string
unit_price: number unit_price: number
discount_percent: number | null
line_total: number line_total: number
vat_rate: number vat_rate: number
vat_amount: number | null vat_amount: number | null
@@ -6390,6 +6407,7 @@ export const tools: McpTool[] = [
quantity: row.quantity, quantity: row.quantity,
unit: row.unit, unit: row.unit,
unit_price: row.unit_price, unit_price: row.unit_price,
discount_percent: row.discount_percent ?? 0,
line_total: row.line_total, line_total: row.line_total,
vat_rate: row.vat_rate, vat_rate: row.vat_rate,
vat_amount: row.vat_amount ?? 0, vat_amount: row.vat_amount ?? 0,
@@ -6427,6 +6445,7 @@ export const tools: McpTool[] = [
remaining_amount: invoice.remaining_amount ?? null, remaining_amount: invoice.remaining_amount ?? null,
your_reference: invoice.your_reference ?? null, your_reference: invoice.your_reference ?? null,
our_reference: invoice.our_reference ?? null, our_reference: invoice.our_reference ?? null,
invoice_marking: invoice.invoice_marking ?? null,
notes: invoice.notes ?? null, notes: invoice.notes ?? null,
default_dimensions: (invoice.default_dimensions as Record<string, string> | null) ?? {}, default_dimensions: (invoice.default_dimensions as Record<string, string> | null) ?? {},
editable_draft: isEditableInvoiceDraft(invoice), editable_draft: isEditableInvoiceDraft(invoice),
@@ -6456,6 +6475,7 @@ export const tools: McpTool[] = [
quantity: { type: 'number' }, quantity: { type: 'number' },
unit: { type: 'string', description: 'st, tim, dag, mån' }, unit: { type: 'string', description: 'st, tim, dag, mån' },
unit_price: { type: 'number', description: 'Price per unit excl. VAT' }, 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)' }, vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' },
article_id: { article_id: {
type: 'string', type: 'string',
@@ -6483,6 +6503,7 @@ export const tools: McpTool[] = [
currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] }, currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] },
our_reference: { type: 'string' }, our_reference: { type: 'string' },
your_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' }, notes: { type: 'string' },
payment_link_url: { payment_link_url: {
type: 'string', type: 'string',
@@ -6603,8 +6624,11 @@ export const tools: McpTool[] = [
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated) const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(permittedRates.map((r) => r.rate)) const allowedRates = new Set(permittedRates.map((r) => r.rate))
// Calculate per-item VAT // Calculate per-item VAT (line totals net of any per-line discount)
const subtotal = items.reduce((s, item) => s + item.quantity * item.unit_price, 0) const subtotal = items.reduce(
(s, item) => s + computeLineNet(item.quantity, item.unit_price, item.discount_percent),
0,
)
let vatAmount = 0 let vatAmount = 0
for (const item of items) { for (const item of items) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate 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(', ')}` `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 vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
} }
const total = subtotal + vatAmount const total = subtotal + vatAmount
@@ -6641,6 +6665,7 @@ export const tools: McpTool[] = [
currency, currency,
our_reference: (args.our_reference as string) || null, our_reference: (args.our_reference as string) || null,
your_reference: (args.your_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, notes: (args.notes as string) || null,
payment_link_url: paymentLinkUrl, payment_link_url: paymentLinkUrl,
}, },
@@ -6649,7 +6674,7 @@ export const tools: McpTool[] = [
customer_type: customer.customer_type, customer_type: customer.customer_type,
items: stagedItems.map(item => ({ items: stagedItems.map(item => ({
...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, vat_rate: item.vat_rate ?? vatRules.rate,
})), })),
subtotal: Math.round(subtotal * 100) / 100, 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.' }, delivery_date: { type: ['string', 'null'], description: 'YYYY-MM-DD; null clears the delivery date.' },
your_reference: { type: 'string' }, your_reference: { type: 'string' },
our_reference: { type: 'string' }, our_reference: { type: 'string' },
invoice_marking: { type: 'string', description: 'Fakturamärkning (buyer marking/PO label), separate from your_reference.' },
items: { items: {
type: 'array', type: 'array',
items: { items: {
@@ -17183,6 +17209,7 @@ export const tools: McpTool[] = [
quantity: { type: 'number' }, quantity: { type: 'number' },
unit: { type: 'string', description: 'st, tim, dag, mån' }, unit: { type: 'string', description: 'st, tim, dag, mån' },
unit_price: { type: 'number', description: 'Price per unit excl. VAT' }, 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)' }, vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' },
article_id: { article_id: {
type: 'string', type: 'string',
@@ -17259,7 +17286,7 @@ export const tools: McpTool[] = [
} }
const headerChanges: Record<string, unknown> = {} 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 (args[key] !== undefined) headerChanges[key] = args[key]
} }
if (rawItems === undefined && args.default_dimensions === undefined && Object.keys(headerChanges).length === 0) { 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(', ')}` `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 subtotal += lineTotal
vatAmount += roundOre(lineTotal * itemRate / 100) vatAmount += roundOre(lineTotal * itemRate / 100)
} }
@@ -17379,6 +17406,7 @@ export const tools: McpTool[] = [
deductionLines.map((item) => ({ deductionLines.map((item) => ({
unit_price: item.unit_price, unit_price: item.unit_price,
quantity: item.quantity, quantity: item.quantity,
discount_percent: item.discount_percent ?? 0,
deduction_type: item.deduction_type ?? null, deduction_type: item.deduction_type ?? null,
vat_rate: item.vat_rate ?? vatRules.rate, vat_rate: item.vat_rate ?? vatRules.rate,
labor_hours: item.labor_hours ?? null, labor_hours: item.labor_hours ?? null,
@@ -17421,7 +17449,7 @@ export const tools: McpTool[] = [
// the property columns are not needed for the preview. // the property columns are not needed for the preview.
const { data: currentRows, error: currentError } = await supabase const { data: currentRows, error: currentError } = await supabase
.from('invoice_items') .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) .eq('invoice_id', invoice.id)
.order('sort_order', { ascending: true }) .order('sort_order', { ascending: true })
if (currentError) throw dbError(currentError) if (currentError) throw dbError(currentError)
@@ -17431,6 +17459,7 @@ export const tools: McpTool[] = [
quantity: row.quantity, quantity: row.quantity,
unit: row.unit, unit: row.unit,
unit_price: row.unit_price, unit_price: row.unit_price,
discount_percent: row.discount_percent ?? 0,
line_total: row.line_total, line_total: row.line_total,
vat_rate: row.vat_rate, vat_rate: row.vat_rate,
revenue_account: row.revenue_account ?? null, 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 { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients' import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account' import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import { computeLineNet } from '@/lib/invoices/line-amounts'
import { import {
DEDUCTION_LINE_ERRORS, DEDUCTION_LINE_ERRORS,
HOUSEWORK_TYPE_VALUES, HOUSEWORK_TYPE_VALUES,
@@ -403,6 +404,10 @@ export const CreateInvoiceItemSchema = z
quantity: z.number(), quantity: z.number(),
unit: z.string(), unit: z.string(),
unit_price: z.number(), 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(), vat_rate: z.number().min(0).max(100).optional(),
// Article linkage. `article_id` ties the line to a catalog article (text // 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 // 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', 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({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['accrual_period_start'], path: ['accrual_period_start'],
@@ -520,6 +526,10 @@ const CreateInvoiceBaseSchema = z.object({
document_type: InvoiceDocumentTypeSchema.optional(), document_type: InvoiceDocumentTypeSchema.optional(),
your_reference: z.string().optional(), your_reference: z.string().optional(),
our_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(), notes: z.string().optional(),
// Optional online payment link (manual MVP): the user pastes a link created // 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 // 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 = 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 * 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 = export const INVOICE_PDF_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, document_type, ' + '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, ' + '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' 'paid_amount, remaining_amount, deduction_total, deduction_personnummer_last4'
export const INVOICE_ITEM_FULL_COLUMNS = 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) 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 () => { 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) + // ROT line 10 000 (deduction 30% of 12 500 inkl. moms = 3 750) +
// non-deduction materials line 4 000. // non-deduction materials line 4 000.
+4
View File
@@ -331,6 +331,10 @@ function generateRotRutLines(
const amount = computeDeduction({ const amount = computeDeduction({
unit_price: side === 'credit' ? Math.abs(item.unit_price) : item.unit_price, unit_price: side === 'credit' ? Math.abs(item.unit_price) : item.unit_price,
quantity: side === 'credit' ? Math.abs(item.quantity) : item.quantity, 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, deduction_type: item.deduction_type,
vat_rate: item.vat_rate, vat_rate: item.vat_rate,
}) })
+3
View File
@@ -245,6 +245,9 @@ function buildSendLines(
const deduction = computeDeduction({ const deduction = computeDeduction({
unit_price: item.unit_price, unit_price: item.unit_price,
quantity: item.quantity, 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, deduction_type: item.deduction_type,
vat_rate: item.vat_rate, vat_rate: item.vat_rate,
}) })
@@ -52,4 +52,19 @@ describe('buildCreditNoteItem', () => {
dimensions: { '6': 'P001' }, 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', () => { it('rejects credit notes and self-billed invoices in the generation layer', () => {
for (const invoice of [ for (const invoice of [
makeInvoice({ makeInvoice({
@@ -19,6 +19,7 @@ function makeItem(overrides: Partial<InvoiceWriteItemRow> = {}): InvoiceWriteIte
quantity: 1, quantity: 1,
unit: 'tim', unit: 'tim',
unit_price: 1000, unit_price: 1000,
discount_percent: 0,
line_total: 1000, line_total: 1000,
vat_rate: 25, vat_rate: 25,
vat_amount: 250, vat_amount: 250,
+4
View File
@@ -9,6 +9,10 @@ export function buildCreditNoteItem(invoiceId: string, item: InvoiceItem) {
quantity: -Math.abs(item.quantity), quantity: -Math.abs(item.quantity),
unit: item.unit, unit: item.unit,
unit_price: item.unit_price, 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), line_total: -Math.abs(item.line_total),
vat_rate: item.vat_rate ?? 0, vat_rate: item.vat_rate ?? 0,
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 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 type { Currency, Customer, InvoiceDocumentType } from '@/types'
import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules' import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules'
import { isBalanceSheetAccount } from '@/lib/invoices/posting-account' import { isBalanceSheetAccount } from '@/lib/invoices/posting-account'
import { computeLineNet } from '@/lib/invoices/line-amounts'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions' import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import { import {
@@ -46,6 +47,9 @@ export interface InvoiceWriteItemInput {
quantity: number quantity: number
unit: string unit: string
unit_price: number 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 vat_rate?: number
article_id?: string | null article_id?: string | null
revenue_account?: string | null revenue_account?: string | null
@@ -70,6 +74,8 @@ export interface InvoiceWriteInput {
currency: Currency currency: Currency
your_reference?: string your_reference?: string
our_reference?: string our_reference?: string
/** Fakturamärkning: buyer-required marking, separate from your_reference. */
invoice_marking?: string
notes?: string notes?: string
/** Optional https payment link (schema-validated). Omitted/empty → null. */ /** Optional https payment link (schema-validated). Omitted/empty → null. */
payment_link_url?: string payment_link_url?: string
@@ -111,6 +117,7 @@ export type InvoiceWriteFields = {
reverse_charge_text: string | null reverse_charge_text: string | null
your_reference: string | null | undefined your_reference: string | null | undefined
our_reference: string | null | undefined our_reference: string | null | undefined
invoice_marking: string | null
notes: string | null | undefined notes: string | null | undefined
payment_link_url: string | null payment_link_url: string | null
payment_link_auto: boolean payment_link_auto: boolean
@@ -129,6 +136,7 @@ export type InvoiceWriteItemRow = {
quantity: number quantity: number
unit: string unit: string
unit_price: number unit_price: number
discount_percent: number
line_total: number line_total: number
vat_rate: number vat_rate: number
vat_amount: 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. // 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( 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, 0,
) )
@@ -260,7 +272,7 @@ export async function buildInvoiceWriteData(params: {
details: { account: item.revenue_account, vatRate: itemRate }, 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 vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
} }
} }
@@ -330,6 +342,7 @@ export async function buildInvoiceWriteData(params: {
const validateInput = items.map((item) => ({ const validateInput = items.map((item) => ({
unit_price: item.unit_price, unit_price: item.unit_price,
quantity: item.quantity, quantity: item.quantity,
discount_percent: item.discount_percent ?? 0,
deduction_type: item.deduction_type ?? null, deduction_type: item.deduction_type ?? null,
// The deduction base is arbetskostnaden inkl. moms (HUSFL 6-9 §§), so // The deduction base is arbetskostnaden inkl. moms (HUSFL 6-9 §§), so
// the validator and total need the same per-line rate the item rows // 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), reverse_charge_text: notVatRegistered ? null : (headerRules.reverseChargeText || null),
your_reference: input.your_reference, your_reference: input.your_reference,
our_reference: input.our_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, notes: input.notes,
// Always a concrete value (never undefined) so a draft edit that cleared // Always a concrete value (never undefined) so a draft edit that cleared
// the field actually NULLs the column: supabase-js drops undefined keys. // the field actually NULLs the column: supabase-js drops undefined keys.
@@ -542,6 +558,7 @@ export async function buildInvoiceWriteData(params: {
quantity: 0, quantity: 0,
unit: '', unit: '',
unit_price: 0, unit_price: 0,
discount_percent: 0,
line_total: 0, line_total: 0,
vat_rate: 0, vat_rate: 0,
vat_amount: 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 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 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 // ROT/RUT deduction is recomputed server-side so a tampered client can't
// expand the 1513 receivable beyond the rules. Non-invoice document types // expand the 1513 receivable beyond the rules. Non-invoice document types
@@ -571,6 +589,7 @@ export async function buildInvoiceWriteData(params: {
? computeDeduction({ ? computeDeduction({
unit_price: item.unit_price, unit_price: item.unit_price,
quantity: item.quantity, quantity: item.quantity,
discount_percent: discountPercent,
deduction_type: deductionType, deduction_type: deductionType,
vat_rate: itemRate, vat_rate: itemRate,
}) })
@@ -582,6 +601,7 @@ export async function buildInvoiceWriteData(params: {
quantity: item.quantity, quantity: item.quantity,
unit: item.unit, unit: item.unit,
unit_price: item.unit_price, unit_price: item.unit_price,
discount_percent: discountPercent,
line_total: lineTotal, line_total: lineTotal,
vat_rate: itemRate, vat_rate: itemRate,
vat_amount: itemVat, vat_amount: itemVat,
+3
View File
@@ -22,6 +22,7 @@ export interface InvoiceCopyItem {
quantity: number quantity: number
unit: string unit: string
unit_price: number unit_price: number
discount_percent: number
vat_rate: number vat_rate: number
article_id: null article_id: null
revenue_account: string | null revenue_account: string | null
@@ -85,6 +86,8 @@ export function buildInvoiceCopyInitial(source: InvoiceCopySource): InvoiceCopyI
quantity: item.quantity, quantity: item.quantity,
unit: item.unit, unit: item.unit,
unit_price: item.unit_price, 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, vat_rate: item.vat_rate ?? 25,
// A copied line keeps the frozen description and price, but is not // A copied line keeps the frozen description and price, but is not
// linked to a possibly changed or archived article preset. // 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:', deliveryDate: 'Leveransdatum:',
yourReference: 'Er referens:', yourReference: 'Er referens:',
ourReference: 'Vår referens:', ourReference: 'Vår referens:',
invoiceMarking: 'Märkning:',
// Customer box // Customer box
custNo: 'Kundnr:', custNo: 'Kundnr:',
orgNo: 'Org.nr:', orgNo: 'Org.nr:',
@@ -64,6 +65,7 @@ const LABELS = {
colQty: 'Antal', colQty: 'Antal',
colUnit: 'Enhet', colUnit: 'Enhet',
colUnitPrice: 'à-pris', colUnitPrice: 'à-pris',
colDiscount: 'Rabatt',
colVat: 'Moms', colVat: 'Moms',
colTotal: 'Summa', colTotal: 'Summa',
// Totals // Totals
@@ -136,6 +138,7 @@ const LABELS = {
deliveryDate: 'Delivery date:', deliveryDate: 'Delivery date:',
yourReference: 'Your reference:', yourReference: 'Your reference:',
ourReference: 'Our reference:', ourReference: 'Our reference:',
invoiceMarking: 'Buyer reference:',
custNo: 'Customer no.:', custNo: 'Customer no.:',
orgNo: 'Reg. no.:', orgNo: 'Reg. no.:',
vat: 'VAT:', vat: 'VAT:',
@@ -143,6 +146,7 @@ const LABELS = {
colQty: 'Qty', colQty: 'Qty',
colUnit: 'Unit', colUnit: 'Unit',
colUnitPrice: 'Unit price', colUnitPrice: 'Unit price',
colDiscount: 'Discount',
colVat: 'VAT', colVat: 'VAT',
colTotal: 'Amount', colTotal: 'Amount',
subtotal: 'Subtotal:', subtotal: 'Subtotal:',
@@ -367,6 +371,10 @@ function createStyles(branding?: InvoiceBranding) {
flex: 1.5, flex: 1.5,
textAlign: 'right', textAlign: 'right',
}, },
colDiscount: {
flex: 1,
textAlign: 'right',
},
colVat: { colVat: {
flex: 1, flex: 1,
textAlign: 'right', textAlign: 'right',
@@ -785,6 +793,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
? new Set(billableItems.map((item) => item.vat_rate)) ? new Set(billableItems.map((item) => item.vat_rate))
: new Set<number>() : new Set<number>()
const showVatColumn = hasPerLineVat && uniqueRates.size > 1 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 // Calculate per-rate VAT breakdown for totals
const vatByRate = hasPerLineVat const vatByRate = hasPerLineVat
@@ -950,6 +962,18 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View> </View>
</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> </View>
{/* Customer */} {/* Customer */}
@@ -1003,6 +1027,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{!isDeliveryNote && ( {!isDeliveryNote && (
<Text style={[styles.colPrice, styles.tableHeaderText]}>{L.colUnitPrice}</Text> <Text style={[styles.colPrice, styles.tableHeaderText]}>{L.colUnitPrice}</Text>
)} )}
{!isDeliveryNote && showDiscountColumn && (
<Text style={[styles.colDiscount, styles.tableHeaderText]}>{L.colDiscount}</Text>
)}
{!isDeliveryNote && showVatColumn && ( {!isDeliveryNote && showVatColumn && (
<Text style={[styles.colVat, styles.tableHeaderText]}>{L.colVat}</Text> <Text style={[styles.colVat, styles.tableHeaderText]}>{L.colVat}</Text>
)} )}
@@ -1029,6 +1056,11 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{!isDeliveryNote && ( {!isDeliveryNote && (
<Text style={styles.colPrice}>{formatPdfCurrency(item.unit_price, invoice.currency, lang)}</Text> <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 && ( {!isDeliveryNote && showVatColumn && (
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text> <Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
)} )}
+42 -9
View File
@@ -4,6 +4,7 @@ import {
validatePlusgiroNumber, validatePlusgiroNumber,
} from '@/lib/bankgiro/luhn' } from '@/lib/bankgiro/luhn'
import { isSaneDateString, normalizeOrgNumber } from '@/lib/invariants' import { isSaneDateString, normalizeOrgNumber } from '@/lib/invariants'
import { computeLineAmounts, hasLineDiscount } from '@/lib/invoices/line-amounts'
import { getDisplayTotal } from '@/lib/invoices/rounding' import { getDisplayTotal } from '@/lib/invoices/rounding'
import { equalOre, roundOre } from '@/lib/money' import { equalOre, roundOre } from '@/lib/money'
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types' 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.', '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( issues.push(validationIssue(
'BUYER_REFERENCE_REQUIRED', 'invoice.your_reference', 'BUYER_REFERENCE_REQUIRED', 'invoice.your_reference',
'Er referens krävs för Peppol när inköpsordernummer saknas.', 'Märkning eller 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.', 'A marking or buyer reference is required for Peppol when no purchase order reference is available.',
)) ))
} }
if ((invoice.deduction_total ?? 0) !== 0) { 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.`, `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( issues.push(validationIssue(
'LINE_TOTAL_MISMATCH', `${lineField}.line_total`, 'LINE_TOTAL_MISMATCH', `${lineField}.line_total`,
`Beloppet på fakturarad ${index + 1} stämmer inte med antal gånger pris.`, `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.`, `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))) { 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:TaxCategory>',
' </cac:TaxSubtotal>', ' </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>', ' <cac:InvoiceLine>',
` <cbc:ID>${index + 1}</cbc:ID>`, ` <cbc:ID>${index + 1}</cbc:ID>`,
` <cbc:InvoicedQuantity unitCode="${UNIT_CODES[item.unit]}">${formatDecimal(item.quantity)}</cbc:InvoicedQuantity>`, ` <cbc:InvoicedQuantity unitCode="${UNIT_CODES[item.unit]}">${formatDecimal(item.quantity)}</cbc:InvoicedQuantity>`,
` <cbc:LineExtensionAmount currencyID="SEK">${formatMoney(item.line_total)}</cbc:LineExtensionAmount>`, ` <cbc:LineExtensionAmount currencyID="SEK">${formatMoney(item.line_total)}</cbc:LineExtensionAmount>`,
...allowance,
' <cac:Item>', ' <cac:Item>',
` <cbc:Name>${escapeXml(item.description.trim())}</cbc:Name>`, ` <cbc:Name>${escapeXml(item.description.trim())}</cbc:Name>`,
' <cac:ClassifiedTaxCategory>', ' <cac:ClassifiedTaxCategory>',
@@ -569,7 +601,8 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice):
` <cbc:PriceAmount currencyID="SEK">${formatDecimal(item.unit_price)}</cbc:PriceAmount>`, ` <cbc:PriceAmount currencyID="SEK">${formatDecimal(item.unit_price)}</cbc:PriceAmount>`,
' </cac:Price>', ' </cac:Price>',
' </cac:InvoiceLine>', ' </cac:InvoiceLine>',
]) ]
})
return [ return [
'<?xml version="1.0" encoding="UTF-8"?>', '<?xml version="1.0" encoding="UTF-8"?>',
@@ -584,7 +617,7 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice):
' <cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>', ' <cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>',
invoice.notes ? ` <cbc:Note>${escapeXml(invoice.notes)}</cbc:Note>` : null, invoice.notes ? ` <cbc:Note>${escapeXml(invoice.notes)}</cbc:Note>` : null,
' <cbc:DocumentCurrencyCode>SEK</cbc:DocumentCurrencyCode>', ' <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('AccountingSupplierParty', prepared.supplier, company.f_skatt),
renderParty('AccountingCustomerParty', prepared.buyer, false), renderParty('AccountingCustomerParty', prepared.buyer, false),
invoice.delivery_date invoice.delivery_date
+9 -1
View File
@@ -1,4 +1,5 @@
import { roundOre } from '@/lib/money' import { roundOre } from '@/lib/money'
import { computeLineNet } from '@/lib/invoices/line-amounts'
/** /**
* ROT/RUT-avdrag rules. * ROT/RUT-avdrag rules.
@@ -211,6 +212,12 @@ export interface ItemForDeduction {
unit_price: number unit_price: number
/** Quantity. Same field as invoice_items.quantity. */ /** Quantity. Same field as invoice_items.quantity. */
quantity: number 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. */ /** 'rot' | 'rut' | null. Drives whether the deduction kicks in at all. */
deduction_type?: DeductionType | null deduction_type?: DeductionType | null
/** /**
@@ -242,7 +249,8 @@ export interface ItemForDeduction {
*/ */
export function computeDeduction(item: ItemForDeduction): number { export function computeDeduction(item: ItemForDeduction): number {
if (!item.deduction_type) return 0 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 if (lineTotal <= 0) return 0
const rate = item.vat_rate ?? 0 const rate = item.vat_rate ?? 0
const lineVat = rate > 0 ? Math.round(lineTotal * rate / 100 * 100) / 100 : 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 InvoiceWriteInput,
type InvoiceWriteItemInput, type InvoiceWriteItemInput,
} from '@/lib/invoices/build-invoice-write' } from '@/lib/invoices/build-invoice-write'
import { computeLineNet } from '@/lib/invoices/line-amounts'
import { deleteDraftInvoice } from '@/lib/invoices/delete-draft-invoice' import { deleteDraftInvoice } from '@/lib/invoices/delete-draft-invoice'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft' import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items' import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items'
@@ -1631,6 +1632,7 @@ async function commitCreateInvoice(
const customerId = params.customer_id as string const customerId = params.customer_id as string
const items = params.items as Array<{ const items = params.items as Array<{
description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number
discount_percent?: number | null
article_id?: string | null; revenue_account?: string | null article_id?: string | null; revenue_account?: string | null
line_type?: 'product' | 'text' line_type?: 'product' | 'text'
dimensions?: Record<string, string> dimensions?: Record<string, string>
@@ -1674,7 +1676,12 @@ async function commitCreateInvoice(
const notVatRegistered = vatSettings?.vat_registered === false const notVatRegistered = vatSettings?.vat_registered === false
if (notVatRegistered) for (const item of items) item.vat_rate = 0 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 let vatAmount = 0
for (const item of billableItems) { for (const item of billableItems) {
@@ -1682,7 +1689,14 @@ async function commitCreateInvoice(
if (!allowedRates.has(itemRate)) { if (!allowedRates.has(itemRate)) {
return { error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp`, status: 400 } 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 vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
} }
@@ -1824,6 +1838,7 @@ async function commitCreateInvoice(
reverse_charge_text: notVatRegistered ? null : (vatRules.reverseChargeText || null), reverse_charge_text: notVatRegistered ? null : (vatRules.reverseChargeText || null),
our_reference: (params.our_reference as string) || null, our_reference: (params.our_reference as string) || null,
your_reference: (params.your_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, notes: (params.notes as string) || null,
payment_link_url: paymentLinkUrl, payment_link_url: paymentLinkUrl,
default_dimensions: defaultDimensions ?? {}, default_dimensions: defaultDimensions ?? {},
@@ -1846,6 +1861,7 @@ async function commitCreateInvoice(
quantity: 0, quantity: 0,
unit: '', unit: '',
unit_price: 0, unit_price: 0,
discount_percent: 0,
line_total: 0, line_total: 0,
vat_rate: 0, vat_rate: 0,
vat_amount: 0, vat_amount: 0,
@@ -1855,7 +1871,8 @@ async function commitCreateInvoice(
} }
} }
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate 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 const itemVat = Math.round(lineTotal * itemRate / 100 * 100) / 100
return { return {
invoice_id: invoice.id, invoice_id: invoice.id,
@@ -1865,6 +1882,7 @@ async function commitCreateInvoice(
quantity: item.quantity, quantity: item.quantity,
unit: item.unit, unit: item.unit,
unit_price: item.unit_price, unit_price: item.unit_price,
discount_percent: discountPercent,
line_total: lineTotal, line_total: lineTotal,
vat_rate: itemRate, vat_rate: itemRate,
vat_amount: itemVat, vat_amount: itemVat,
@@ -1942,7 +1960,7 @@ async function commitUpdateInvoice(
const { data: existing, error: fetchError } = await supabase const { data: existing, error: fetchError } = await supabase
.from('invoices') .from('invoices')
.select( .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('id', invoiceId)
.eq('company_id', companyId) .eq('company_id', companyId)
@@ -2003,7 +2021,7 @@ async function commitUpdateInvoice(
const { data: itemRows, error: itemsFetchError } = await supabase const { data: itemRows, error: itemsFetchError } = await supabase
.from('invoice_items') .from('invoice_items')
.select( .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) .eq('invoice_id', invoiceId)
.order('sort_order', { ascending: true }) .order('sort_order', { ascending: true })
@@ -2028,6 +2046,7 @@ async function commitUpdateInvoice(
currency: existing.currency as Currency, currency: existing.currency as Currency,
your_reference: changes.your_reference ?? existing.your_reference ?? undefined, your_reference: changes.your_reference ?? existing.your_reference ?? undefined,
our_reference: changes.our_reference ?? existing.our_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, notes: changes.notes ?? existing.notes ?? undefined,
// Not editable through this operation: fed back so the builder echoes the // Not editable through this operation: fed back so the builder echoes the
// stored values instead of clearing them. // stored values instead of clearing them.
@@ -4553,6 +4572,7 @@ async function commitCreditInvoice(
reverse_charge_text: original.reverse_charge_text, reverse_charge_text: original.reverse_charge_text,
your_reference: original.your_reference, your_reference: original.your_reference,
our_reference: original.our_reference, our_reference: original.our_reference,
invoice_marking: original.invoice_marking ?? null,
notes: reason || `Krediterar faktura ${original.invoice_number}`, notes: reason || `Krediterar faktura ${original.invoice_number}`,
credited_invoice_id: id, credited_invoice_id: id,
// Dimensions PR7: copy so the reversal nets against the same cells. // Dimensions PR7: copy so the reversal nets against the same cells.
@@ -4573,6 +4593,7 @@ async function commitCreditInvoice(
quantity: number quantity: number
unit: string unit: string
unit_price: number unit_price: number
discount_percent?: number | null
line_total: number line_total: number
vat_rate?: number vat_rate?: number
vat_amount?: number vat_amount?: number
@@ -4587,6 +4608,8 @@ async function commitCreditInvoice(
quantity: -Math.abs(item.quantity), quantity: -Math.abs(item.quantity),
unit: item.unit, unit: item.unit,
unit_price: item.unit_price, 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), line_total: -Math.abs(item.line_total),
vat_rate: item.vat_rate ?? 0, vat_rate: item.vat_rate ?? 0,
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0), vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
@@ -23,6 +23,8 @@ const InvoiceChangesSchema = z
delivery_date: isoDate.nullable().optional(), delivery_date: isoDate.nullable().optional(),
your_reference: z.string().optional(), your_reference: z.string().optional(),
our_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(), items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required').optional(),
// Replaces the whole bag; {} clears all tags. // Replaces the whole bag; {} clears all tags.
default_dimensions: DimensionsBagSchema.optional(), default_dimensions: DimensionsBagSchema.optional(),
+8
View File
@@ -3813,6 +3813,7 @@
"chip_received": "Received {date}", "chip_received": "Received {date}",
"chip_delivery": "Delivery date {date}", "chip_delivery": "Delivery date {date}",
"chip_your_reference": "Your reference: {reference}", "chip_your_reference": "Your reference: {reference}",
"chip_invoice_marking": "Marking: {marking}",
"chip_stripe_auto": "Stripe link created on send", "chip_stripe_auto": "Stripe link created on send",
"chip_payment_link": "Payment link added", "chip_payment_link": "Payment link added",
"chip_ore_off": "No öre rounding", "chip_ore_off": "No öre rounding",
@@ -3831,6 +3832,10 @@
"ready_create": "Ready to review: everything else has sensible defaults.", "ready_create": "Ready to review: everything else has sensible defaults.",
"ready_edit": "Ready to save.", "ready_edit": "Ready to save.",
"ready_self_billed": "Ready to register.", "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_set_account": "Set posting account",
"row_menu_remove_account": "Remove posting account", "row_menu_remove_account": "Remove posting account",
"row_menu_set_dimensions": "Set cost centre/project", "row_menu_set_dimensions": "Set cost centre/project",
@@ -3899,6 +3904,8 @@
"your_reference_placeholder": "Customer contact person", "your_reference_placeholder": "Customer contact person",
"our_reference_label": "Our reference", "our_reference_label": "Our reference",
"our_reference_placeholder": "Your name", "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_label": "Payment link (optional)",
"payment_link_placeholder": "https://buy.stripe.com/…", "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.", "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", "total": "Total",
"your_reference": "Your reference:", "your_reference": "Your reference:",
"our_reference": "Our reference:", "our_reference": "Our reference:",
"invoice_marking": "Marking:",
"notes_prefix": "Note: {notes}" "notes_prefix": "Note: {notes}"
}, },
"invoice_bank_setup": { "invoice_bank_setup": {
+8
View File
@@ -3813,6 +3813,7 @@
"chip_received": "Mottagen {date}", "chip_received": "Mottagen {date}",
"chip_delivery": "Leveransdatum {date}", "chip_delivery": "Leveransdatum {date}",
"chip_your_reference": "Er referens: {reference}", "chip_your_reference": "Er referens: {reference}",
"chip_invoice_marking": "Märkning: {marking}",
"chip_stripe_auto": "Stripe-länk skapas vid utskick", "chip_stripe_auto": "Stripe-länk skapas vid utskick",
"chip_payment_link": "Betalningslänk inlagd", "chip_payment_link": "Betalningslänk inlagd",
"chip_ore_off": "Ingen öresavrundning", "chip_ore_off": "Ingen öresavrundning",
@@ -3831,6 +3832,10 @@
"ready_create": "Klar att granska: allt annat har smarta förval.", "ready_create": "Klar att granska: allt annat har smarta förval.",
"ready_edit": "Klart att spara.", "ready_edit": "Klart att spara.",
"ready_self_billed": "Klar att registrera.", "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_set_account": "Ange bokföringskonto",
"row_menu_remove_account": "Ta bort bokföringskonto", "row_menu_remove_account": "Ta bort bokföringskonto",
"row_menu_set_dimensions": "Ange kostnadsställe/projekt", "row_menu_set_dimensions": "Ange kostnadsställe/projekt",
@@ -3899,6 +3904,8 @@
"your_reference_placeholder": "Kontaktperson hos kund", "your_reference_placeholder": "Kontaktperson hos kund",
"our_reference_label": "Vår referens", "our_reference_label": "Vår referens",
"our_reference_placeholder": "Ditt namn", "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_label": "Betalningslänk (valfritt)",
"payment_link_placeholder": "https://buy.stripe.com/…", "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.", "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", "total": "Totalt",
"your_reference": "Er referens:", "your_reference": "Er referens:",
"our_reference": "Vår referens:", "our_reference": "Vår referens:",
"invoice_marking": "Märkning:",
"notes_prefix": "Anteckning: {notes}" "notes_prefix": "Anteckning: {notes}"
}, },
"invoice_bank_setup": { "invoice_bank_setup": {
+4 -3
View File
@@ -110,6 +110,7 @@ Request body:
document_type?: "invoice" | "proforma" | "delivery_note", document_type?: "invoice" | "proforma" | "delivery_note",
your_reference?: string, your_reference?: string,
our_reference?: string, our_reference?: string,
invoice_marking?: string,
notes?: string, notes?: string,
payment_link_url?: string | "", payment_link_url?: string | "",
payment_link_auto?: boolean, payment_link_auto?: boolean,
@@ -124,7 +125,7 @@ Request body:
external_invoice_number?: string | "", external_invoice_number?: string | "",
self_billing_agreement_ref?: string, self_billing_agreement_ref?: string,
received_date?: 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, our_reference?: string | unknown,
notes?: string | unknown, notes?: string | unknown,
default_dimensions?: Record<string, string>, 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: Request body:
```ts ```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 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 // Reference
your_reference: string | null your_reference: string | null
our_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 // 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 // Link). Rendered as a "Betala online" button in the invoice email and as a
@@ -1431,7 +1436,12 @@ export interface InvoiceItem {
// Price // Price
unit_price: number 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 line_total: number
// Per-line VAT // Per-line VAT
@@ -1708,6 +1718,8 @@ export interface CreateInvoiceInput {
document_type?: InvoiceDocumentType document_type?: InvoiceDocumentType
your_reference?: string your_reference?: string
our_reference?: string our_reference?: string
/** Fakturamärkning: buyer-required marking, separate from your_reference. */
invoice_marking?: string
notes?: string notes?: string
/** Optional https link where the customer can pay online (e.g. a Stripe Payment Link). */ /** Optional https link where the customer can pay online (e.g. a Stripe Payment Link). */
payment_link_url?: string payment_link_url?: string
@@ -1731,6 +1743,8 @@ export interface CreateInvoiceItemInput {
quantity: number quantity: number
unit: string unit: string
unit_price: number unit_price: number
/** Percentage discount on the line (0-100). Omitted/null = 0. */
discount_percent?: number | null
vat_rate?: number vat_rate?: number
/** Source article (optional). Free-text lines omit it. */ /** Source article (optional). Free-text lines omit it. */
article_id?: string | null article_id?: string | null